diff --git a/apps/editor/index.html b/apps/editor/index.html index fa207c8..e93ddfc 100644 --- a/apps/editor/index.html +++ b/apps/editor/index.html @@ -1,2 +1,8 @@ + + +配置编辑器
diff --git a/apps/editor/package.json b/apps/editor/package.json index 7b8401b..9ae1019 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -10,7 +10,8 @@ "vue": "^3.5.17", "@demo/page-schema": "*", "@demo/materials": "*", - "@demo/renderer": "*" + "@demo/renderer": "*", + "@demo/shared": "*" }, "devDependencies": { "vite": "^7.0.6", diff --git a/apps/editor/src/main.ts b/apps/editor/src/main.ts index 03cc411..88b462a 100644 --- a/apps/editor/src/main.ts +++ b/apps/editor/src/main.ts @@ -1,6 +1,17 @@ -import { computed, createApp, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +import { + computed, + createApp, + defineComponent, + onMounted, + onUnmounted, + ref, + toRaw, + watch, +} from 'vue'; import type { Block, PageSchema, ComponentType } from '@demo/page-schema'; +import { validSchema } from '@demo/page-schema'; import { createId, defaults, labels, materialSets, type MaterialSet } from '@demo/materials'; +import type { PreviewMessage } from '@demo/shared'; import { Inspector } from './Inspector'; import './style.css'; import './device.css'; @@ -10,7 +21,7 @@ import './toast.css'; const serviceOrigin = `${location.protocol}//${location.hostname}`; const api = `${serviceOrigin}:3000/api/pages/demo-store`; const h5Url = `${serviceOrigin}:5174`; -const clone = (x: T): T => JSON.parse(JSON.stringify(x)); +const clone = (x: T): T => structuredClone(toRaw(x)); const normalize = (b: Block) => { const legacyBanner = b.type === 'banner' && !Array.isArray(b.props.slides) @@ -47,7 +58,9 @@ const App = defineComponent({ publishing = ref(false); const dragType = ref(null), dragIndex = ref(-1); - let frame: HTMLIFrameElement | undefined, toastTimer: ReturnType | undefined; + let frame: HTMLIFrameElement | undefined, + toastTimer: ReturnType | undefined, + previewTimer: ReturnType | undefined; const showToast = (message: string, type: 'success' | 'error' = 'success') => { if (toastTimer) clearTimeout(toastTimer); toast.value = { message, type }; @@ -59,6 +72,7 @@ const App = defineComponent({ }; const commit = () => { history.value.push(clone(schema.value)); + if (history.value.length > 50) history.value.shift(); future.value = []; }; const choose = (id: string) => { @@ -150,6 +164,13 @@ const App = defineComponent({ if (!response.ok) throw new Error('草稿保存失败'); saved.value = '草稿已保存'; }; + const saveDraft = async () => { + try { + await save(); + } catch (error: any) { + showToast(`保存失败 · ${error?.message || '请稍后重试'}`, 'error'); + } + }; const publish = async () => { if (publishing.value) return; publishing.value = true; @@ -167,23 +188,33 @@ const App = defineComponent({ } }; const sel = () => schema.value.blocks.find((x) => x.id === selected.value); + const postPreview = () => { + const message: PreviewMessage = { type: 'preview', schema: clone(schema.value) }; + frame?.contentWindow?.postMessage(message, h5Url); + }; watch( schema, - () => - frame?.contentWindow?.postMessage({ type: 'preview', schema: clone(schema.value) }, '*'), + () => { + if (previewTimer) clearTimeout(previewTimer); + previewTimer = setTimeout(postPreview, 100); + }, { deep: true }, ); onMounted(async () => { - const x = await fetch(api + '/draft').then((r) => r.json()); - x.blocks.forEach(normalize); - schema.value = x; + try { + const x = await fetch(api + '/draft').then((r) => r.json()); + if (!validSchema(x)) throw new Error('草稿数据不合法'); + x.blocks.forEach(normalize); + schema.value = x; + } catch (error: any) { + showToast(`草稿加载失败 · ${error?.message || '请稍后重试'}`, 'error'); + } frame = document.querySelector('iframe') || undefined; - if (frame) - frame.onload = () => - frame?.contentWindow?.postMessage({ type: 'preview', schema: clone(schema.value) }, '*'); + if (frame) frame.onload = postPreview; }); onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); + if (previewTimer) clearTimeout(previewTimer); }); return { schema, @@ -198,6 +229,7 @@ const App = defineComponent({ undo, redo, save, + saveDraft, publish, saved, device, @@ -217,7 +249,7 @@ const App = defineComponent({ }; }, template: `
-
MOSAIC H5 STUDIO
打开移动端 ↗
+
MOSAIC H5 STUDIO
打开移动端 ↗
预览 · {{device==='phone'?'390 × 844':'375 × 667'}}{{schema.title}}
diff --git a/apps/editor/tsconfig.json b/apps/editor/tsconfig.json index b3ed883..d2cf333 100644 --- a/apps/editor/tsconfig.json +++ b/apps/editor/tsconfig.json @@ -7,6 +7,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, diff --git a/apps/h5/index.html b/apps/h5/index.html index f83f8fb..52acca3 100644 --- a/apps/h5/index.html +++ b/apps/h5/index.html @@ -1,3 +1,9 @@ + + +H5 预览
diff --git a/apps/h5/package.json b/apps/h5/package.json index 9a0c035..02918d7 100644 --- a/apps/h5/package.json +++ b/apps/h5/package.json @@ -9,7 +9,8 @@ "dependencies": { "vue": "^3.5.17", "@demo/page-schema": "*", - "@demo/renderer": "*" + "@demo/renderer": "*", + "@demo/shared": "*" }, "devDependencies": { "vite": "^7.0.6", diff --git a/apps/h5/src/main.ts b/apps/h5/src/main.ts index fc5d2ac..1589e57 100644 --- a/apps/h5/src/main.ts +++ b/apps/h5/src/main.ts @@ -1,6 +1,7 @@ import { createApp, defineComponent, ref, onMounted } from 'vue'; import { PageRenderer } from '@demo/renderer'; import type { PageSchema } from '@demo/page-schema'; +import { previewOrigin, type PreviewMessage } from '@demo/shared'; import './style.css'; import './enhanced.css'; const api = `${location.protocol}//${location.hostname}:3000/api/pages/`; @@ -11,20 +12,32 @@ const App = defineComponent({ error = ref(''); onMounted(async () => { addEventListener('message', (e) => { - if (e.data?.type === 'preview') schema.value = e.data.schema; + if (e.origin !== previewOrigin()) return; + const data = e.data as PreviewMessage; + if (data?.type === 'preview') schema.value = data.schema; }); - if (!location.search.includes('preview')) + if (!location.search.includes('preview')) { + const id = new URLSearchParams(location.search).get('id') || 'demo-store'; + const cacheKey = 'page:' + id; + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed?.schema) schema.value = parsed.schema as PageSchema; + } + } catch { + /* ignore corrupt cache */ + } try { - const id = new URLSearchParams(location.search).get('id') || 'demo-store'; - const cached = sessionStorage.getItem('page:' + id); - if (cached) schema.value = JSON.parse(cached); const r = await fetch(api + id + '/published'); if (!r.ok) throw Error('该模板尚未发布'); - schema.value = await r.json(); - sessionStorage.setItem('page:' + id, JSON.stringify(schema.value)); + const next: PageSchema = await r.json(); + schema.value = next; + sessionStorage.setItem(cacheKey, JSON.stringify({ version: next.version, schema: next })); } catch (e: any) { - error.value = e.message; + if (!schema.value) error.value = e.message; } + } }); return { schema, error }; }, diff --git a/apps/h5/tsconfig.json b/apps/h5/tsconfig.json index b3ed883..d2cf333 100644 --- a/apps/h5/tsconfig.json +++ b/apps/h5/tsconfig.json @@ -7,6 +7,7 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, diff --git a/apps/server/package.json b/apps/server/package.json index c59f442..89c42c0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "@demo/server", "type": "module", "scripts": { - "dev": "node src/index.mjs" + "dev": "node --watch src/index.mjs" }, "dependencies": { "express": "^5.1.0", diff --git a/apps/server/src/index.mjs b/apps/server/src/index.mjs index 518fc69..74c678d 100644 --- a/apps/server/src/index.mjs +++ b/apps/server/src/index.mjs @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const app = express(), file = path.resolve('data.json'); @@ -18,26 +19,33 @@ const seed = { versions: [], }; const read = () => { - const x = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : structuredClone(seed); + let x; + if (fs.existsSync(file)) { + try { + x = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + console.error('data.json 解析失败,已回退到种子数据', error); + x = structuredClone(seed); + } + } else { + x = structuredClone(seed); + } x.versions ||= x.published ? [x.published] : []; return x; }; -const write = (x) => fs.writeFileSync(file, JSON.stringify(x, null, 2)); -const allowed = new Set([ - 'banner', - 'title', - 'text', - 'image', - 'button', - 'product-list', - 'divider', - 'spacer', - 'two-column', - 'benefit-product', - 'benefit-actions', - 'benefit-notice', - 'benefit-product-list', -]); +const write = (x) => { + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(x, null, 2)); + fs.renameSync(tmp, file); +}; +const allowed = new Set( + JSON.parse( + fs.readFileSync( + fileURLToPath(new URL('../../../packages/page-schema/component-types.json', import.meta.url)), + 'utf8', + ), + ), +); const isSafe = (s) => s && typeof s.id === 'string' && @@ -60,8 +68,9 @@ app.put('/api/pages/:id/draft', (q, s) => { s.json(x.draft); }); app.post('/api/pages/:id/publish', (q, s) => { - const x = read(), - next = (x.versions.at(-1)?.version || 0) + 1, + const x = read(); + if (!isSafe(x.draft)) return s.status(400).json({ error: 'draft invalid' }); + const next = (x.versions.at(-1)?.version || 0) + 1, snapshot = structuredClone({ ...x.draft, id: q.params.id, @@ -69,6 +78,7 @@ app.post('/api/pages/:id/publish', (q, s) => { updatedAt: new Date().toISOString(), }); x.versions.push(snapshot); + x.versions = x.versions.slice(-20); x.published = snapshot; write(x); s.json(x.published); diff --git a/package-lock.json b/package-lock.json index e84c2d3..a5f455b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@demo/materials": "*", "@demo/page-schema": "*", "@demo/renderer": "*", + "@demo/shared": "*", "vue": "^3.5.17" }, "devDependencies": { @@ -42,6 +43,7 @@ "dependencies": { "@demo/page-schema": "*", "@demo/renderer": "*", + "@demo/shared": "*", "vue": "^3.5.17" }, "devDependencies": { @@ -3861,7 +3863,10 @@ }, "packages/shared": { "name": "@demo/shared", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "@demo/page-schema": "*" + } } } } diff --git a/packages/page-schema/component-types.json b/packages/page-schema/component-types.json new file mode 100644 index 0000000..c528bf8 --- /dev/null +++ b/packages/page-schema/component-types.json @@ -0,0 +1,15 @@ +[ + "banner", + "title", + "text", + "image", + "button", + "product-list", + "divider", + "spacer", + "two-column", + "benefit-product", + "benefit-actions", + "benefit-notice", + "benefit-product-list" +] diff --git a/packages/page-schema/src/index.ts b/packages/page-schema/src/index.ts index 7b1ae83..980dff2 100644 --- a/packages/page-schema/src/index.ts +++ b/packages/page-schema/src/index.ts @@ -1,17 +1,33 @@ -export type ComponentType = - | 'banner' - | 'title' - | 'text' - | 'image' - | 'button' - | 'product-list' - | 'divider' - | 'spacer' - | 'two-column' - | 'benefit-product' - | 'benefit-actions' - | 'benefit-notice' - | 'benefit-product-list'; +import componentTypesMirror from '../component-types.json'; + +// Canonical list + compile-time union in one place. +// component-types.json is the runtime mirror read by the Node server; the guard +// below fails loudly (editor / H5 / e2e) if the two ever drift out of sync. +export const COMPONENT_TYPES = [ + 'banner', + 'title', + 'text', + 'image', + 'button', + 'product-list', + 'divider', + 'spacer', + 'two-column', + 'benefit-product', + 'benefit-actions', + 'benefit-notice', + 'benefit-product-list', +] as const; +export type ComponentType = (typeof COMPONENT_TYPES)[number]; + +{ + const canonical = [...COMPONENT_TYPES].sort().join(','); + const mirror = [...(componentTypesMirror as string[])].sort().join(','); + if (canonical !== mirror) + throw new Error( + `component-types.json out of sync with COMPONENT_TYPES:\n json: [${mirror}]\n const: [${canonical}]`, + ); +} export interface Block { id: string; type: ComponentType; @@ -35,23 +51,7 @@ export function validSchema(x: unknown): x is PageSchema { Array.isArray(p.blocks) && p.blocks.every( (b) => - b && - typeof b.id === 'string' && - [ - 'banner', - 'title', - 'text', - 'image', - 'button', - 'product-list', - 'divider', - 'spacer', - 'two-column', - 'benefit-product', - 'benefit-actions', - 'benefit-notice', - 'benefit-product-list', - ].includes(b.type), + b && typeof b.id === 'string' && (COMPONENT_TYPES as readonly string[]).includes(b.type), ) ); } diff --git a/packages/shared/package.json b/packages/shared/package.json index dabecde..8476ae0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,5 +2,8 @@ "name": "@demo/shared", "version": "1.0.0", "type": "module", - "exports": "./src/index.ts" + "exports": "./src/index.ts", + "dependencies": { + "@demo/page-schema": "*" + } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5e1df64..e2eb50d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,5 @@ -export type PreviewMessage = { type: 'select'; id: string } | { type: 'ready' }; -export const send = (m: PreviewMessage) => parent.postMessage(m, '*'); +import type { PageSchema } from '@demo/page-schema'; + +export type PreviewMessage = { type: 'preview'; schema: PageSchema }; +export const EDITOR_PORT = 5173; +export const previewOrigin = () => `${location.protocol}//${location.hostname}:${EDITOR_PORT}`;