feat(editor): 集成共享包支持并优化草稿处理
- 新增 @demo/shared 依赖并引入类型支持 - 使用 structuredClone 替代 JSON 方法提升性能和安全性 - 增加草稿保存错误提示机制,提升用户体验 - 实现草稿保存历史长度限制,防止内存泄漏 - 实现编辑器到预览端的实时预览数据推送,减少频繁渲染 - 修复编辑器和预览端消息来源校验,提升安全性 - 服务端新增组件类型校验,数据读取添加异常处理并增量保留版本历史 - 使用临时文件替换写入逻辑,防止数据写入破损 - 页面静态资源头部新增字符集与图标配置,改善浏览器加载体验 - 构建配置允许解析 JSON 模块,方便配置文件导入 - 统一组件类型定义来源,确保类型安全和运行时数据一致性
This commit is contained in:
parent
976b48a09d
commit
882961f45b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"vue": "^3.5.17",
|
||||
"@demo/page-schema": "*",
|
||||
"@demo/materials": "*",
|
||||
"@demo/renderer": "*"
|
||||
"@demo/renderer": "*",
|
||||
"@demo/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.0.6",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
"dependencies": {
|
||||
"vue": "^3.5.17",
|
||||
"@demo/page-schema": "*",
|
||||
"@demo/renderer": "*"
|
||||
"@demo/renderer": "*",
|
||||
"@demo/shared": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.0.6",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
[
|
||||
"banner",
|
||||
"title",
|
||||
"text",
|
||||
"image",
|
||||
"button",
|
||||
"product-list",
|
||||
"divider",
|
||||
"spacer",
|
||||
"two-column",
|
||||
"benefit-product",
|
||||
"benefit-actions",
|
||||
"benefit-notice",
|
||||
"benefit-product-list"
|
||||
]
|
||||
|
|
@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue