feat: add low-code H5 editor demo

This commit is contained in:
wgm 2026-07-17 15:07:50 +08:00
commit 97793b77cf
38 changed files with 6770 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
node_modules/
dist/
data.json
test-results/
playwright-report/
.DS_Store
.env
.env.*
!.env.example
*.log

6
.prettierignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
**/dist
test-results
playwright-report
apps/server/data.json
package-lock.json

12
.prettierrc.json Normal file
View File

@ -0,0 +1,12 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

75
README.md Normal file
View File

@ -0,0 +1,75 @@
# Mosaic Low-code H5 Demo
## Run
```bash
cd /Users/wgm/lowcode_test
npm install
npm run dev
```
- Editor: http://localhost:5173
- Published H5: http://localhost:5174/?id=demo-store
- API: http://localhost:3000
### LAN access
The development services listen on all network interfaces. Start them normally with `npm run dev`, then replace `localhost` with the computer's LAN IP, for example:
- Editor: `http://192.168.7.6:5173`
- Published H5: `http://192.168.7.6:5174/?id=demo-store`
- API: `http://192.168.7.6:3000`
The editor, iframe preview and H5 API automatically follow the hostname used in the browser. Devices must be on the same local network, and the operating-system firewall must allow Node.js connections.
## Verify
```bash
npm run build
npm run check:h5
npm run test:e2e
```
`check:h5` builds the standalone H5 and rejects known editor/drag dependencies. The Playwright flow resets its own draft, drags in a component, publishes it, confirms draft changes do not change mobile output, republishes, and confirms the mobile page updates.
## Material libraries
Use the segmented switch in the editor header to change the left material palette. Switching libraries never removes blocks already on the page, so marketing and digital-benefit components can also be mixed in one Schema.
- `营销组件`: keeps the original Banner, content, image, button, product-list and layout materials.
- `直充 / 卡密`: provides benefit product cards, redemption actions, redemption notices and benefit product lists.
- `添加业务示例组合`: inserts one of each digital-benefit component as an editable starter page.
## Architecture
- `apps/editor`: Vue 3 editor with material and layer panels, iframe canvas, properties, history, draft and publication actions.
- `apps/h5`: standalone H5 entry that reads only the published endpoint and keeps a session Schema cache.
- `apps/server`: Express API with JSON persistence, immutable publication snapshots and version lookup endpoints.
- `packages/page-schema`: Schema types, component whitelist and runtime helpers.
- `packages/materials`: centrally registered material labels and defaults.
- `packages/renderer`: shared safe Renderer used by preview and mobile H5.
- `packages/shared`: shared preview message contract.
The Demo intentionally uses vertical flow layout and limited two-column composition. It does not execute Schema JavaScript, dynamic component paths, or arbitrary API definitions. Interactions accept only `http(s)` or site-relative links.
## Component editing
- Banner: title, subtitle, multiple images and alt text, carousel toggle, looping, autoplay interval, height, overlay, alignment and text color.
- Title: text, size, weight, alignment, padding and color.
- Text: multiline content, size, line height, alignment, padding, foreground and background colors.
- Image: URL, alt text, height, crop mode and radius.
- Button: text, sizing, radius, colors and safe link interaction.
- Product list: add/remove products, name, price, image, columns, gap, image height and colors.
- Divider: thickness, spacing, line style and color.
- Spacer: live height slider and background color.
- Two-column: independent content, gap, height, padding, alignment and per-column backgrounds.
- Benefit product card: direct-recharge/card-code mode, name, description, price, image, badge, VIP marker, action copy and brand colors.
- Redemption actions: primary/secondary action copy, visibility, radius and colors.
- Redemption notice: section label, title, multiline instructions and accent styling.
- Benefit product list: add/remove benefits, delivery mode, name, price, count display and card styling.
## Demo boundaries
- JSON persistence is single-process and intended for local demonstration, not concurrent production writes.
- Authentication, tenancy, asset upload/CDN conversion, collaborative editing, and database migrations are outside this Demo.
- Images use fixed dimensions and native lazy loading; a production image pipeline should generate WebP/AVIF variants.

2
apps/editor/index.html Normal file
View File

@ -0,0 +1,2 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

19
apps/editor/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "@demo/editor",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"vue": "^3.5.17",
"@demo/page-schema": "*",
"@demo/materials": "*",
"@demo/renderer": "*"
},
"devDependencies": {
"vite": "^7.0.6",
"@vitejs/plugin-vue": "^6.0.0"
}
}

View File

@ -0,0 +1,98 @@
import { defineComponent, type PropType } from 'vue';
import type { Block } from '@demo/page-schema';
import { interactiveTypes } from '@demo/materials';
export const Inspector = defineComponent({
props: { block: Object as PropType<Block | undefined>, label: String },
emits: ['checkpoint', 'copy', 'remove'],
setup(props, { emit }) {
const items = () =>
Array.isArray((props.block?.props as any)?.items) ? (props.block!.props as any).items : [];
const bannerSlides = () =>
Array.isArray((props.block?.props as any)?.slides) ? (props.block!.props as any).slides : [];
const benefitItems = () =>
Array.isArray((props.block?.props as any)?.items) ? (props.block!.props as any).items : [];
const addProduct = () => {
emit('checkpoint');
items().push({ name: '新商品', price: '¥ 0', image: '' });
};
const removeProduct = (index: number) => {
emit('checkpoint');
items().splice(index, 1);
};
const addBannerSlide = () => {
emit('checkpoint');
bannerSlides().push({ image: '', alt: '' });
};
const removeBannerSlide = (index: number) => {
emit('checkpoint');
if (bannerSlides().length > 1) bannerSlides().splice(index, 1);
};
const addBenefitProduct = () => {
emit('checkpoint');
benefitItems().push({ name: '新权益商品', price: '¥0.01', mode: '直充' });
};
const removeBenefitProduct = (index: number) => {
emit('checkpoint');
if (benefitItems().length > 1) benefitItems().splice(index, 1);
};
const interactive = () => !!props.block && interactiveTypes.includes(props.block.type);
return {
items,
bannerSlides,
addProduct,
removeProduct,
addBannerSlide,
removeBannerSlide,
benefitItems,
addBenefitProduct,
removeBenefitProduct,
interactive,
};
},
template: `<div v-if="block" class="property-form" @focusin="$emit('checkpoint')">
<div class="property-title"><div><p class="eyebrow"></p><h2>{{label}}</h2></div><span class="type-chip">{{block.type}}</span></div>
<section class="property-section">
<h3><span></span><small></small></h3>
<template v-if="block.type==='banner'">
<label><input aria-label="Banner 主标题" v-model="block.props.title"></label><label><input aria-label="Banner 副标题" v-model="block.props.subtitle"></label>
<div class="banner-slide-editor"><article v-for="(slide,index) in bannerSlides()" :key="index"><div class="item-head"><b> {{index+1}}</b><button type="button" @click="removeBannerSlide(index)" :disabled="bannerSlides().length===1"></button></div><label> URL<input :aria-label="'Banner 图片 '+(index+1)" v-model="slide.image" placeholder="https://..."></label><label><input :aria-label="'Banner 图片 '+(index+1)+' 说明'" v-model="slide.alt" placeholder="描述图片内容"></label></article><button type="button" class="add-item" @click="addBannerSlide"> </button></div>
<div class="carousel-settings"><label class="check-field"><input aria-label="开启轮播" type="checkbox" v-model="block.props.enableCarousel"></label><template v-if="block.props.enableCarousel"><label class="check-field"><input aria-label="循环播放" type="checkbox" v-model="block.props.loop"></label><label class="check-field"><input aria-label="自动轮播" type="checkbox" v-model="block.props.autoplay"></label><label v-if="block.props.autoplay"><input aria-label="自动轮播时间" type="number" min="1" max="60" step="1" v-model.number="block.props.autoplaySeconds"></label><p v-if="bannerSlides().length<2" class="safe-note"></p></template></div>
</template>
<label v-else-if="block.type==='title'"><input aria-label="标题文字" v-model="block.props.text"></label>
<label v-else-if="block.type==='text'"><textarea aria-label="正文内容" rows="5" v-model="block.props.text"></textarea></label>
<template v-else-if="block.type==='image'"><label> URL<input aria-label="图片 URL" v-model="block.props.src"></label><label><input aria-label="图片说明" v-model="block.props.alt"></label></template>
<label v-else-if="block.type==='button'"><input aria-label="按钮文字" v-model="block.props.text"></label>
<div v-else-if="block.type==='product-list'" class="product-editor">
<article v-for="(item,index) in items()" :key="index"><div class="item-head"><b> {{index+1}}</b><button type="button" @click="removeProduct(index)" :disabled="items().length===1"></button></div>
<label><input :aria-label="'商品 '+(index+1)+' 名称'" v-model="item.name"></label><label><input :aria-label="'商品 '+(index+1)+' 价格'" v-model="item.price"></label><label> URL<input :aria-label="'商品 '+(index+1)+' 图片'" v-model="item.image"></label>
</article><button type="button" class="add-item" @click="addProduct"> </button>
</div>
<p v-else-if="block.type==='divider'" class="field-hint">线线</p>
<p v-else-if="block.type==='spacer'" class="field-hint"></p>
<div v-else-if="block.type==='two-column'" class="column-editor"><p class="field-hint"></p><label><span class="column-key"></span><textarea aria-label="左栏内容" rows="3" v-model="block.props.left"></textarea></label><label><span class="column-key"></span><textarea aria-label="右栏内容" rows="3" v-model="block.props.right"></textarea></label></div>
<template v-else-if="block.type==='benefit-product'"><label><select aria-label="权益交付方式" v-model="block.props.deliveryMode"><option value="direct"></option><option value="code"></option></select></label><div class="field-grid"><label><input aria-label="权益商品名称" v-model="block.props.name"></label><label><input aria-label="权益业务标签" v-model="block.props.badge"></label></div><label><input aria-label="权益商品副标题" v-model="block.props.subtitle"></label><div class="field-grid"><label><input aria-label="权益商品价格" v-model="block.props.price"></label><label>线<input aria-label="权益商品划线价" v-model="block.props.originalPrice"></label></div><label> URL<input aria-label="权益商品图片" v-model="block.props.image" placeholder="https://..."></label><label><input aria-label="权益商品图片说明" v-model="block.props.imageAlt"></label><label><input aria-label="权益卡片按钮" v-model="block.props.buttonText"></label><div class="field-grid"><label class="check-field compact-check"><input type="checkbox" v-model="block.props.showVip"> VIP </label><label class="check-field compact-check"><input type="checkbox" v-model="block.props.showOriginalPrice">线</label></div></template>
<template v-else-if="block.type==='benefit-actions'"><label><input aria-label="兑换主按钮文案" v-model="block.props.primaryText"></label><label><input aria-label="兑换次按钮文案" v-model="block.props.secondaryText"></label><label class="check-field"><input type="checkbox" v-model="block.props.showSecondary"></label></template>
<template v-else-if="block.type==='benefit-notice'"><label><input aria-label="兑换说明标签" v-model="block.props.eyebrow"></label><label><input aria-label="兑换说明标题" v-model="block.props.title"></label><label><textarea aria-label="兑换说明内容" rows="7" v-model="block.props.content" placeholder="每行填写一条说明"></textarea></label></template>
<div v-else-if="block.type==='benefit-product-list'" class="product-editor benefit-product-editor"><label><input aria-label="权益列表标题" v-model="block.props.title"></label><article v-for="(item,index) in benefitItems()" :key="index"><div class="item-head"><b> {{index+1}}</b><button type="button" @click="removeBenefitProduct(index)" :disabled="benefitItems().length===1"></button></div><label><input :aria-label="'权益 '+(index+1)+' 名称'" v-model="item.name"></label><div class="field-grid"><label><input :aria-label="'权益 '+(index+1)+' 价格'" v-model="item.price"></label><label><select :aria-label="'权益 '+(index+1)+' 交付方式'" v-model="item.mode"><option value="直充"></option><option value="卡密"></option></select></label></div></article><button type="button" class="add-item" @click="addBenefitProduct"> </button></div>
</section>
<section class="property-section appearance-section">
<h3><span></span><small></small></h3>
<template v-if="block.type==='banner'"><div class="field-grid"><label><input aria-label="Banner 高度" type="number" min="100" max="500" v-model.number="block.props.height"></label><label><input aria-label="Banner 遮罩" type="number" min="0" max="1" step="0.05" v-model.number="block.props.overlayOpacity"></label></div><label><select aria-label="Banner 文字对齐" v-model="block.props.textAlign"><option value="left"></option><option value="center"></option><option value="right"></option></select></label><label><div class="color-field"><input type="color" v-model="block.style.color"><input aria-label="Banner 文字颜色" v-model="block.style.color"></div></label></template>
<template v-else-if="block.type==='title'"><div class="field-grid"><label><input aria-label="标题字号" type="number" min="14" max="64" v-model.number="block.props.fontSize"></label><label><select aria-label="标题字重" v-model="block.props.fontWeight"><option value="500"></option><option value="600"></option><option value="700"></option><option value="800"></option></select></label></div><label><select aria-label="标题对齐" v-model="block.props.textAlign"><option value="left"></option><option value="center"></option><option value="right"></option></select></label><div class="field-grid"><label><input type="number" min="0" max="80" v-model.number="block.props.paddingX"></label><label><input type="number" min="0" max="80" v-model.number="block.props.paddingY"></label></div><label><div class="color-field"><input type="color" v-model="block.style.color"><input aria-label="标题颜色" v-model="block.style.color"></div></label></template>
<template v-else-if="block.type==='text'"><div class="field-grid"><label><input aria-label="正文字号" type="number" min="10" max="40" v-model.number="block.props.fontSize"></label><label><input aria-label="正文行高" type="number" min="1" max="3" step="0.1" v-model.number="block.props.lineHeight"></label></div><label><select aria-label="正文对齐" v-model="block.props.textAlign"><option value="left"></option><option value="center"></option><option value="right"></option></select></label><div class="field-grid"><label><input type="number" min="0" max="80" v-model.number="block.props.paddingX"></label><label><input type="number" min="0" max="80" v-model.number="block.props.paddingY"></label></div><label><div class="color-field"><input type="color" v-model="block.style.color"><input aria-label="正文颜色" v-model="block.style.color"></div></label><label><div class="color-field"><input type="color" v-model="block.style.backgroundColor"><input aria-label="正文背景颜色" v-model="block.style.backgroundColor"></div></label></template>
<template v-else-if="block.type==='image'"><div class="field-grid"><label><input aria-label="图片高度" type="number" min="60" max="800" v-model.number="block.props.height"></label><label><input aria-label="图片圆角" type="number" min="0" max="80" v-model.number="block.props.borderRadius"></label></div><label><select aria-label="图片填充方式" v-model="block.props.objectFit"><option value="cover"></option><option value="contain"></option><option value="fill"></option></select></label></template>
<template v-else-if="block.type==='button'"><div class="field-grid"><label><input aria-label="按钮圆角" type="number" min="0" max="40" v-model.number="block.props.borderRadius"></label><label><input aria-label="按钮高度" type="number" min="6" max="30" v-model.number="block.props.paddingY"></label></div><label class="check-field"><input type="checkbox" v-model="block.props.fullWidth"></label><label><div class="color-field"><input type="color" v-model="block.style.color"><input aria-label="按钮文字颜色" v-model="block.style.color"></div></label><label><div class="color-field"><input type="color" v-model="block.style.backgroundColor"><input aria-label="按钮背景颜色" v-model="block.style.backgroundColor"></div></label></template>
<template v-else-if="block.type==='product-list'"><div class="field-grid"><label><select aria-label="商品列数" v-model.number="block.props.columns"><option :value="1"></option><option :value="2"></option></select></label><label><input aria-label="商品间距" type="number" min="0" max="40" v-model.number="block.props.gap"></label></div><div class="field-grid"><label><input aria-label="商品图片高度" type="number" min="60" max="400" v-model.number="block.props.imageHeight"></label><label class="check-field compact-check"><input type="checkbox" v-model="block.props.showImage"></label></div><label><div class="color-field"><input type="color" v-model="block.props.priceColor"><input aria-label="商品价格颜色" v-model="block.props.priceColor"></div></label><label><div class="color-field"><input type="color" v-model="block.style.backgroundColor"><input aria-label="商品区背景颜色" v-model="block.style.backgroundColor"></div></label></template>
<template v-else-if="block.type==='divider'"><div class="field-grid"><label><input aria-label="分割线粗细" type="number" min="1" max="12" v-model.number="block.props.thickness"></label><label><input aria-label="分割线上下间距" type="number" min="0" max="100" v-model.number="block.props.marginY"></label></div><div class="field-grid"><label><input aria-label="分割线左右间距" type="number" min="0" max="100" v-model.number="block.props.marginX"></label><label>线<select aria-label="分割线线型" v-model="block.props.style"><option value="solid">线</option><option value="dashed">线</option><option value="dotted">线</option></select></label></div><label>线<div class="color-field"><input type="color" v-model="block.props.color"><input aria-label="分割线颜色" v-model="block.props.color"></div></label></template>
<template v-else-if="block.type==='spacer'"><label><input aria-label="间距高度" type="range" min="4" max="240" v-model.number="block.props.height"><output>{{block.props.height}} px</output></label><label><div class="color-field"><input type="color" v-model="block.props.backgroundColor"><input aria-label="间距背景颜色" v-model="block.props.backgroundColor"></div></label></template>
<template v-else-if="block.type==='two-column'"><div class="field-grid"><label><input aria-label="双列间距" type="number" min="0" max="60" v-model.number="block.props.gap"></label><label><input aria-label="双列高度" type="number" min="30" max="400" v-model.number="block.props.minHeight"></label></div><div class="field-grid"><label><input type="number" min="0" max="80" v-model.number="block.props.paddingX"></label><label><input type="number" min="0" max="80" v-model.number="block.props.paddingY"></label></div><label><select aria-label="双列文字对齐" v-model="block.props.textAlign"><option value="left"></option><option value="center"></option><option value="right"></option></select></label><label><div class="color-field"><input type="color" v-model="block.props.leftBackground"><input aria-label="左栏背景" v-model="block.props.leftBackground"></div></label><label><div class="color-field"><input type="color" v-model="block.props.rightBackground"><input aria-label="右栏背景" v-model="block.props.rightBackground"></div></label></template>
<template v-else-if="block.type==='benefit-product'"><label><input aria-label="权益卡片圆角" type="number" min="0" max="36" v-model.number="block.props.borderRadius"></label><label><div class="color-field"><input type="color" v-model="block.props.accentColor"><input aria-label="权益动作颜色" v-model="block.props.accentColor"></div></label><label><div class="color-field"><input type="color" v-model="block.props.priceColor"><input aria-label="权益价格颜色" v-model="block.props.priceColor"></div></label><label><div class="color-field"><input type="color" v-model="block.style.backgroundColor"><input aria-label="权益卡片背景" v-model="block.style.backgroundColor"></div></label></template>
<template v-else-if="block.type==='benefit-actions'"><label><input aria-label="兑换按钮圆角" type="number" min="0" max="30" v-model.number="block.props.borderRadius"></label><label><div class="color-field"><input type="color" v-model="block.props.primaryColor"><input aria-label="兑换主按钮颜色" v-model="block.props.primaryColor"></div></label><label><div class="color-field"><input type="color" v-model="block.props.secondaryColor"><input aria-label="兑换次按钮颜色" v-model="block.props.secondaryColor"></div></label></template>
<template v-else-if="block.type==='benefit-notice'"><label><input aria-label="兑换说明圆角" type="number" min="0" max="36" v-model.number="block.props.borderRadius"></label><label><div class="color-field"><input type="color" v-model="block.props.accentColor"><input aria-label="兑换说明强调颜色" v-model="block.props.accentColor"></div></label><label><div class="color-field"><input type="color" v-model="block.style.color"><input aria-label="兑换说明文字颜色" v-model="block.style.color"></div></label></template>
<template v-else-if="block.type==='benefit-product-list'"><label><input aria-label="权益列表圆角" type="number" min="0" max="36" v-model.number="block.props.borderRadius"></label><label class="check-field"><input type="checkbox" v-model="block.props.showCount"></label><label><div class="color-field"><input type="color" v-model="block.props.priceColor"><input aria-label="权益列表价格颜色" v-model="block.props.priceColor"></div></label></template>
</section>
<section v-if="interactive()" class="property-section interaction-section"><h3><span></span><small></small></h3><label><select aria-label="点击行为" v-model="block.interaction.type"><option value="none"></option><option value="link"></option></select></label><label v-if="block.interaction.type==='link'"><input aria-label="目标链接" v-model="block.interaction.url" placeholder="https:// 或 /path"></label><p v-if="block.interaction.type==='link'" class="safe-note"> HTTPSHTTP </p></section>
<div class="actions"><button type="button" @click="$emit('copy')"></button><button type="button" class="danger" @click="$emit('remove')"></button></div>
</div><div v-else class="empty"><b></b><span></span></div>`,
});

View File

@ -0,0 +1,32 @@
.phone {
transition:
width 0.2s,
height 0.2s;
}
.phone.compact {
width: 375px;
height: 667px;
}
.phone {
position: relative;
}
.phone.dragging::after {
content: '释放以添加组件';
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #173d3acc;
color: #fff;
font: 700 14px Manrope;
z-index: 3;
border: 2px dashed #dda952;
}
.tools select {
margin: 0;
width: auto;
padding: 7px;
background: #204d49;
color: #f6f4ec;
border-color: #557570;
}

View File

@ -0,0 +1,266 @@
.shell {
grid-template-columns: 270px minmax(430px, 1fr) 360px;
}
.inspector {
padding: 0;
background: #f8faf6;
}
.property-form {
padding: 22px 20px 28px;
}
.property-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 15px;
border-bottom: 1px solid #dce3dc;
}
.property-title h2 {
margin: 2px 0 0;
font-size: 19px;
letter-spacing: -0.3px;
}
.type-chip {
padding: 4px 7px;
border: 1px solid #b9cbc5;
border-radius: 3px;
color: #58736d;
font: 9px 'DM Mono';
letter-spacing: 0.3px;
}
.property-section {
margin-top: 16px;
padding: 15px;
background: #fff;
border: 1px solid #e0e6df;
border-radius: 6px;
box-shadow: 0 2px 8px #173d3a08;
}
.property-section h3 {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0 0 13px;
color: #173d3a;
font-size: 13px;
}
.property-section h3 small {
color: #879994;
font: 9px 'DM Mono';
font-weight: 400;
}
.property-section label {
margin: 11px 0;
font-size: 11px;
}
.property-section input,
.property-section select,
.property-section textarea {
width: 100%;
margin-top: 6px;
padding: 9px 10px;
border: 1px solid #d4ddd5;
border-radius: 4px;
background: #fcfdfb;
color: #173d3a;
font: 12px Manrope;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.property-section textarea {
resize: vertical;
line-height: 1.55;
}
.property-section input:focus,
.property-section select:focus,
.property-section textarea:focus {
border-color: #4e7e76;
box-shadow: 0 0 0 3px #4e7e7618;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.field-grid label {
min-width: 0;
}
.color-field {
display: grid;
grid-template-columns: 38px 1fr;
gap: 7px;
margin-top: 6px;
}
.color-field input {
margin: 0;
}
.color-field input[type='color'] {
height: 36px;
padding: 3px;
cursor: pointer;
}
.check-field {
display: flex !important;
align-items: center;
gap: 8px;
padding: 9px 10px;
border: 1px solid #e0e6df;
border-radius: 4px;
background: #f7faf6;
}
.check-field input {
width: 14px !important;
height: 14px;
margin: 0 !important;
accent-color: #1f5d55;
}
.compact-check {
margin-top: 29px !important;
height: 36px;
}
.field-hint {
margin: 2px 0;
color: #71847f;
font-size: 11px;
line-height: 1.6;
}
.safe-note {
margin: 8px 0 0;
padding: 8px 10px;
background: #f7f2e8;
color: #886529;
font-size: 10px;
line-height: 1.5;
border-left: 2px solid #dda952;
}
.column-editor {
margin: 0;
padding: 12px;
background: #edf3ed;
border-left: 3px solid #dda952;
border-radius: 4px;
}
.column-editor label {
position: relative;
margin: 10px 0;
}
.column-key {
display: inline-block;
margin-right: 7px;
padding: 2px 5px;
background: #173d3a;
color: #f8f7f0;
border-radius: 2px;
font: 9px 'DM Mono';
letter-spacing: 0.5px;
}
.product-editor article {
margin: 10px 0;
padding: 11px;
background: #f3f7f2;
border: 1px solid #e0e7df;
border-radius: 5px;
}
.benefit-product-editor article {
background: #f5f7ff;
border-color: #e0e5f5;
}
.banner-slide-editor article {
margin: 10px 0;
padding: 11px;
background: #f3f7f2;
border: 1px solid #e0e7df;
border-radius: 5px;
}
.carousel-settings {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid #e0e6df;
}
.carousel-settings .check-field {
margin: 7px 0;
}
.product-editor article label {
margin: 8px 0;
}
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
color: #315d57;
font-size: 11px;
}
.item-head button {
border: 0;
background: transparent;
color: #b15942;
font: 10px Manrope;
cursor: pointer;
}
.item-head button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.add-item {
width: 100%;
padding: 9px;
border: 1px dashed #8ca9a1;
border-radius: 4px;
background: #f8fbf7;
color: #315f59;
font: 600 11px Manrope;
cursor: pointer;
}
.property-form .actions {
position: sticky;
bottom: -28px;
z-index: 2;
margin: 18px -20px -28px;
padding: 13px 20px;
background: #f8faf6ef;
border-top: 1px solid #dce3dc;
backdrop-filter: blur(8px);
}
.property-form .actions button {
flex: 1;
color: #315d57;
background: #fff;
}
.property-form .actions .danger {
color: #aa4d38 !important;
}
.inspector > .empty {
display: flex;
flex-direction: column;
gap: 6px;
margin: 24px;
padding: 18px;
border: 1px dashed #bdcbc5;
border-radius: 6px;
background: #fff;
}
.inspector > .empty b {
font-size: 13px;
}
.inspector > .empty span {
font-size: 11px;
color: #7a8c87;
}
.inspector > .saved {
margin: 15px 20px 24px;
}
output {
display: block;
margin-top: 6px;
color: #a56f1b;
font: 10px 'DM Mono';
}
@media (max-width: 1100px) {
.shell {
grid-template-columns: 230px minmax(390px, 1fr) 320px;
}
}

227
apps/editor/src/main.ts Normal file
View File

@ -0,0 +1,227 @@
import { computed, createApp, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue';
import type { Block, PageSchema, ComponentType } from '@demo/page-schema';
import { createId, defaults, labels, materialSets, type MaterialSet } from '@demo/materials';
import { Inspector } from './Inspector';
import './style.css';
import './device.css';
import './inspector.css';
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 normalize = (b: Block) => {
const legacyBanner =
b.type === 'banner' && !Array.isArray(b.props.slides)
? [{ image: b.props.image || '', alt: b.props.alt || '' }]
: null;
const fresh = defaults[b.type]();
b.props = { ...fresh.props, ...b.props };
if (legacyBanner) b.props.slides = legacyBanner;
b.style = { ...fresh.style, ...b.style };
b.interaction = {
type: b.interaction?.type ?? fresh.interaction?.type ?? 'none',
url: b.interaction?.url ?? fresh.interaction?.url,
};
return b;
};
const App = defineComponent({
components: { Inspector },
setup() {
const schema = ref<PageSchema>({
id: 'demo-store',
version: 0,
title: '夏日精选',
blocks: [],
updatedAt: '',
});
const selected = ref(''),
history = ref<PageSchema[]>([]),
future = ref<PageSchema[]>([]),
saved = ref(''),
device = ref<'phone' | 'compact'>('phone'),
materialSet = ref<MaterialSet>('marketing');
const toast = ref<{ message: string; type: 'success' | 'error' } | null>(null),
publishing = ref(false);
const dragType = ref<ComponentType | null>(null),
dragIndex = ref(-1);
let frame: HTMLIFrameElement | undefined, toastTimer: ReturnType<typeof setTimeout> | undefined;
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
if (toastTimer) clearTimeout(toastTimer);
toast.value = { message, type };
toastTimer = setTimeout(() => (toast.value = null), 3600);
};
const dismissToast = () => {
if (toastTimer) clearTimeout(toastTimer);
toast.value = null;
};
const commit = () => {
history.value.push(clone(schema.value));
future.value = [];
};
const choose = (id: string) => {
selected.value = id;
const b = schema.value.blocks.find((x) => x.id === id);
if (b) normalize(b);
};
const add = (t: ComponentType) => {
commit();
const b = normalize(defaults[t]());
schema.value.blocks.push(b);
selected.value = b.id;
};
const activeMaterials = computed(() => materialSets[materialSet.value]);
const addBenefitsPreset = () => {
commit();
const blocks = materialSets.benefits.map((type) => normalize(defaults[type]()));
schema.value.blocks.push(...blocks);
selected.value = blocks[0].id;
};
const remove = () => {
const n = schema.value.blocks.findIndex((x) => x.id === selected.value);
if (n >= 0) {
commit();
schema.value.blocks.splice(n, 1);
selected.value = '';
}
};
const copy = () => {
const b = schema.value.blocks.find((x) => x.id === selected.value);
if (b) {
commit();
const x = clone(b);
x.id = createId();
schema.value.blocks.splice(schema.value.blocks.indexOf(b) + 1, 0, x);
selected.value = x.id;
}
};
const move = (d: number) => {
const i = schema.value.blocks.findIndex((x) => x.id === selected.value),
j = i + d;
if (i >= 0 && j >= 0 && j < schema.value.blocks.length) {
commit();
[schema.value.blocks[i], schema.value.blocks[j]] = [
schema.value.blocks[j],
schema.value.blocks[i],
];
}
};
const beginMaterial = (type: ComponentType) => {
dragType.value = type;
dragIndex.value = -1;
};
const dropCanvas = () => {
if (dragType.value) add(dragType.value);
dragType.value = null;
};
const beginLayer = (index: number) => {
dragIndex.value = index;
dragType.value = null;
};
const dropLayer = (index: number) => {
if (dragIndex.value < 0 || dragIndex.value === index) return;
commit();
const [block] = schema.value.blocks.splice(dragIndex.value, 1);
schema.value.blocks.splice(index, 0, block);
dragIndex.value = -1;
};
const undo = () => {
const x = history.value.pop();
if (x) {
future.value.push(clone(schema.value));
schema.value = x;
}
};
const redo = () => {
const x = future.value.pop();
if (x) {
history.value.push(clone(schema.value));
schema.value = x;
}
};
const save = async () => {
const response = await fetch(api + '/draft', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(schema.value),
});
if (!response.ok) throw new Error('草稿保存失败');
saved.value = '草稿已保存';
};
const publish = async () => {
if (publishing.value) return;
publishing.value = true;
try {
await save();
const response = await fetch(api + '/publish', { method: 'POST' });
if (!response.ok) throw new Error('服务端未完成发布');
const x = await response.json();
saved.value = `已发布版本 v${x.version}`;
showToast(`发布成功 · 当前版本 v${x.version}`);
} catch (error: any) {
showToast(`发布失败 · ${error?.message || '请稍后重试'}`, 'error');
} finally {
publishing.value = false;
}
};
const sel = () => schema.value.blocks.find((x) => x.id === selected.value);
watch(
schema,
() =>
frame?.contentWindow?.postMessage({ type: 'preview', schema: clone(schema.value) }, '*'),
{ deep: true },
);
onMounted(async () => {
const x = await fetch(api + '/draft').then((r) => r.json());
x.blocks.forEach(normalize);
schema.value = x;
frame = document.querySelector('iframe') || undefined;
if (frame)
frame.onload = () =>
frame?.contentWindow?.postMessage({ type: 'preview', schema: clone(schema.value) }, '*');
});
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
});
return {
schema,
selected,
labels,
add,
choose,
sel,
remove,
copy,
move,
undo,
redo,
save,
publish,
saved,
device,
dragType,
beginMaterial,
dropCanvas,
beginLayer,
dropLayer,
commit,
toast,
publishing,
dismissToast,
h5Url,
materialSet,
activeMaterials,
addBenefitsPreset,
};
},
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>
<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>
<div v-if="toast" class="publish-toast" :class="toast.type" :role="toast.type==='error'?'alert':'status'" aria-live="polite"><span class="toast-mark">{{toast.type==='success'?'✓':'!'}}</span><div><b>{{toast.type==='success'?'页面已发布':'发布未完成'}}</b><p>{{toast.message}}</p></div><button type="button" aria-label="关闭提示" @click="dismissToast">×</button></div>
</div>`,
});
createApp(App).mount('#app');

305
apps/editor/src/style.css Normal file
View File

@ -0,0 +1,305 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono&family=Manrope:wght@400;500;600;700;800&display=swap');
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #edf0ec;
color: #173d3a;
font-family: Manrope, system-ui;
}
.shell {
display: grid;
grid-template-columns: 270px 1fr 300px;
grid-template-rows: 64px calc(100vh - 64px);
min-height: 100vh;
}
header {
grid-column: 1/-1;
background: #123a37;
color: #f6f4ec;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
gap: 18px;
}
.brand {
font-size: 21px;
font-weight: 800;
letter-spacing: 2px;
}
.brand small {
font: 10px 'DM Mono';
color: #d6b66d;
letter-spacing: 1px;
margin-left: 8px;
}
.library-switch {
display: flex;
gap: 3px;
padding: 3px;
border: 1px solid #587570;
border-radius: 7px;
background: #0d302e;
}
.library-switch button {
position: relative;
padding: 7px 13px;
border: 0;
border-radius: 4px;
background: transparent;
color: #a9c0bb;
font: 700 11px Manrope;
cursor: pointer;
}
.library-switch button.active {
background: #f5f7f3;
color: #173d3a;
box-shadow: 0 2px 8px #061c1a55;
}
.library-switch small {
margin-left: 4px;
color: #dfa948;
font: 8px 'DM Mono';
}
.tools {
display: flex;
gap: 8px;
align-items: center;
}
.tools button,
.tools a,
.actions button {
border: 1px solid #c8d0c8;
background: transparent;
color: inherit;
padding: 8px 11px;
border-radius: 5px;
font: 600 12px Manrope;
text-decoration: none;
cursor: pointer;
}
.tools .publish {
background: #dda952;
border-color: #dda952;
color: #173d3a;
}
.materials,
.inspector {
background: #f9faf6;
padding: 24px;
overflow: auto;
}
.materials {
border-right: 1px solid #d8ded7;
}
.inspector {
border-left: 1px solid #d8ded7;
}
.eyebrow {
font: 11px 'DM Mono';
letter-spacing: 1px;
text-transform: uppercase;
color: #78908b;
margin: 0 0 8px;
}
h2 {
font-size: 18px;
margin: 0 0 18px;
}
.material-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.material-grid button {
padding: 13px 8px;
background: #edf3ed;
border: 0;
border-radius: 5px;
color: #1d514c;
font: 600 12px Manrope;
cursor: grab;
}
.benefit-library {
background: #f8f9fc;
border-right-color: #dfe4ee;
}
.benefit-library h2 {
color: #17213a;
}
.benefit-intro {
margin-bottom: 14px;
padding: 13px;
border: 1px solid #dfe5f2;
border-radius: 8px;
background: linear-gradient(145deg, #fff, #f0f3ff);
}
.benefit-intro b,
.benefit-intro span {
display: block;
}
.benefit-intro b {
color: #26345c;
font-size: 12px;
}
.benefit-intro span {
margin-top: 4px;
color: #7f8ba4;
font-size: 10px;
}
.benefit-intro button {
width: 100%;
margin-top: 11px;
padding: 8px;
border: 1px solid #526cf2;
border-radius: 5px;
background: #526cf2;
color: #fff;
font: 700 10px Manrope;
cursor: pointer;
}
.benefit-library .material-grid {
grid-template-columns: 1fr;
}
.benefit-library .material-grid button {
display: flex;
align-items: center;
gap: 9px;
padding: 12px;
border: 1px solid #e3e7f0;
background: #fff;
color: #34415d;
text-align: left;
}
.benefit-library .material-grid button:hover {
border-color: #9baafa;
background: #f2f4ff;
color: #3f58df;
}
.material-icon {
display: grid;
width: 24px;
height: 24px;
place-items: center;
border-radius: 6px;
background: #edf0ff;
color: #526cf2;
font: 700 12px 'DM Mono';
}
.layers {
margin-top: 30px;
}
ol {
padding: 0;
margin: 0;
list-style: none;
}
li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 8px;
border-bottom: 1px solid #edf0ec;
font-size: 12px;
cursor: pointer;
}
li.active {
background: #e0ece5;
color: #0e5148;
}
li button {
border: 0;
background: transparent;
color: #376a63;
cursor: pointer;
}
.stage {
background: #e2e7e1;
display: flex;
flex-direction: column;
align-items: center;
overflow: auto;
}
.stage-top {
height: 58px;
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 0 36px;
font: 12px 'DM Mono';
color: #59736e;
}
.phone {
width: 390px;
height: 844px;
background: #fff;
box-shadow: 0 18px 50px #53726a55;
border-radius: 18px;
overflow: hidden;
margin-bottom: 30px;
}
.phone iframe {
border: 0;
width: 100%;
height: 100%;
display: block;
}
label {
display: block;
font-size: 11px;
font-weight: 700;
margin: 15px 0;
color: #4b6963;
}
input,
select {
margin-top: 6px;
width: 100%;
padding: 10px;
border: 1px solid #d5ddd5;
border-radius: 4px;
background: #fff;
font: 12px Manrope;
color: #173d3a;
}
.actions {
display: flex;
gap: 8px;
margin-top: 22px;
}
.danger {
color: #aa4d38 !important;
border-color: #e4c5b9 !important;
}
.empty {
padding: 18px 0;
color: #7c8d89;
font-size: 13px;
}
.saved {
color: #a56f1b;
font-size: 12px;
margin-top: 28px;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 220px 1fr;
}
.inspector {
display: none;
}
.phone {
transform: scale(0.8);
transform-origin: top center;
margin-bottom: -150px;
}
.tools a {
display: none;
}
.library-switch {
display: none;
}
}

77
apps/editor/src/toast.css Normal file
View File

@ -0,0 +1,77 @@
.publish-toast {
position: fixed;
z-index: 20;
top: 78px;
right: 24px;
display: grid;
grid-template-columns: 32px 1fr 24px;
align-items: center;
gap: 11px;
min-width: 300px;
max-width: 420px;
padding: 13px 13px 13px 14px;
border: 1px solid #7da39a;
border-radius: 7px;
background: #f8fbf7;
color: #173d3a;
box-shadow: 0 16px 42px #173d3a30;
animation: toast-enter 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.publish-toast.error {
border-color: #d49a88;
background: #fff9f6;
color: #743e31;
}
.toast-mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: #1f665b;
color: #fff;
font-weight: 800;
}
.error .toast-mark {
background: #b55b43;
}
.publish-toast b {
display: block;
font-size: 12px;
}
.publish-toast p {
margin: 3px 0 0;
color: #59736d;
font: 10px 'DM Mono';
}
.publish-toast.error p {
color: #8b5b4e;
}
.publish-toast button {
align-self: start;
border: 0;
background: transparent;
color: #71847f;
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.tools .publish:disabled {
opacity: 0.65;
cursor: wait;
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.publish-toast {
animation: none;
}
}

1
apps/editor/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
apps/editor/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } },
server: { host: '0.0.0.0', port: 5173, strictPort: true },
});

3
apps/h5/index.html Normal file
View File

@ -0,0 +1,3 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>

18
apps/h5/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "@demo/h5",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"vue": "^3.5.17",
"@demo/page-schema": "*",
"@demo/renderer": "*"
},
"devDependencies": {
"vite": "^7.0.6",
"@vitejs/plugin-vue": "^6.0.0"
}
}

301
apps/h5/src/enhanced.css Normal file
View File

@ -0,0 +1,301 @@
.product-img {
display: block;
width: 100%;
object-fit: cover;
background: #dceae5;
}
.product-placeholder {
display: grid;
place-items: center;
color: #7d9690;
font-size: 11px;
}
.products article {
min-width: 0;
}
.products article b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.r-spacer {
width: 100%;
}
.r-banner,
.r-image,
.r-button,
.products,
.cols,
.benefit-card {
transition: all 0.15s ease;
}
.carousel-slide {
animation: carousel-in 0.28s ease both;
}
.carousel-arrow {
position: absolute;
top: 50%;
z-index: 3;
width: 28px;
height: 40px;
transform: translateY(-50%);
border: 0;
background: #102f2c99;
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.carousel-arrow:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.carousel-prev {
left: 6px;
}
.carousel-next {
right: 6px;
}
.carousel-dots {
position: absolute;
z-index: 3;
left: 50%;
bottom: 8px;
display: flex;
gap: 6px;
transform: translateX(-50%);
}
.carousel-dots button {
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
background: #ffffff88;
cursor: pointer;
transition:
width 0.2s,
background 0.2s;
}
.carousel-dots button.active {
width: 18px;
background: #fff;
}
@keyframes carousel-in {
from {
opacity: 0.35;
transform: scale(1.01);
}
to {
opacity: 1;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.r-banner,
.r-image,
.r-button,
.products,
.cols,
.benefit-card {
transition: none;
}
}
.benefits-page {
min-height: 100vh;
padding: 18px 14px 34px;
background: #f3f6fb;
color: #17213a;
font-family: Inter, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.benefit-card {
margin-bottom: 12px;
padding: 15px;
border: 1px solid #e3e8f2;
background: #fff;
box-shadow: 0 4px 14px #2942740a;
}
.benefit-card-head,
.benefit-list-title,
.benefit-name-row,
.benefit-price-row {
display: flex;
align-items: center;
}
.benefit-card-head,
.benefit-list-title {
justify-content: space-between;
margin-bottom: 13px;
}
.benefit-kicker {
color: #7b869d;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.benefit-kicker::before {
content: '▦';
margin-right: 6px;
color: #9ca8c0;
}
.delivery-badge,
.benefit-meta,
.benefit-count,
.list-mode,
.vip-chip {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 6px;
background: #eef1f7;
color: #69748b;
font-size: 10px;
font-weight: 700;
}
.delivery-badge.mode-direct {
background: #edf0ff;
color: #4f68f6;
}
.delivery-badge.mode-code {
background: #fff2e8;
color: #d46c23;
}
.benefit-product-body {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
gap: 13px;
align-items: center;
}
.benefit-product-image {
width: 72px;
height: 72px;
border-radius: 12px;
object-fit: cover;
background: #f0f3f9;
}
.benefit-image-placeholder {
display: grid;
place-items: center;
color: #a8b4c9;
font-size: 34px;
}
.benefit-product-copy {
min-width: 0;
}
.benefit-name-row {
gap: 6px;
}
.benefit-name-row b {
overflow: hidden;
color: #17213a;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.vip-chip {
min-height: 18px;
padding: 0 5px;
background: #fff1cf;
color: #a66b00;
font-size: 8px;
}
.benefit-product-copy p {
overflow: hidden;
margin: 5px 0 6px;
color: #8993a8;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.benefit-price-row {
gap: 7px;
}
.benefit-price-row strong {
font-size: 18px;
}
.benefit-price-row del {
color: #aab2c2;
font-size: 10px;
}
.benefit-card-action,
.benefit-action-row button {
border: 0;
font-weight: 700;
cursor: pointer;
}
.benefit-card-action {
padding: 10px 13px;
border-radius: 999px;
color: #fff;
font-size: 12px;
white-space: nowrap;
}
.benefit-action-row {
display: flex;
gap: 10px;
}
.benefit-action-row button {
min-height: 42px;
color: #17213a;
}
.benefit-primary-action {
flex: 1;
color: #fff !important;
}
.benefit-secondary-action {
flex: 0 0 112px;
}
.benefit-notice {
border-left-width: 3px;
}
.benefit-notice h3 {
margin: 0 0 8px;
color: #17213a;
font-size: 15px;
}
.benefit-notice-copy {
margin: 0;
color: inherit;
font-size: 12px;
line-height: 1.85;
white-space: pre-line;
}
.benefit-list-title {
padding-bottom: 9px;
border-bottom: 1px solid #edf0f6;
}
.benefit-list-items article {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 45px;
border-bottom: 1px solid #f0f2f7;
}
.benefit-list-items article:last-child {
border-bottom: 0;
}
.benefit-list-items article > div {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.benefit-list-items b {
overflow: hidden;
color: #263149;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.benefit-list-items strong {
margin-left: 12px;
font-size: 13px;
white-space: nowrap;
}
.list-mode {
min-height: 18px;
padding: 0 5px;
font-size: 8px;
}

33
apps/h5/src/main.ts Normal file
View File

@ -0,0 +1,33 @@
import { createApp, defineComponent, ref, onMounted } from 'vue';
import { PageRenderer } from '@demo/renderer';
import type { PageSchema } from '@demo/page-schema';
import './style.css';
import './enhanced.css';
const api = `${location.protocol}//${location.hostname}:3000/api/pages/`;
const App = defineComponent({
components: { PageRenderer },
setup() {
const schema = ref<PageSchema | null>(null),
error = ref('');
onMounted(async () => {
addEventListener('message', (e) => {
if (e.data?.type === 'preview') schema.value = e.data.schema;
});
if (!location.search.includes('preview'))
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));
} catch (e: any) {
error.value = e.message;
}
});
return { schema, error };
},
template: `<PageRenderer v-if="schema" :schema="schema"/><div class="h5-error" v-else-if="error">{{error}}</div><div class="loading" v-else>正在加载页面…</div>`,
});
createApp(App).mount('#app');

