feat: add low-code H5 editor demo
This commit is contained in:
commit
97793b77cf
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
dist/
|
||||
data.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
**/dist
|
||||
test-results
|
||||
playwright-report
|
||||
apps/server/data.json
|
||||
package-lock.json
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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">仅支持 HTTPS、HTTP 或站内相对路径</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>`,
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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'] } } } },
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@demo/server",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node src/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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,
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "@demo/materials",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@demo/page-schema": "*"
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@demo/page-schema",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@demo/shared",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export type PreviewMessage = { type: 'select'; id: string } | { type: 'ready' };
|
||||
export const send = (m: PreviewMessage) => parent.postMessage(m, '*');
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue