feat(editor): 集成共享包支持并优化草稿处理

- 新增 @demo/shared 依赖并引入类型支持
- 使用 structuredClone 替代 JSON 方法提升性能和安全性
- 增加草稿保存错误提示机制,提升用户体验
- 实现草稿保存历史长度限制,防止内存泄漏
- 实现编辑器到预览端的实时预览数据推送,减少频繁渲染
- 修复编辑器和预览端消息来源校验,提升安全性
- 服务端新增组件类型校验,数据读取添加异常处理并增量保留版本历史
- 使用临时文件替换写入逻辑,防止数据写入破损
- 页面静态资源头部新增字符集与图标配置,改善浏览器加载体验
- 构建配置允许解析 JSON 模块,方便配置文件导入
- 统一组件类型定义来源,确保类型安全和运行时数据一致性
This commit is contained in:
wgm 2026-07-17 17:23:42 +08:00
parent 976b48a09d
commit 882961f45b
15 changed files with 174 additions and 77 deletions

View File

@ -1,2 +1,8 @@
<meta charset="utf-8" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%23234a3a'/><rect x='4' y='4' width='8' height='8' rx='1.5' fill='%23e9c46a'/></svg>"
/>
<title>配置编辑器</title>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

View File

@ -10,7 +10,8 @@
"vue": "^3.5.17",
"@demo/page-schema": "*",
"@demo/materials": "*",
"@demo/renderer": "*"
"@demo/renderer": "*",
"@demo/shared": "*"
},
"devDependencies": {
"vite": "^7.0.6",

View File

@ -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 = <T>(x: T): T => JSON.parse(JSON.stringify(x));
const clone = <T>(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<ComponentType | null>(null),
dragIndex = ref(-1);
let frame: HTMLIFrameElement | undefined, toastTimer: ReturnType<typeof setTimeout> | undefined;
let frame: HTMLIFrameElement | undefined,
toastTimer: ReturnType<typeof setTimeout> | undefined,
previewTimer: ReturnType<typeof setTimeout> | 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 () => {
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: `<div class="shell">
<header><div class="brand">MOSAIC <small>H5 STUDIO</small></div><div class="library-switch" role="group" aria-label="组件库切换"><button :class="{active:materialSet==='marketing'}" @click="materialSet='marketing'"></button><button :class="{active:materialSet==='benefits'}" @click="materialSet='benefits'"> / <small>NEW</small></button></div><div class="tools"><button @click="undo"> </button><button @click="redo"> </button><select v-model="device" aria-label="设备尺寸"><option value="phone">390 × 844</option><option value="compact">375 × 667</option></select><button @click="save">稿</button><button class="publish" :disabled="publishing" @click="publish">{{publishing?'发布中…':'发布'}}</button><a :href="h5Url+'/?id=demo-store'" target="_blank"> </a></div></header>
<header><div class="brand">MOSAIC <small>H5 STUDIO</small></div><div class="library-switch" role="group" aria-label="组件库切换"><button :class="{active:materialSet==='marketing'}" @click="materialSet='marketing'"></button><button :class="{active:materialSet==='benefits'}" @click="materialSet='benefits'"> / <small>NEW</small></button></div><div class="tools"><button @click="undo"> </button><button @click="redo"> </button><select v-model="device" aria-label="设备尺寸"><option value="phone">390 × 844</option><option value="compact">375 × 667</option></select><button @click="saveDraft">稿</button><button class="publish" :disabled="publishing" @click="publish">{{publishing?'发布中…':'发布'}}</button><a :href="h5Url+'/?id=demo-store'" target="_blank"> </a></div></header>
<aside class="materials" :class="{'benefit-library':materialSet==='benefits'}"><p class="eyebrow">{{materialSet==='marketing'?'营销组件库':'数字权益组件库'}}</p><h2>{{materialSet==='marketing'?'搭建活动页面':'配置直充与卡密'}}</h2><div v-if="materialSet==='benefits'" class="benefit-intro"><b></b><span> H5</span><button type="button" @click="addBenefitsPreset"> </button></div><div class="material-grid"><button v-for="type in activeMaterials" :key="type" :aria-label="labels[type]" draggable="true" @dragstart="beginMaterial(type)" @click="add(type)"><span v-if="materialSet==='benefits'" class="material-icon" aria-hidden="true">{{type==='benefit-product'?'▣':type==='benefit-actions'?'↗':type==='benefit-notice'?'¶':'☷'}}</span>{{labels[type]}}</button></div><p class="eyebrow layers"> · </p><ol><li v-for="(b,i) in schema.blocks" :key="b.id" draggable="true" @dragstart="beginLayer(i)" @dragover.prevent @drop="dropLayer(i)" :class="{active:selected===b.id}" @click="choose(b.id)"><span>{{i+1}} · {{labels[b.type]}}</span><span><button @click.stop="move(-1)"></button><button @click.stop="move(1)"></button></span></li></ol></aside>
<section class="stage"><div class="stage-top"><span> · {{device==='phone'?'390 × 844':'375 × 667'}}</span><b>{{schema.title}}</b></div><div class="phone" :class="[device,{dragging:!!dragType}]" @dragover.prevent @drop="dropCanvas"><iframe :src="h5Url+'/?preview=1'" title="H5 实时预览"></iframe></div></section>
<aside class="inspector"><Inspector :block="sel()" :label="sel()?labels[sel().type]:''" @checkpoint="commit" @copy="copy" @remove="remove"/><p class="saved">{{saved}}</p></aside>

View File

@ -7,6 +7,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,

View File

@ -1,3 +1,9 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect x='4' y='1' width='8' height='14' rx='2' fill='%23234a3a'/><rect x='5.5' y='3' width='5' height='9' fill='%23ffffff'/></svg>"
/>
<title>H5 预览</title>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

View File

@ -9,7 +9,8 @@
"dependencies": {
"vue": "^3.5.17",
"@demo/page-schema": "*",
"@demo/renderer": "*"
"@demo/renderer": "*",
"@demo/shared": "*"
},
"devDependencies": {
"vite": "^7.0.6",

View File

@ -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,19 +12,31 @@ 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'))
try {
if (!location.search.includes('preview')) {
const id = new URLSearchParams(location.search).get('id') || 'demo-store';
const cached = sessionStorage.getItem('page:' + id);
if (cached) schema.value = JSON.parse(cached);
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 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 };

View File

@ -7,6 +7,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,

View File

@ -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",

View File

@ -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);

7
package-lock.json generated
View File

@ -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": "*"
}
}
}
}

View File

@ -0,0 +1,15 @@
[
"banner",
"title",
"text",
"image",
"button",
"product-list",
"divider",
"spacer",
"two-column",
"benefit-product",
"benefit-actions",
"benefit-notice",
"benefit-product-list"
]

View File

@ -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),
)
);
}

View File

@ -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": "*"
}
}

View File

@ -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}`;