121
apps/h5/src/style.css Normal file
View File

@ -0,0 +1,121 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #fff;
color: #173d3a;
font-family: Arial, sans-serif;
}
.page-renderer {
min-height: 100vh;
background: #fff;
}
.page-renderer h2 {
margin: 0;
padding: 18px 20px 6px;
font-size: 23px;
letter-spacing: -0.5px;
}
.r-text {
padding: 3px 20px 14px;
line-height: 1.7;
color: #4d605d;
font-size: 14px;
}
.r-banner {
height: 180px;
position: relative;
overflow: hidden;
color: white;
}
.r-banner img {
position: absolute;
width: 100%;
height: 100%;
object-fit: cover;
}
.shade {
position: absolute;
inset: 0;
background: linear-gradient(90deg, #0b2624a8, transparent);
}
.banner-copy {
position: absolute;
left: 22px;
bottom: 22px;
}
.banner-copy p {
margin: 0 0 5px;
font-size: 12px;
}
.banner-copy h1 {
font-size: 28px;
margin: 0;
}
.r-image {
display: block;
width: 100%;
height: auto;
min-height: 150px;
object-fit: cover;
}
.r-button {
display: block;
margin: 12px 20px 22px;
width: calc(100% - 40px);
border: 0;
border-radius: 3px;
padding: 13px;
background: #dca74e;
color: #173d3a;
font-weight: bold;
}
.products {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 14px 20px;
}
.products article {
font-size: 13px;
}
.product-img {
height: 100px;
background: #dceae5;
margin-bottom: 8px;
}
.products b,
.products span {
display: block;
}
.products span {
color: #b77523;
margin-top: 4px;
}
.page-renderer hr {
border: 0;
border-top: 1px solid #e3e8e2;
margin: 12px 20px;
}
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 12px 20px;
}
.cols div {
background: #e9f0e9;
padding: 17px;
font-size: 13px;
}
.unknown,
.h5-error,
.loading {
padding: 32px;
text-align: center;
color: #6d7a77;
}
.h5-error {
color: #a75140;
}

1
apps/h5/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
apps/h5/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

8
apps/h5/vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } },
server: { host: '0.0.0.0', port: 5174, strictPort: true },
build: { rollupOptions: { output: { manualChunks: { vue: ['vue'] } } } },
});

11
apps/server/package.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "@demo/server",
"type": "module",
"scripts": {
"dev": "node src/index.mjs"
},
"dependencies": {
"express": "^5.1.0",
"cors": "^2.8.5"
}
}

89
apps/server/src/index.mjs Normal file
View File

@ -0,0 +1,89 @@
import express from 'express';
import cors from 'cors';
import fs from 'node:fs';
import path from 'node:path';
const app = express(),
file = path.resolve('data.json');
app.use(cors(), express.json({ limit: '256kb' }));
const seed = {
draft: {
id: 'demo-store',
version: 0,
title: '夏日精选',
blocks: [],
updatedAt: new Date().toISOString(),
},
published: null,
versions: [],
};
const read = () => {
const x = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : 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 isSafe = (s) =>
s &&
typeof s.id === 'string' &&
typeof s.title === 'string' &&
Array.isArray(s.blocks) &&
s.blocks.every(
(b) =>
b &&
typeof b.id === 'string' &&
allowed.has(b.type) &&
(!b.interaction?.url || /^(https?:\/\/|\/)/.test(b.interaction.url)),
);
app.get('/api/pages/:id/draft', (q, s) => s.json(read().draft));
app.put('/api/pages/:id/draft', (q, s) => {
if (!isSafe(q.body)) return s.status(400).json({ error: 'invalid schema' });
const x = read();
x.draft = { ...q.body, id: q.params.id, version: 0, updatedAt: new Date().toISOString() };
write(x);
s.json(x.draft);
});
app.post('/api/pages/:id/publish', (q, s) => {
const x = read(),
next = (x.versions.at(-1)?.version || 0) + 1,
snapshot = structuredClone({
...x.draft,
id: q.params.id,
version: next,
updatedAt: new Date().toISOString(),
});
x.versions.push(snapshot);
x.published = snapshot;
write(x);
s.json(x.published);
});
app.get('/api/pages/:id/published', (q, s) => {
const x = read();
if (x.published) s.json(x.published);
else s.status(404).json({ error: 'not published' });
});
app.get('/api/pages/:id/versions', (q, s) =>
s.json(read().versions.map(({ version, updatedAt, title }) => ({ version, updatedAt, title }))),
);
app.get('/api/pages/:id/versions/:version', (q, s) => {
const v = read().versions.find((x) => x.version === Number(q.params.version));
if (v) s.json(v);
else s.status(404).json({ error: 'version not found' });
});
app.listen(3000, '0.0.0.0', () => console.log('API http://0.0.0.0:3000'));

37
eslint.config.mjs Normal file
View File

@ -0,0 +1,37 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default [
{
ignores: [
'**/node_modules/**',
'**/dist/**',
'test-results/**',
'playwright-report/**',
'apps/server/data.json',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{js,mjs,ts}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
prettier,
];

3867
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "lowcode-h5-demo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "concurrently -n server,editor,h5 -c cyan,magenta,yellow \"npm run dev -w @demo/server\" \"npm run dev -w @demo/editor\" \"npm run dev -w @demo/h5\"",
"build": "npm run build --workspaces --if-present",
"lint": "eslint . --max-warnings=0",
"typecheck": "npm run typecheck --workspaces --if-present",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run typecheck && npm run format:check && npm run build",
"test:e2e": "playwright test",
"check:h5": "npm run build -w @demo/h5 && ! rg -i \"sortablejs|vuedraggable|@dnd-kit|apps/editor\" apps/h5/dist/assets"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.54.1",
"@vitejs/plugin-vue": "^6.0.0",
"concurrently": "^9.1.2",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.7.0",
"prettier": "^3.9.5",
"typescript": "^5.8.3",
"typescript-eslint": "^8.64.0",
"vite": "^7.0.6",
"vue": "^3.5.17"
}
}

View File

@ -0,0 +1,9 @@
{
"name": "@demo/materials",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@demo/page-schema": "*"
}
}

View File

@ -0,0 +1,248 @@
import type { Block, ComponentType } from '@demo/page-schema';
export const createId = () => {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID();
if (typeof cryptoApi?.getRandomValues === 'function') {
const bytes = cryptoApi.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'));
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex
.slice(6, 8)
.join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
}
return `block-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
};
const base = (
type: ComponentType,
props: Record<string, unknown>,
style: Record<string, string> = {},
): Block => ({ id: createId(), type, props, style, interaction: { type: 'none' } });
export const labels: Record<ComponentType, string> = {
banner: '横幅 Banner',
title: '标题',
text: '文本',
image: '图片',
button: '按钮',
'product-list': '商品列表',
divider: '分割线',
spacer: '间距',
'two-column': '双列容器',
'benefit-product': '权益商品卡片',
'benefit-actions': '兑换操作区',
'benefit-notice': '兑换说明',
'benefit-product-list': '权益商品列表',
};
export type MaterialSet = 'marketing' | 'benefits';
export const materialSets: Record<MaterialSet, ComponentType[]> = {
marketing: [
'banner',
'title',
'text',
'image',
'button',
'product-list',
'divider',
'spacer',
'two-column',
],
benefits: ['benefit-product', 'benefit-actions', 'benefit-notice', 'benefit-product-list'],
};
export const defaults: Record<ComponentType, () => Block> = {
banner: () =>
base(
'banner',
{
title: '夏日新品',
subtitle: '为好心情准备',
image:
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=900&q=80',
alt: '咖啡与夏日新品',
slides: [
{
image:
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=900&q=80',
alt: '咖啡与夏日新品',
},
],
enableCarousel: false,
loop: true,
autoplay: false,
autoplaySeconds: 3,
height: 180,
overlayOpacity: 0.55,
textAlign: 'left',
},
{ color: '#ffffff' },
),
title: () =>
base(
'title',
{
text: '一眼心动的新选择',
fontSize: 24,
fontWeight: '700',
textAlign: 'left',
paddingX: 20,
paddingY: 18,
},
{ color: '#173d3a' },
),
text: () =>
base(
'text',
{
text: '用简洁的内容讲清楚价值与行动。',
fontSize: 14,
lineHeight: 1.7,
textAlign: 'left',
paddingX: 20,
paddingY: 10,
},
{ color: '#4d605d', backgroundColor: '#ffffff' },
),
image: () =>
base('image', {
src: 'https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85?auto=format&fit=crop&w=900&q=80',
alt: '精选生活图片',
height: 250,
objectFit: 'cover',
borderRadius: 0,
}),
button: () =>
base(
'button',
{ text: '立即了解', fullWidth: true, borderRadius: 4, paddingY: 13 },
{ color: '#173d3a', backgroundColor: '#dca74e' },
),
'product-list': () =>
base(
'product-list',
{
columns: 2,
gap: 12,
imageHeight: 112,
showImage: true,
priceColor: '#b77523',
items: [
{
name: '云朵马克杯',
price: '¥ 88',
image:
'https://images.unsplash.com/photo-1514228742587-6b1558fcca3d?auto=format&fit=crop&w=500&q=80',
},
{
name: '晨光托特包',
price: '¥ 128',
image:
'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?auto=format&fit=crop&w=500&q=80',
},
],
},
{ color: '#173d3a', backgroundColor: '#ffffff' },
),
divider: () =>
base('divider', { color: '#dfe6df', thickness: 1, marginY: 16, marginX: 20, style: 'solid' }),
spacer: () => base('spacer', { height: 24, backgroundColor: '#ffffff' }),
'two-column': () =>
base(
'two-column',
{
left: '左侧内容',
right: '右侧内容',
gap: 8,
paddingX: 20,
paddingY: 12,
minHeight: 58,
leftBackground: '#e9f0e9',
rightBackground: '#e9f0e9',
textAlign: 'left',
},
{ color: '#173d3a' },
),
'benefit-product': () =>
base(
'benefit-product',
{
deliveryMode: 'direct',
name: '凯叔讲故事月卡',
subtitle: 'VIP 会员 · 购买后立即生效',
price: '¥0.01',
originalPrice: '¥19.90',
image: '',
imageAlt: '凯叔讲故事月卡',
badge: '直充',
buttonText: '立即兑换',
showVip: true,
showOriginalPrice: false,
borderRadius: 16,
accentColor: '#4f68f6',
priceColor: '#ff5562',
},
{ color: '#17213a', backgroundColor: '#ffffff' },
),
'benefit-actions': () =>
base(
'benefit-actions',
{
primaryText: '立即兑换',
secondaryText: '使用说明',
showSecondary: true,
borderRadius: 12,
primaryColor: '#4f68f6',
secondaryColor: '#eef1f7',
},
{ color: '#17213a', backgroundColor: '#ffffff' },
),
'benefit-notice': () =>
base(
'benefit-notice',
{
title: '兑换说明',
content: '1. 兑换后请在有效期内使用\n2. 每个用户限兑一次\n3. 虚拟商品兑换后不退换',
eyebrow: '使用须知',
borderRadius: 16,
accentColor: '#4f68f6',
},
{ color: '#34405a', backgroundColor: '#ffffff' },
),
'benefit-product-list': () =>
base(
'benefit-product-list',
{
title: '更多权益',
showCount: true,
priceColor: '#ff5562',
borderRadius: 16,
items: [
{ name: '爱奇艺黄金 VIP 月卡', price: '¥5.00', mode: '卡密' },
{ name: '腾讯视频 VIP 月卡', price: '¥6.00', mode: '直充' },
{ name: '微信立减金', price: '¥0.01', mode: '卡密' },
],
},
{ color: '#17213a', backgroundColor: '#ffffff' },
),
};
export const interactiveTypes: ComponentType[] = [
'banner',
'title',
'text',
'image',
'button',
'product-list',
'two-column',
'benefit-product',
'benefit-actions',
'benefit-notice',
'benefit-product-list',
];

View File

@ -0,0 +1,6 @@
{
"name": "@demo/page-schema",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts"
}

View File

@ -0,0 +1,57 @@
export type ComponentType =
| 'banner'
| 'title'
| 'text'
| 'image'
| 'button'
| 'product-list'
| 'divider'
| 'spacer'
| 'two-column'
| 'benefit-product'
| 'benefit-actions'
| 'benefit-notice'
| 'benefit-product-list';
export interface Block {
id: string;
type: ComponentType;
props: Record<string, unknown>;
style?: Record<string, string>;
interaction?: { type: 'none' | 'link'; url?: string };
}
export interface PageSchema {
id: string;
version: number;
title: string;
blocks: Block[];
updatedAt: string;
}
export const safeUrl = (url?: string) => !!url && /^(https?:\/\/|\/)/.test(url);
export function validSchema(x: unknown): x is PageSchema {
const p = x as PageSchema;
return (
!!p &&
typeof p.id === 'string' &&
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),
)
);
}

View File

@ -0,0 +1,10 @@
{
"name": "@demo/renderer",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"vue": "^3.5.17",
"@demo/page-schema": "*"
}
}

View File

@ -0,0 +1,497 @@
import {
defineComponent,
h,
onUnmounted,
ref,
watch,
type CSSProperties,
type PropType,
} from 'vue';
import type { Block, PageSchema } from '@demo/page-schema';
import { safeUrl } from '@demo/page-schema';
const num = (value: unknown, fallback: number) =>
Number.isFinite(Number(value)) ? Number(value) : fallback;
const px = (value: unknown, fallback: number) => `${num(value, fallback)}px`;
const go = (b: Block) => {
const url = b.interaction?.type === 'link' ? b.interaction.url : undefined;
if (safeUrl(url)) location.href = url!;
};
const BannerView = defineComponent({
props: { block: { type: Object as PropType<Block>, required: true } },
setup(input) {
const current = ref(0);
let timer: ReturnType<typeof setInterval> | undefined;
const props = () => input.block.props as any;
const slides = () => {
const p = props();
return Array.isArray(p.slides) && p.slides.length
? p.slides
: [{ image: p.image || '', alt: p.alt || '' }];
};
const enabled = () => !!props().enableCarousel && slides().length > 1;
const stop = () => {
if (timer) {
clearInterval(timer);
timer = undefined;
}
};
const move = (direction: number) => {
const length = slides().length;
if (!enabled()) return;
const next = current.value + direction;
if (props().loop) current.value = (next + length) % length;
else current.value = Math.min(length - 1, Math.max(0, next));
};
const restart = () => {
stop();
current.value = Math.min(current.value, slides().length - 1);
if (enabled() && props().autoplay)
timer = setInterval(() => move(1), Math.max(1, num(props().autoplaySeconds, 3)) * 1000);
};
watch(
() => [
props().enableCarousel,
props().autoplay,
props().autoplaySeconds,
props().loop,
slides().length,
],
restart,
{ immediate: true },
);
onUnmounted(stop);
return () => {
const b = input.block,
p = props(),
base = (b.style || {}) as CSSProperties,
list = slides(),
carouselOn = enabled(),
index = carouselOn ? current.value : 0,
slide = list[index] || list[0],
align = p.textAlign || 'left';
const copy: CSSProperties = {
textAlign: align,
left: align === 'right' ? 'auto' : carouselOn && align === 'left' ? '54px' : '22px',
right: align === 'left' ? 'auto' : carouselOn && align === 'right' ? '54px' : '22px',
width: align === 'center' ? 'calc(100% - 80px)' : 'auto',
};
const controls = carouselOn
? [
h(
'button',
{
type: 'button',
class: 'carousel-arrow carousel-prev',
'aria-label': '上一张',
disabled: !p.loop && index === 0,
onClick: (event: Event) => {
event.stopPropagation();
move(-1);
},
},
'',
),
h(
'button',
{
type: 'button',
class: 'carousel-arrow carousel-next',
'aria-label': '下一张',
disabled: !p.loop && index === list.length - 1,
onClick: (event: Event) => {
event.stopPropagation();
move(1);
},
},
'',
),
h(
'div',
{ class: 'carousel-dots' },
list.map((_: unknown, i: number) =>
h(
'button',
{
type: 'button',
class: { active: i === index },
'aria-label': `切换到第 ${i + 1}`,
onClick: (event: Event) => {
event.stopPropagation();
current.value = i;
},
},
'',
),
),
),
]
: [];
return h(
'section',
{ class: 'r-banner', style: { ...base, height: px(p.height, 180) }, onClick: () => go(b) },
[
h('img', {
key: index,
class: 'carousel-slide',
src: slide?.image || '',
alt: slide?.alt || '',
loading: index === 0 ? 'eager' : 'lazy',
width: 390,
height: num(p.height, 180),
}),
h('div', {
class: 'shade',
style: {
background: `linear-gradient(90deg,rgba(11,38,36,${num(p.overlayOpacity, 0.55)}),transparent)`,
},
}),
h('div', { class: 'banner-copy', style: copy }, [
h('p', p.subtitle || ''),
h('h1', p.title || ''),
]),
...controls,
],
);
};
},
});
const BlockView = defineComponent({
props: { block: { type: Object as PropType<Block>, required: true } },
setup(input) {
return () => {
const b = input.block,
p = b.props as any,
base = (b.style || {}) as CSSProperties,
click = () => go(b);
switch (b.type) {
case 'banner':
return h(BannerView, { block: b });
case 'title':
return h(
'h2',
{
style: {
...base,
fontSize: px(p.fontSize, 24),
fontWeight: p.fontWeight || '700',
textAlign: p.textAlign || 'left',
padding: `${px(p.paddingY, 18)} ${px(p.paddingX, 20)}`,
},
onClick: click,
},
p.text || '',
);
case 'text':
return h(
'p',
{
class: 'r-text',
style: {
...base,
fontSize: px(p.fontSize, 14),
lineHeight: String(p.lineHeight || 1.7),
textAlign: p.textAlign || 'left',
padding: `${px(p.paddingY, 10)} ${px(p.paddingX, 20)}`,
},
onClick: click,
},
p.text || '',
);
case 'image':
return h('img', {
class: 'r-image',
style: {
...base,
height: px(p.height, 250),
objectFit: p.objectFit || 'cover',
borderRadius: px(p.borderRadius, 0),
},
src: p.src,
alt: p.alt || '',
loading: 'lazy',
width: 390,
height: Number(p.height) || 250,
onClick: click,
});
case 'button':
return h(
'button',
{
class: 'r-button',
style: {
...base,
width: p.fullWidth ? 'calc(100% - 40px)' : 'auto',
borderRadius: px(p.borderRadius, 4),
padding: `${px(p.paddingY, 13)} 20px`,
},
onClick: click,
},
p.text || '按钮',
);
case 'product-list': {
const items = Array.isArray(p.items) ? p.items : [];
return h(
'div',
{
class: 'products',
style: {
...base,
gridTemplateColumns: `repeat(${Number(p.columns) || 2},minmax(0,1fr))`,
gap: px(p.gap, 12),
},
onClick: click,
},
items.map((item: any, index: number) =>
h('article', { key: index }, [
p.showImage !== false
? item.image
? h('img', {
class: 'product-img',
src: item.image,
alt: item.name || '',
loading: 'lazy',
width: 160,
height: Number(p.imageHeight) || 112,
style: { height: px(p.imageHeight, 112) },
})
: h(
'div',
{
class: 'product-img product-placeholder',
style: { height: px(p.imageHeight, 112) },
},
'暂无图片',
)
: null,
h('b', item.name || '未命名商品'),
h('span', { style: { color: p.priceColor || '#b77523' } }, item.price || ''),
]),
),
);
}
case 'divider':
return h('hr', {
style: {
borderTopColor: p.color || '#dfe6df',
borderTopWidth: px(p.thickness, 1),
borderTopStyle: p.style || 'solid',
margin: `${px(p.marginY, 16)} ${px(p.marginX, 20)}`,
},
});
case 'spacer':
return h('div', {
class: 'r-spacer',
style: { height: px(p.height, 24), backgroundColor: p.backgroundColor || '#ffffff' },
});
case 'two-column':
return h(
'div',
{
class: 'cols',
style: {
...base,
gap: px(p.gap, 8),
padding: `${px(p.paddingY, 12)} ${px(p.paddingX, 20)}`,
textAlign: p.textAlign || 'left',
},
onClick: click,
},
[
h(
'div',
{
style: {
backgroundColor: p.leftBackground || '#e9f0e9',
minHeight: px(p.minHeight, 58),
},
},
p.left || '',
),
h(
'div',
{
style: {
backgroundColor: p.rightBackground || '#e9f0e9',
minHeight: px(p.minHeight, 58),
},
},
p.right || '',
),
],
);
case 'benefit-product':
return h(
'section',
{
class: 'benefit-card benefit-product-card',
style: {
...base,
borderRadius: px(p.borderRadius, 16),
'--benefit-accent': p.accentColor || '#4f68f6',
} as CSSProperties,
onClick: click,
},
[
h('div', { class: 'benefit-card-head' }, [
h('span', { class: 'benefit-kicker' }, '权益商品'),
h(
'span',
{ class: ['delivery-badge', `mode-${p.deliveryMode || 'direct'}`] },
p.badge || (p.deliveryMode === 'code' ? '卡密' : '直充'),
),
]),
h('div', { class: 'benefit-product-body' }, [
p.image
? h('img', {
class: 'benefit-product-image',
src: p.image,
alt: p.imageAlt || p.name || '',
loading: 'lazy',
width: 72,
height: 72,
})
: h('div', { class: 'benefit-product-image benefit-image-placeholder' }, '◇'),
h('div', { class: 'benefit-product-copy' }, [
h('div', { class: 'benefit-name-row' }, [
h('b', p.name || '未命名权益'),
p.showVip ? h('span', { class: 'vip-chip' }, 'VIP') : null,
]),
h('p', p.subtitle || ''),
h('div', { class: 'benefit-price-row' }, [
h('strong', { style: { color: p.priceColor || '#ff5562' } }, p.price || '¥0'),
p.showOriginalPrice ? h('del', p.originalPrice || '') : null,
]),
]),
h(
'button',
{
type: 'button',
class: 'benefit-card-action',
style: { backgroundColor: p.accentColor || '#4f68f6' },
},
p.buttonText || '立即兑换',
),
]),
],
);
case 'benefit-actions':
return h(
'section',
{
class: 'benefit-card benefit-actions',
style: { ...base, borderRadius: px(p.borderRadius, 16) },
onClick: click,
},
[
h('div', { class: 'benefit-card-head' }, [
h('span', { class: 'benefit-kicker' }, '兑换操作'),
h('span', { class: 'benefit-meta' }, '主操作'),
]),
h('div', { class: 'benefit-action-row' }, [
h(
'button',
{
type: 'button',
class: 'benefit-primary-action',
style: {
backgroundColor: p.primaryColor || '#4f68f6',
borderRadius: px(p.borderRadius, 12),
},
},
p.primaryText || '立即兑换',
),
p.showSecondary !== false
? h(
'button',
{
type: 'button',
class: 'benefit-secondary-action',
style: {
backgroundColor: p.secondaryColor || '#eef1f7',
borderRadius: px(p.borderRadius, 12),
},
},
p.secondaryText || '使用说明',
)
: null,
]),
],
);
case 'benefit-notice':
return h(
'section',
{
class: 'benefit-card benefit-notice',
style: {
...base,
borderRadius: px(p.borderRadius, 16),
borderLeftColor: p.accentColor || '#4f68f6',
},
onClick: click,
},
[
h('div', { class: 'benefit-card-head' }, [
h('span', { class: 'benefit-kicker' }, p.eyebrow || '使用须知'),
]),
h('h3', p.title || '兑换说明'),
h('p', { class: 'benefit-notice-copy' }, p.content || ''),
],
);
case 'benefit-product-list': {
const benefitItems = Array.isArray(p.items) ? p.items : [];
return h(
'section',
{
class: 'benefit-card benefit-product-list',
style: { ...base, borderRadius: px(p.borderRadius, 16) },
onClick: click,
},
[
h('div', { class: 'benefit-list-title' }, [
h('span', { class: 'benefit-kicker' }, p.title || '更多权益'),
p.showCount !== false
? h('span', { class: 'benefit-count' }, `${benefitItems.length}`)
: null,
]),
h(
'div',
{ class: 'benefit-list-items' },
benefitItems.map((item: any, index: number) =>
h('article', { key: index }, [
h('div', [
h('b', item.name || '未命名权益'),
item.mode ? h('span', { class: 'list-mode' }, item.mode) : null,
]),
h('strong', { style: { color: p.priceColor || '#ff5562' } }, item.price || ''),
]),
),
),
],
);
}
default:
return h('div', { class: 'unknown' }, '此组件版本暂不支持');
}
};
},
});
export const PageRenderer = defineComponent({
props: { schema: { type: Object as PropType<PageSchema>, required: true } },
setup(p) {
return () =>
h(
'main',
{
class: [
'page-renderer',
{ 'benefits-page': p.schema.blocks.some((block) => block.type.startsWith('benefit-')) },
],
},
p.schema.blocks.map((b) => h(BlockView, { block: b, key: b.id })),
);
},
});

View File

@ -0,0 +1,6 @@
{
"name": "@demo/shared",
"version": "1.0.0",
"type": "module",
"exports": "./src/index.ts"
}

View File

@ -0,0 +1,2 @@
export type PreviewMessage = { type: 'select'; id: string } | { type: 'ready' };
export const send = (m: PreviewMessage) => parent.postMessage(m, '*');

11
playwright.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: { baseURL: 'http://localhost:5173' },
webServer: {
command: 'npm run dev',
url: 'http://localhost:5174',
reuseExistingServer: true,
timeout: 120_000,
},
});

231
tests/flow.spec.ts Normal file
View File

@ -0,0 +1,231 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ request }) => {
await expect
.poll(
async () => {
try {
return (await request.get('http://localhost:5174')).status();
} catch {
return 0;
}
},
{ timeout: 20_000 },
)
.toBe(200);
});
test('draft, immutable publish and mobile update', async ({ page, context, request }) => {
const api = 'http://localhost:3000/api/pages/demo-store';
const draft = await request.get(api + '/draft').then((r) => r.json());
await request.put(api + '/draft', { data: { ...draft, blocks: [] } });
await page.goto('/');
await page.getByText('标题', { exact: true }).first().dragTo(page.locator('.phone'));
await expect(page.locator('.materials li')).toHaveCount(1);
await page.getByRole('button', { name: '发布' }).click();
await expect(page.locator('.saved')).toContainText('已发布版本');
await expect(page.getByRole('status')).toContainText(/发布成功 · 当前版本 v\d+/);
const first = await request.get(api + '/published').then((r) => r.json());
const mobile = await context.newPage();
await mobile.goto('http://localhost:5174/?id=demo-store');
await expect(mobile.locator('h2')).toHaveCount(1);
await page.locator('.materials li').first().click();
await page.getByRole('button', { name: '复制' }).click();
await page.getByRole('button', { name: '保存草稿' }).click();
await mobile.reload();
await expect(mobile.locator('h2')).toHaveCount(1);
expect((await request.get(api + '/published').then((r) => r.json())).version).toBe(first.version);
await page.getByRole('button', { name: '发布' }).click();
await expect
.poll(async () => (await request.get(api + '/published').then((r) => r.json())).version)
.toBe(first.version + 1);
await mobile.reload();
await expect(mobile.locator('h2')).toHaveCount(2);
expect((await request.get(api + '/published').then((r) => r.json())).version).toBe(
first.version + 1,
);
});
test('two-column regions can be edited independently', async ({ page, request }) => {
const api = 'http://localhost:3000/api/pages/demo-store';
const draft = await request.get(api + '/draft').then((r) => r.json());
await request.put(api + '/draft', { data: { ...draft, blocks: [] } });
await page.goto('/');
await page.getByText('双列容器', { exact: true }).first().click();
await page.getByLabel('左栏内容').fill('左侧活动专区');
await page.getByLabel('右栏内容').fill('右侧新品推荐');
const preview = page.frameLocator('iframe[title="H5 实时预览"]');
await expect(preview.locator('.cols div').first()).toHaveText('左侧活动专区');
await expect(preview.locator('.cols div').nth(1)).toHaveText('右侧新品推荐');
});
test('all material-specific editors update the shared preview', async ({ page, request }) => {
const api = 'http://localhost:3000/api/pages/demo-store';
const draft = await request.get(api + '/draft').then((r) => r.json());
await request.put(api + '/draft', { data: { ...draft, blocks: [] } });
await page.goto('/');
const material = (name: string) =>
page.locator('.material-grid').getByRole('button', { name, exact: true });
const preview = page.frameLocator('iframe[title="H5 实时预览"]');
await material('横幅 Banner').click();
await page.getByLabel('Banner 主标题').fill('可编辑横幅');
await page.getByLabel('Banner 高度').fill('220');
await expect(preview.locator('.r-banner h1')).toHaveText('可编辑横幅');
await expect(preview.locator('.r-banner')).toHaveCSS('height', '220px');
await material('标题').click();
await page.getByLabel('标题文字').fill('可编辑标题');
await page.getByLabel('标题字号').fill('32');
await expect(preview.locator('h2')).toHaveText('可编辑标题');
await expect(preview.locator('h2')).toHaveCSS('font-size', '32px');
await material('文本').click();
await page.getByLabel('正文内容').fill('正文内容与排版均可调整');
await page.getByLabel('正文行高').fill('2');
await expect(preview.locator('.r-text')).toHaveText('正文内容与排版均可调整');
await expect(preview.locator('.r-text')).toHaveCSS('line-height', '28px');
await material('图片').click();
await page.getByLabel('图片说明').fill('编辑后的图片说明');
await page.getByLabel('图片高度').fill('180');
await page.getByLabel('图片圆角').fill('16');
await expect(preview.locator('.r-image')).toHaveAttribute('alt', '编辑后的图片说明');
await expect(preview.locator('.r-image')).toHaveCSS('height', '180px');
await material('按钮').click();
await page.getByLabel('按钮文字', { exact: true }).fill('查看详情');
await page.getByLabel('按钮背景颜色').fill('#245f57');
await page.getByLabel('点击行为').selectOption('link');
await page.getByLabel('目标链接').fill('/detail');
await expect(preview.locator('.r-button')).toHaveText('查看详情');
await expect(preview.locator('.r-button')).toHaveCSS('background-color', 'rgb(36, 95, 87)');
await material('商品列表').click();
await page.getByLabel('商品 1 名称').fill('手工咖啡杯');
await page.getByRole('button', { name: '添加商品' }).click();
await page.getByLabel('商品 3 名称').fill('夏日随行杯');
await page.getByLabel('商品列数').selectOption('1');
await expect(preview.locator('.products article')).toHaveCount(3);
await expect(preview.locator('.products article').first().locator('b')).toHaveText('手工咖啡杯');
await expect(preview.locator('.products')).toHaveCSS('grid-template-columns', '350px');
await material('分割线').click();
await page.getByLabel('分割线粗细').fill('4');
await page.getByLabel('分割线线型').selectOption('dashed');
await expect(preview.locator('hr')).toHaveCSS('border-top-width', '4px');
await expect(preview.locator('hr')).toHaveCSS('border-top-style', 'dashed');
await material('间距').click();
await page.getByLabel('间距高度').evaluate((el) => {
const input = el as HTMLInputElement;
input.value = '96';
input.dispatchEvent(new Event('input', { bubbles: true }));
});
await expect(preview.locator('.r-spacer')).toHaveCSS('height', '96px');
await material('双列容器').click();
await page.getByLabel('左栏内容').fill('品牌故事');
await page.getByLabel('右栏内容').fill('新品指南');
await page.getByLabel('双列间距').fill('20');
await expect(preview.locator('.cols div').first()).toHaveText('品牌故事');
await expect(preview.locator('.cols')).toHaveCSS('gap', '20px');
});
test('banner supports multiple images, looping and autoplay interval', async ({
page,
request,
}) => {
const api = 'http://localhost:3000/api/pages/demo-store';
const draft = await request.get(api + '/draft').then((r) => r.json());
await request.put(api + '/draft', { data: { ...draft, blocks: [] } });
await page.goto('/');
await page
.locator('.material-grid')
.getByRole('button', { name: '横幅 Banner', exact: true })
.click();
await page.getByRole('button', { name: '添加轮播图片' }).click();
await page
.getByLabel('Banner 图片 2', { exact: true })
.fill('https://example.com/banner-two.webp');
await page.getByLabel('Banner 图片 2 说明').fill('第二张轮播图');
await page.getByLabel('开启轮播').check();
const preview = page.frameLocator('iframe[title="H5 实时预览"]');
const image = preview.locator('.carousel-slide');
await expect(preview.locator('.carousel-dots button')).toHaveCount(2);
await preview.getByRole('button', { name: '下一张' }).click();
await expect(image).toHaveAttribute('src', 'https://example.com/banner-two.webp');
await expect(image).toHaveAttribute('alt', '第二张轮播图');
await preview.getByRole('button', { name: '上一张' }).click();
await page.getByLabel('循环播放').uncheck();
await page.getByLabel('自动轮播').check();
await page.getByLabel('自动轮播时间').fill('1');
await expect
.poll(() => image.getAttribute('src'), { timeout: 2500 })
.toBe('https://example.com/banner-two.webp');
await page.waitForTimeout(1200);
await expect(image).toHaveAttribute('src', 'https://example.com/banner-two.webp');
await page.getByLabel('循环播放').check();
await expect
.poll(() => image.getAttribute('src'), { timeout: 2500 })
.not.toBe('https://example.com/banner-two.webp');
});
test('editor and H5 service URLs follow the current LAN host', async ({ page }) => {
await page.goto('http://127.0.0.1:5173');
await expect(page.locator('iframe[title="H5 实时预览"]')).toHaveAttribute(
'src',
'http://127.0.0.1:5174/?preview=1',
);
await expect(page.getByRole('link', { name: '打开移动端' })).toHaveAttribute(
'href',
'http://127.0.0.1:5174/?id=demo-store',
);
await page.goto('http://127.0.0.1:5174/?id=demo-store');
await expect(page.locator('.page-renderer')).toBeVisible();
});
test('benefit library keeps marketing materials and renders direct recharge components', async ({
page,
request,
}) => {
const api = 'http://localhost:3000/api/pages/demo-store';
const draft = await request.get(api + '/draft').then((r) => r.json());
await request.put(api + '/draft', { data: { ...draft, blocks: [] } });
await page.goto('/');
await expect(page.getByRole('button', { name: '营销组件', exact: true })).toHaveClass(/active/);
await expect(page.getByRole('button', { name: '横幅 Banner', exact: true })).toBeVisible();
await page.getByRole('button', { name: /直充 \/ 卡密/ }).click();
await expect(page.getByRole('button', { name: /直充 \/ 卡密/ })).toHaveClass(/active/);
await expect(page.getByRole('button', { name: '权益商品卡片', exact: true })).toBeVisible();
await page.getByRole('button', { name: '添加业务示例组合' }).click();
const preview = page.frameLocator('iframe[title="H5 实时预览"]');
await expect(page.locator('.materials li')).toHaveCount(4);
await expect(preview.locator('.benefit-product-card')).toBeVisible();
await expect(preview.locator('.benefit-actions')).toBeVisible();
await expect(preview.locator('.benefit-notice')).toBeVisible();
await expect(preview.locator('.benefit-product-list')).toBeVisible();
await page.locator('.materials li').first().click();
await page.getByLabel('权益商品名称').fill('网易云音乐黑胶月卡');
await page.getByLabel('权益交付方式').selectOption('code');
await page.getByLabel('权益业务标签').fill('卡密');
await expect(preview.locator('.benefit-product-card .benefit-name-row b')).toHaveText(
'网易云音乐黑胶月卡',
);
await expect(preview.locator('.benefit-product-card .delivery-badge')).toHaveText('卡密');
await page.getByRole('button', { name: '营销组件', exact: true }).click();
await expect(page.getByRole('button', { name: '横幅 Banner', exact: true })).toBeVisible();
await expect(page.locator('.materials li')).toHaveCount(4);
});