Files
danyang-match/scripts/preview-miniapp.mjs
T
WorkBuddy f4e00b4d95 feat(miniapp): 首页 4×2 入口宫格图标改版为参考图 2 的渐变砖 + 白色图形
参考图 2 的视觉语言是「高饱和渐变圆角砖 + 白色描画图形 + 主标题/副标题双层文字」,
原实现是「浅色底砖 + 彩色线性 PNG + 单行标题」,两者差异较大,本轮按参考图重做。

- app.wxss:新增 8 个 --gradient-entry-* 渐变 token 与 --mini-entry-shadow。
  白图形的判据取**非文本图形 3:1**(WCAG 1.4.11);渐变是斜向的、图形又跨在两色之间,
  故以「较亮端」为准逐色实算(3.00~3.02:1,较暗端 3.54~4.08:1),实算值写在 token 注释里。
- index.wxss:.entry-icon 由 72rpx 浅色砖改为 96rpx 渐变砖(radius 28rpx + 落影);
  图形改用 data-uri SVG 背景层(几何图形统一 24×24 viewBox),砖面走 --gradient-entry-*;
  新增 .entry-sub(12px muted 副标题);删除已无用的 .entry-icon-image。
  小屏 @media(≤340px) 同步收紧,副标题不降到 micro —— micro 在本项目是「仅角标/装饰」档位,
  这行字承载语义,不属于装饰。
- index.wxml:宫格项去掉 <image>,图标改为纯 CSS 砖;补 aria-label 承接图形语义。
- index.ts:tone 名与色相语义对齐(rose/orange/violet/blue/purple/pink/green/amber),
  并补 subtitle;entry 不再持有 iconAsset(图标不再是图片资源)。
- preview-miniapp.mjs:首页 mock 同步 tone/subtitle,预览才能看到真实砖面与副标题。

渲染态实测(无头浏览器 + CDP,非真机渲染):
- 8 个 .entry-item 均渲染出 label + subtitle;.entry-icon 计算样式 48×48px / radius 14px /
  background-image 含 svg 且为两层(图形 26px + 渐变铺满);label 14px/600;sub 12px #7D839B。
- 证据截图:artifacts/ui-redesign/miniapp/home-entries-page.png 与 home-entries-grid-zoom.png。

门禁:UI 契约 23/23;语种审计 0 命中;tsc --noEmit 通过;wcc/wcsc 真编译 88 WXSS + 86 WXML / 0 失败。

未改(需产品确认):第 8 项业务名仍是「善缘服务站」(参考图 2 写作「万善缘地」),
副标题自拟为「就在身边」;渐变整体比参考图略深,以保证白图形对比度达标。
2026-09-23 15:22:23 +08:00

797 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 小程序页面静态预览生成器
*
* 用途:本机无微信开发者工具时,把 WXML + WXSS 转成可浏览器打开的静态 HTML,
* 用于核对布局、配色、区块顺序是否符合参考图语言。
*
* 明确边界(不得混淆):
* - 这不是真机渲染,不执行页面 JS 逻辑,不连后端 API
* - wx:if / wx:for 按注入的 mock 数据求值;数据由 MOCK 按页面提供
* - 组件(ui-*)以等效 HTML 结构近似呈现,样式取自组件自身 wxss
* - 结论只能用于视觉走查,不能作为「功能验收通过」的证据
*
* 用法:node scripts/preview-miniapp.mjs [页面名...]
* node scripts/preview-miniapp.mjs # 全部 11 页
* 产出:artifacts/ui-redesign/miniapp/preview.html(含页面切换)
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const OUT_DIR = resolve(root, 'artifacts/ui-redesign/miniapp')
const PAGES = [
'index', 'discover', 'messages', 'activity', 'mine',
'matchmaker', 'counseling-intro', 'premarital-intro',
'content', 'anti-fraud', 'service-station',
]
const requested = process.argv.slice(2).filter((a) => !a.startsWith('-'))
const targets = requested.length ? requested : PAGES
const read = (p) => readFileSync(resolve(root, p), 'utf8')
const readIf = (p) => (existsSync(resolve(root, p)) ? read(p) : '')
// ============================================================
// mock 数据:让各页在静态预览下呈现「有数据」的真实布局
// 键为页面名,值为该页 data 中会被 WXML 消费的字段
// ============================================================
const MOCK = {
index: {
unreadCount: 3,
loading: false,
loadError: false,
isOnboarding: false,
isVerified: true,
isPaused: false,
isSuccessful: false,
statusChecks: [
{ label: '实名认证', done: true },
{ label: '资料审核', done: true },
{ label: '本地资格', done: false },
],
banners: [{ id: 'b1', title: '在丹阳,遇见认真的彼此', subtitle: '公益 · 本地 · 真实 · 安全' }],
currentBannerIndex: 0,
// 与 pages/index/index.ts 的 ENTRY_VISUALS 保持一致(tone + subtitle)。
// 图标已改为 CSS 渐变砖 + data-uri 白色图形,故此处不再有 iconAsset ——
// 预览要看到的是真实砖面与副标题,而不是一张假的图片路径。
quickEntries: [
{ code: 'find_object', label: '找对象', enabled: true, tone: 'rose', subtitle: '真实邂逅' },
{ code: 'matchmaker', label: '公益红娘', enabled: true, tone: 'orange', subtitle: '官方牵线' },
{ code: 'activities', label: '公益活动', enabled: true, tone: 'violet', subtitle: '线下联谊' },
{ code: 'counseling', label: '婚恋咨询', enabled: true, tone: 'blue', subtitle: '专业指导' },
{ code: 'classroom', label: '婚恋课堂', enabled: true, tone: 'purple', subtitle: '提升自我' },
{ code: 'premarital', label: '婚前辅导', enabled: true, tone: 'pink', subtitle: '幸福起航' },
{ code: 'anti_fraud', label: '婚恋反诈', enabled: true, tone: 'green', subtitle: '守护真爱' },
{ code: 'service_station', label: '善缘服务站', enabled: true, tone: 'amber', subtitle: '就在身边' },
],
recommendations: [
{ userId: 'u1', displayName: '王女士', age: 28, heightCm: 163, regionName: '丹阳', educationCode: '本科', occupationCode: '教育', verified: true, reviewed: true, recommendationReasons: ['同城', '年龄相近', '兴趣重合'] },
{ userId: 'u2', displayName: '陈先生', age: 31, heightCm: 176, regionName: '丹阳', educationCode: '硕士', occupationCode: '制造业', verified: true, reviewed: true, recommendationReasons: ['同城', '择偶条件匹配'] },
],
recState: 'idle',
actionLoadingUserId: '',
serviceRecommendations: [
{ type: 'matchmaker', title: '公益红娘一对一', description: '由公益红娘协助梳理择偶条件,推荐更合适的人选。', ctaText: '了解服务' },
{ type: 'counseling', title: '婚恋咨询', description: '面对关系困惑,可预约专业咨询师谈谈。', ctaText: '预约咨询' },
],
activities: [
{ id: 'a1', title: '丹阳青年公益联谊会', locationName: '丹阳市文化中心', startAtText: '09-20 14:00' },
{ id: 'a2', title: '婚恋反诈公益讲堂', locationName: '善缘服务站·云阳街道', startAtText: '09-22 19:00' },
],
serviceLocation: {
name: '善缘服务站(云阳街道)',
shortName: '云阳街道服务站',
address: '丹阳市云阳街道人民路 128 号',
services: ['资料核验', '红娘咨询', '活动报名'],
},
articles: [
{ id: 'c1', title: '第一次见面,聊些什么更自然' },
{ id: 'c2', title: '如何判断对方是否认真' },
],
safetyNotice: {
text: '实名认证和人工审核可以提高信息真实性,交往过程中仍请谨慎涉及借款、投资和个人隐私。',
ctaText: '查看婚恋反诈指南',
},
emptyActions: [
{ text: '查看择偶条件', event: 'preference', variant: 'secondary' },
{ text: '申请公益红娘', event: 'matchmaker', variant: 'primary' },
],
previewMode: false,
},
discover: {
// 字段对齐 discover.wxml:users / regions / educations / maritalStatuses / applied / temp*
users: [
{ userId: 'u1', displayName: '王女士', age: 28, heightCm: 163, regionName: '丹阳', educationCode: '本科', occupationCode: '教育', verified: true, reviewed: true, recommendationReasons: ['同城', '年龄相近'] },
{ userId: 'u2', displayName: '陈先生', age: 31, heightCm: 176, regionName: '丹阳', educationCode: '硕士', occupationCode: '制造业', verified: true, reviewed: true, recommendationReasons: ['同城'] },
{ userId: 'u3', displayName: '李女士', age: 26, heightCm: 160, regionName: '丹阳', educationCode: '大专', occupationCode: '财务', verified: false, reviewed: false, recommendationReasons: [] },
],
loading: false,
loadingMore: false,
loadError: false,
hasActiveFilter: true,
showFilter: false,
keywordInput: '',
panelHighlight: '',
panelScrollTo: '',
actionLoadingUserId: '',
regions: [{ code: 'all', label: '不限' }, { code: 'danyang', label: '丹阳' }],
educations: [{ code: 'all', label: '不限' }, { code: 'bachelor', label: '本科' }, { code: 'master', label: '硕士' }],
maritalStatuses: [{ code: 'all', label: '不限' }, { code: 'never_married', label: '未婚' }],
applied: { minAge: '', maxAge: '', regionCode: 'all', educationCode: 'all', maritalStatus: 'all' },
tempMinAge: '', tempMaxAge: '', tempRegionCode: 'all', tempEducationCode: 'all', tempMaritalStatus: 'all',
filters: [
{ code: 'all', label: '全部', active: true },
{ code: 'local', label: '本地户籍', active: false },
{ code: 'age', label: '年龄相近', active: false },
{ code: 'new', label: '新加入', active: false },
{ code: 'verified', label: '已实名', active: false },
],
},
messages: {
conversations: [
{ conversationId: 'c1', targetUserId: 'u1', targetDisplayName: '王女士', lastMessage: '那我们周六下午见?', lastMessageAt: 1758000000000, lastMessageAtText: '14:32', unreadCount: 2, canSendMessage: true, relationStatus: 'mutual_match' },
{ conversationId: 'c2', targetUserId: 'u2', targetDisplayName: '陈先生', lastMessage: '谢谢你的建议,我会认真考虑的。', lastMessageAt: 1757900000000, lastMessageAtText: '昨天', unreadCount: 0, canSendMessage: true, relationStatus: 'mutual_match' },
{ conversationId: 'c3', targetUserId: 'u3', targetDisplayName: '李女士', lastMessage: '', lastMessageAt: 1757700000000, lastMessageAtText: '3 天前', unreadCount: 0, canSendMessage: false, relationStatus: 'unmatched' },
],
loading: false,
unreadTotal: 2,
emptyActions: [{ text: '去今日推荐', event: 'home', variant: 'primary' }],
},
activity: {
activities: [
{ activityId: 'a1', id: 'a1', title: '丹阳青年公益联谊会', startAtText: '09-20 14:00', locationName: '丹阳市文化中心', feeType: 'free', status: 'registration_open', capacity: 60, registeredCount: 38, intro: '面向丹阳本地单身青年,公益免费,含破冰互动与自由交流环节。' },
{ activityId: 'a2', id: 'a2', title: '婚恋反诈公益讲堂', startAtText: '09-22 19:00', locationName: '善缘服务站·云阳街道', feeType: 'free', status: 'registration_open', capacity: 40, registeredCount: 22, intro: '识别婚恋交往中的常见诈骗套路,公益讲师现场答疑。' },
{ activityId: 'a3', id: 'a3', title: '沟通与相处·婚恋课堂', startAtText: '09-25 15:00', locationName: '丹阳市图书馆', feeType: 'free', status: 'registration_closed', capacity: 30, registeredCount: 30, intro: '从沟通模式入手,学习如何与对方建立稳定的相处节奏。' },
],
loading: false,
loadingMore: false,
hasMore: false,
firstLoaded: true,
activeCategory: 'all',
categories: [
{ code: 'all', label: '全部' },
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'classroom', label: '婚恋课堂' },
{ code: 'anti_fraud', label: '反诈宣传' },
],
emptyConfig: { title: '暂无进行中的活动', description: '近期暂无报名中的公益活动,请稍后再来', illustration: 'activity', actions: [] },
},
mine: {
// 字段对齐 mine.wxml
// 注意:必须显式给 isLoggedIn: true,否则预览只会渲染「未登录」分支(guest-brand),
// 而个人头部(profile-hero / 编辑资料 / 四项统计)才是这页真正要目视走查的版式。
isLoggedIn: true,
loadStatus: 'ready',
isRealNameVerified: true,
isProfileApproved: true,
profileCompleteness: 85,
matchCount: 2,
likersCount: 7,
relationshipText: '认真寻找中',
reviewStatusText: '资料已通过审核',
showLogoutDialog: false,
profile: { displayName: '王女士', avatarUrl: '', regionName: '丹阳' },
loginActions: [{ text: '登录', event: 'login', variant: 'primary' }],
logoutAction: { text: '退出登录', event: 'logout', variant: 'danger' },
},
matchmaker: {
// 字段对齐 matchmaker.wxml
loading: false,
navBarHeight: 64,
statusBarHeight: 20,
showRecommendations: true,
capabilities: { canApplyMatchmaker: true },
activeService: { serviceId: 's1', matchmakerName: '王老师', statusText: '服务中' },
historyServices: [
{ serviceId: 's0', matchmakerName: '李老师', statusText: '已完成' },
],
matchmakers: [
{ id: 'm1', name: '王老师', years: 8, intro: '擅长沟通引导,注重双方真实了解。', city: '丹阳' },
{ id: 'm2', name: '李老师', years: 5, intro: '关注本地青年婚恋需求,耐心细致。', city: '丹阳' },
],
},
'counseling-intro': {
// 字段对齐 counseling-intro.wxml
loading: false,
services: [
{ type: 'counseling', name: '一对一婚恋咨询', description: '面对关系困惑,与专业咨询师单独沟通。', iconAsset: '/assets/icons/home/counseling.png' },
{ type: 'group', name: '团体成长小组', description: '在小组中学习沟通与相处方式。', iconAsset: '/assets/icons/home/classroom.png' },
{ type: 'premarital', name: '婚前准备咨询', description: '为进入婚姻提前做好准备。', iconAsset: '/assets/icons/home/premarital.png' },
{ type: 'hotline', name: '公益咨询热线', description: '需要时,可以随时联系我们。', iconAsset: '/assets/icons/home/service-station.png' },
],
serviceStatus: '可预约',
hero: { title: '婚恋咨询', subtitle: '面对关系困惑,可预约专业咨询师谈谈' },
},
'premarital-intro': {
// 字段对齐 premarital-intro.wxml
loading: false,
topics: [
{ title: '沟通与表达', description: '学习把真实想法说清楚,也听懂对方。', iconAsset: '/assets/icons/home/premarital.png' },
{ title: '家庭与边界', description: '提前讨论双方家庭的相处边界。', iconAsset: '/assets/icons/home/counseling.png' },
{ title: '财务与规划', description: '把未来的开销与计划谈在前面。', iconAsset: '/assets/icons/home/classroom.png' },
{ title: '冲突与修复', description: '建立遇到分歧时的处理方式。', iconAsset: '/assets/icons/home/activity.png' },
],
serviceStatus: '可预约',
hero: { title: '婚前辅导', subtitle: '为即将步入婚姻的你们做好准备' },
},
content: {
// 字段对齐 content.wxml
categories: [
{ id: '', name: '推荐' },
{ id: 'c1', name: '相识沟通' },
{ id: 'c2', name: '关系经营' },
{ id: 'c3', name: '婚姻准备' },
],
activeCategoryId: '',
articles: [
{ id: 'a1', title: '第一次见面,聊些什么更自然', summary: '从共同话题切入,让交流更轻松。', categoryName: '相识沟通', viewCount: 1280, publishAtText: '2026-09-10', coverFileId: '' },
{ id: 'a2', title: '如何判断对方是否认真', summary: '观察行动而非承诺,是更可靠的判断方式。', categoryName: '关系经营', viewCount: 960, publishAtText: '2026-09-08', coverFileId: '' },
{ id: 'a3', title: '婚前需要沟通的五件事', summary: '把重要问题谈在前面,减少未来的分歧。', categoryName: '婚姻准备', viewCount: 1520, publishAtText: '2026-09-05', coverFileId: '' },
],
loading: false,
loadError: false,
},
'anti-fraud': {
// 字段对齐 anti-fraud.wxml
risks: [
{ title: '编造身份', description: '伪造职业、收入或婚恋状况获取信任。' },
{ title: '诱导投资', description: '以内部渠道、稳赚不赔为由引导转账。' },
{ title: '借款周转', description: '以突发困难为由频繁借款并拖延归还。' },
{ title: '隐私勒索', description: '索取私密照片或信息后实施威胁。' },
],
hero: { title: '婚恋反诈', subtitle: '识别常见套路,保护自己的安全与财产' },
},
'service-station': {
// 字段对齐 service-station.wxml
loading: false,
loadError: false,
serviceLocation: {
name: '善缘服务站(云阳街道)',
shortName: '云阳街道服务站',
address: '丹阳市云阳街道人民路 128 号',
contactPhoneDisplay: '0511-88888888',
openingHoursText: '周一至周六 09:00-17:30',
services: ['资料核验', '公益红娘咨询', '活动报名', '服务预约'],
latitude: 31.99,
longitude: 119.58,
},
},
}
// ============================================================
// 自定义组件展开表:组件名 → 组件源码目录
// 组件的属性(title/subtitle/action 等)作为局部 scope 渲染其 index.wxml
// ============================================================
const COMPONENT_DIRS = {}
for (const dir of readdirSync(resolve(root, 'miniapp/components'))) {
const base = `miniapp/components/${dir}`
if (!existsSync(resolve(root, `${base}/index.wxml`))) continue
// 约定别名:组件目录名加 ui- 前缀(页面 json 若声明了其他别名会在下方覆盖)
COMPONENT_DIRS[`ui-${dir}`] = base
}
/** 补齐实际使用的组件名(页面 json 中声明的别名) */
for (const page of PAGES) {
const json = readIf(`miniapp/pages/${page}/${page}.json`)
if (!json) continue
try {
for (const [alias, p] of Object.entries(JSON.parse(json).usingComponents || {})) {
// 声明形如 "/components/section-header/index",去掉前导 / 后需补 miniapp/ 前缀
// (若声明已是 "miniapp/..." 则直接用)
const normalized = String(p).replace(/^\//, '')
const base = normalized.startsWith('miniapp/')
? normalized
: `miniapp/${normalized.replace(/\/index$/, '')}`
if (existsSync(resolve(root, `${base}/index.wxml`))) COMPONENT_DIRS[alias] = base
}
} catch { /* ignore */ }
}
const componentCache = new Map()
function getComponentWxml(compName) {
const base = COMPONENT_DIRS[compName]
if (!base) return null
if (componentCache.has(compName)) return componentCache.get(compName)
const wxml = readIf(`${base}/index.wxml`)
componentCache.set(compName, wxml || null)
return wxml || null
}
// ============================================================
// WXML → HTML
// ============================================================
const TAG_MAP = {
view: 'div', text: 'span', image: 'img', 'scroll-view': 'div', swiper: 'div',
'swiper-item': 'div', block: null, navigator: 'a', button: 'button',
input: 'input', textarea: 'textarea', picker: 'div', form: 'form',
'movable-area': 'div', 'movable-view': 'div', canvas: 'canvas', map: 'div',
'cover-view': 'div', 'cover-image': 'img', 'rich-text': 'div', icon: 'span',
label: 'label', checkbox: 'input', radio: 'input', switch: 'input', slider: 'input',
'checkbox-group': 'div', 'radio-group': 'div', progress: 'div', 'open-data': 'div',
}
/** 解析表达式(属性访问 / 三元 / 字面量)。入参可带 {{}} 包裹,内部统一剥离 */
function evalExpr(expr, scope) {
let code = String(expr ?? '').trim()
// 允许传入 "{{ expr }}" 或 "expr" 两种形式
const wrapped = code.match(/^\{\{([\s\S]*)\}\}$/)
if (wrapped) code = wrapped[1].trim()
if (!code) return ''
// 逻辑取反 !expr
if (code.startsWith('!')) {
return !truthyValue(code.slice(1).trim(), scope)
}
// 字符串字面量
if (/^'[^']*'$/.test(code) || /^"[^"]*"$/.test(code)) return code.slice(1, -1)
// 数字 / 布尔
if (/^-?\d+(\.\d+)?$/.test(code)) return Number(code)
if (code === 'true') return true
if (code === 'false') return false
// 比较运算 === / !== / == / != / >= / <= / > / <
for (const op of ['===', '!==', '>=', '<=', '==', '!=', '>', '<']) {
const parts = splitTopLevel(code, op)
if (parts.length === 2) {
const [a, b] = parts.map((p) => evalExpr(p, scope))
switch (op) {
case '===': case '==': return a === b
case '!==': case '!=': return a !== b
case '>=': return Number(a) >= Number(b)
case '<=': return Number(a) <= Number(b)
case '>': return Number(a) > Number(b)
case '<': return Number(a) < Number(b)
}
}
}
// 不含运算符、也不是合法标识符路径的,按静态文案原样返回
// (组件属性常直接写中文文案,如 title="今日推荐",不能当作变量名去 scope 里取)
const isPathLike = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*|\[[^\]]+\])*$/.test(code)
if (!isPathLike) {
// 可能是含运算符的表达式(a ? b : c / a || b / a && b),继续走下方分支
const hasOperator = /[?:]|\|\||&&/.test(code)
if (!hasOperator) return code
}
// 三元 a ? b : c(含嵌套时仅处理最外层一层)
const q = splitTopLevel(code, '?')
if (q.length === 2) {
const colon = splitTopLevel(q[1], ':')
if (colon.length === 2) {
return evalExpr(truthyValue(q[0], scope) ? colon[0] : colon[1], scope)
}
}
// || 与 && 兜底
for (const op of ['||', '&&']) {
const parts = splitTopLevel(code, op)
if (parts.length > 1) {
const vals = parts.map((p) => evalExpr(p, scope))
return op === '||' ? vals.find(Boolean) ?? '' : (vals.every(Boolean) ? vals[vals.length - 1] : false)
}
}
// 数组/对象字面量
if (code === '[]') return []
if (code === '{}') return {}
if (!isPathLike) return code
// 属性路径
const path = code.split('.')
let cur = scope
for (const key of path) {
if (cur == null) return ''
cur = cur[key]
}
return cur == null ? '' : cur
}
/** 取 truthy 值(用于 wx:if) */
function truthyValue(expr, scope) {
const v = evalExpr(expr, scope)
if (Array.isArray(v)) return v.length > 0
return !!v
}
function splitTopLevel(str, sep) {
const out = []
let depth = 0, quote = null, start = 0
for (let i = 0; i < str.length; i++) {
const ch = str[i]
if (quote) { if (ch === quote) quote = null; continue }
if (ch === "'" || ch === '"') { quote = ch; continue }
if ('([{'.includes(ch)) depth++
else if (')]}'.includes(ch)) depth--
else if (depth === 0 && str.startsWith(sep, i)) {
out.push(str.slice(start, i))
i += sep.length - 1
start = i + 1
}
}
out.push(str.slice(start))
return out
}
/** 用 scope 插值整串文本,支持多个 {{}} */
function interpolate(str, scope) {
return str.replace(/\{\{([\s\S]*?)\}\}/g, (_, expr) => {
const v = evalExpr(expr, scope)
if (v == null || v === false) return ''
if (typeof v === 'object') return ''
return String(v)
})
}
/** 极简 HTML 属性解析 */
function parseAttrs(attrStr, scope) {
const attrs = {}
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g
let m
while ((m = re.exec(attrStr))) {
const name = m[1]
const raw = m[2] ?? m[3] ?? m[4] ?? ''
attrs[name] = raw
}
return attrs
}
/** 构造 class:保留静态类名,插值去掉 {{}} */
function buildClass(staticClass, scope) {
return interpolate(staticClass, scope)
.split(/\s+/).filter(Boolean).join(' ')
}
/** 主体渲染:递归处理标签 */
function renderWxml(src, scope, depth = 0) {
if (depth > 60) return '<!-- depth limit -->'
let out = ''
let i = 0
while (i < src.length) {
const lt = src.indexOf('<', i)
if (lt === -1) { out += interpolate(src.slice(i), scope); break }
out += interpolate(src.slice(i, lt), scope)
// 注释
if (src.startsWith('<!--', lt)) {
const end = src.indexOf('-->', lt)
i = end === -1 ? src.length : end + 3
continue
}
const gt = findTagEnd(src, lt)
if (gt === -1) { out += src.slice(lt); break }
const tagText = src.slice(lt + 1, gt)
const selfClose = /\/$/.test(tagText)
const body = selfClose ? tagText.slice(0, -1) : tagText
const spaceIdx = body.search(/\s/)
const rawName = (spaceIdx === -1 ? body : body.slice(0, spaceIdx)).trim()
const attrStr = spaceIdx === -1 ? '' : body.slice(spaceIdx)
const isClose = rawName.startsWith('/')
const name = isClose ? rawName.slice(1) : rawName
if (isClose) { out += closeTag(name); i = gt + 1; continue }
const attrs = parseAttrs(attrStr, scope)
// 找出配对结束位置(用于块级指令)
const closeIdx = selfClose ? gt + 1 : findClose(src, gt + 1, name)
// -------- wx:for --------
if (attrs['wx:for'] !== undefined) {
const listExpr = attrs['wx:for']
const list = evalExpr(listExpr, scope)
const itemName = attrs['wx:for-item'] || 'item'
const idxName = attrs['wx:for-index'] || 'index'
const innerStart = gt + 1
const innerEnd = closeIdx.start
const inner = innerEnd === -1 ? '' : src.slice(innerStart, innerEnd)
// 去掉 wx:for 属性后重新渲染
const cleanedAttrs = attrStr.replace(/wx:for(?:-item|-index)?\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '')
const arr = Array.isArray(list) ? list : []
for (let k = 0; k < arr.length; k++) {
const childScope = { ...scope, [itemName]: arr[k], [idxName]: k }
out += renderWxml(
`<${name}${cleanedAttrs}>${inner}</${name}>`,
childScope, depth + 1,
)
}
i = closeIdx.end
continue
}
// -------- wx:if / wx:elif / wx:else --------
if (attrs['wx:if'] !== undefined) {
const branchOk = truthyValue(attrs['wx:if'], scope)
// 自闭合标签没有配对闭合,closeIdx 必须落在标签自身之后
const scopedClose = selfClose ? { start: gt + 1, end: gt + 1 } : closeIdx
const inner = selfClose ? '' : src.slice(gt + 1, scopedClose.start === -1 ? src.length : scopedClose.start)
if (branchOk) {
const cleaned = attrStr.replace(/wx:if\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '')
out += renderWxml(`<${name}${cleaned}>${inner}</${name}>`, scope, depth + 1)
}
i = scopedClose.end === -1 ? src.length : scopedClose.end
// 吞掉紧随的 elif / else 兄弟节点
i = consumeElseChain(src, i, scope, out, (s) => { out += s })
continue
}
if (attrs['wx:elif'] !== undefined || attrs['wx:else'] !== undefined) {
// 没有前置 if 命中,跳过该分支
i = selfClose ? gt + 1 : (closeIdx.end === -1 ? src.length : closeIdx.end)
continue
}
// -------- 自定义组件:用属性作为局部 scope 展开 --------
const compWxml = getComponentWxml(name)
if (compWxml) {
const closeIdxC = selfClose ? { start: -1, end: gt + 1 } : findClose(src, gt + 1, name)
const slot = closeIdxC.start === -1 ? '' : src.slice(gt + 1, closeIdxC.start)
// 属性名去掉 bind:/wx: 前缀,绑定事件不参与渲染
const compScope = { ...scope }
for (const [k, v] of Object.entries(attrs)) {
if (k.startsWith('bind') || k.startsWith('catch') || k.startsWith('wx:') || k === 'class' || k === 'id') continue
// 属性值可能是「纯插值」或「文本+插值混合」,统一先尝试整体求值,失败则做插值替换
const wrapped = String(v).match(/^\{\{([\s\S]*)\}\}$/)
compScope[k] = wrapped ? evalExpr(wrapped[1], scope) : interpolate(String(v), scope)
}
// 支持 slot(部分组件用 slot 承载内容)
const innerRendered = slot ? renderWxml(slot, scope, depth + 1) : ''
let expanded = renderWxml(compWxml, compScope, depth + 1)
if (innerRendered) expanded = expanded.replace(/<slot\s*\/>|<slot><\/slot>/g, innerRendered)
out += `<div class="comp comp-${name}">${expanded}</div>`
i = closeIdxC.end === -1 ? src.length : closeIdxC.end
continue
}
// -------- 普通标签 --------
const htmlName = TAG_MAP[name] === undefined ? name : TAG_MAP[name]
if (htmlName === null) {
// block:只渲染内容
const inner = selfClose ? '' : src.slice(gt + 1, closeIdx.start === -1 ? src.length : closeIdx.start)
out += renderWxml(inner, scope, depth + 1)
i = closeIdx.end === -1 ? src.length : closeIdx.end
continue
}
const staticClass = attrs.class || ''
const cls = buildClass(staticClass, scope)
const htmlAttrs = []
if (cls) htmlAttrs.push(`class="${cls}"`)
if (attrs.id) htmlAttrs.push(`id="${interpolate(attrs.id, scope)}"`)
if (name === 'image' || htmlName === 'img') {
const srcV = attrs.src ? interpolate(attrs.src, scope) : ''
htmlAttrs.push(`src="${srcV || PLACEHOLDER}"`)
htmlAttrs.push('alt=""')
htmlAttrs.push('loading="lazy"')
}
if (attrs['data-id']) htmlAttrs.push(`data-id="${interpolate(attrs['data-id'], scope)}"`)
if (selfClose || ['img', 'input', 'br'].includes(htmlName)) {
out += `<${htmlName} ${htmlAttrs.join(' ')}>`
i = gt + 1
continue
}
const inner = closeIdx.start === -1 ? '' : src.slice(gt + 1, closeIdx.start)
out += `<${htmlName} ${htmlAttrs.join(' ')}>${renderWxml(inner, scope, depth + 1)}</${htmlName}>`
i = closeIdx.end === -1 ? src.length : closeIdx.end
}
return out
}
const PLACEHOLDER = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23E8EAF2'/%3E%3Ctext x='100' y='105' font-size='16' fill='%238890A8' text-anchor='middle'%3EIMG%3C/text%3E%3C/svg%3E"
function closeTag(name) { return '' } // 闭合由调用方成对输出,这里不重复
function findTagEnd(src, lt) {
let quote = null
for (let i = lt + 1; i < src.length; i++) {
const ch = src[i]
if (quote) { if (ch === quote) quote = null; continue }
if (ch === '"' || ch === "'") quote = ch
else if (ch === '>') return i
}
return -1
}
function findClose(src, from, name) {
const openRe = new RegExp(`<${name}\\b`, 'g')
const closeRe = new RegExp(`</${name}\\s*>`, 'g')
let depth = 1
let cursor = from
// 简化实现:顺序扫描最近的开闭标签
while (cursor < src.length) {
openRe.lastIndex = cursor
closeRe.lastIndex = cursor
const o = openRe.exec(src)
const c = closeRe.exec(src)
if (!c) return { start: -1, end: src.length }
if (o && o.index < c.index) {
// 自闭合开标签不增加深度
const selfEnd = findTagEnd(src, o.index)
const isSelf = selfEnd !== -1 && src[selfEnd - 1] === '/'
if (!isSelf) depth++
cursor = (selfEnd === -1 ? o.index + name.length : selfEnd) + 1
continue
}
depth--
if (depth === 0) return { start: c.index, end: c.index + c[0].length }
cursor = c.index + c[0].length
}
return { start: -1, end: src.length }
}
/** 处理 wx:if 之后的 elif/else 链:命中则追加,未命中则跳过 */
function consumeElseChain(src, from, scope, _out, emitter) {
let cursor = skipWs(src, from)
for (;;) {
if (src[cursor] !== '<') return cursor
// 任意标签(view/block/组件)都可能承载 wx:elif / wx:else,不做标签名限制
const lt = cursor
const gt = findTagEnd(src, lt)
if (gt === -1) return cursor
const tagText = src.slice(lt + 1, gt)
const selfClose = /\/$/.test(tagText)
const body = selfClose ? tagText.slice(0, -1) : tagText
const spaceIdx = body.search(/\s/)
const name = (spaceIdx === -1 ? body : body.slice(0, spaceIdx)).trim()
if (!name || name.startsWith('/')) return cursor
const attrStr = spaceIdx === -1 ? '' : body.slice(spaceIdx)
const attrs = parseAttrs(attrStr, scope)
if (attrs['wx:elif'] === undefined && attrs['wx:else'] === undefined) return cursor
const closeIdx = selfClose ? { start: gt + 1, end: gt + 1 } : findClose(src, gt + 1, name)
const inner = selfClose ? '' : (closeIdx.start === -1 ? '' : src.slice(gt + 1, closeIdx.start))
const ok = attrs['wx:elif'] !== undefined ? truthyValue(attrs['wx:elif'], scope) : true
if (ok) {
const cleaned = attrStr
.replace(/wx:elif\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '')
.replace(/wx:else\s*=\s*(?:"[^"]*"|'[^']*'|\S+)/g, '')
.replace(/\bwx:else\b/g, '')
emitter(renderWxml(`<${name}${cleaned}>${inner}</${name}>`, scope))
return closeIdx.end === -1 ? src.length : closeIdx.end
}
cursor = closeIdx.end === -1 ? src.length : closeIdx.end
cursor = skipWs(src, cursor)
}
}
function skipWs(src, from) {
let i = from
while (i < src.length && /\s/.test(src[i])) i++
return i
}
// ============================================================
// WXSS → CSS:rpx 换算、page 选择器映射、token 补全
// ============================================================
function convertWxss(src, deviceWidth) {
const scale = deviceWidth / 750
return src
// rpx → px
.replace(/([\d.]+)rpx/g, (_, n) => `${(Number(n) * scale).toFixed(2)}px`)
// page 选择器 → .wx-page(小程序页面根)
.replace(/(^|[}\s,])page\b/g, '$1.wx-page')
// 720/设备:环境变量降级
.replace(/env\(safe-area-inset-top\)/g, '20px')
.replace(/env\(safe-area-inset-bottom\)/g, '0px')
}
/** 递归收集组件样式 */
function collectComponentCss(pageName, depth = 0, seen = new Set()) {
if (depth > 3) return ''
const jsonPath = `miniapp/pages/${pageName}/${pageName}.json`
if (!existsSync(resolve(root, jsonPath))) return ''
try {
const json = JSON.parse(read(jsonPath))
let css = ''
for (const [, compPath] of Object.entries(json.usingComponents || {})) {
const base = COMPONENT_DIRS[String(compPath).replace(/^\//, '').replace(/\/index$/, '')]
|| `miniapp/${String(compPath).replace(/^\//, '').replace(/\/index$/, '')}`
if (seen.has(base)) continue
seen.add(base)
const wxss = readIf(`${base}/index.wxss`)
if (wxss) css += `\n/* ==== ${base} ==== */\n` + wxss
}
return css
} catch { return '' }
}
// ============================================================
// 产出
// ============================================================
function buildPage(page, deviceWidth = 375) {
const wxml = readIf(`miniapp/pages/${page}/${page}.wxml`)
if (!wxml) return { html: `<p style="padding:24px;color:#999">${page}.wxml 不存在</p>`, missing: true }
const pageWxss = readIf(`miniapp/pages/${page}/${page}.wxss`)
const appWxss = readIf('miniapp/app.wxss')
const serviceWxss = readIf('miniapp/styles/service-page.wxss')
const compWxss = collectComponentCss(page)
const css = convertWxss([appWxss, serviceWxss, compWxss, pageWxss].join('\n'), deviceWidth)
const scope = MOCK[page] || {}
let body = ''
try {
body = renderWxml(wxml, scope)
} catch (e) {
body = `<pre style="color:#c00;padding:16px">渲染失败: ${e.message}</pre>`
}
return { html: `<div class="wx-page reference-page">${body}</div>`, css, missing: false }
}
function main() {
if (process.argv.includes('--list-components')) {
for (const [k, v] of Object.entries(COMPONENT_DIRS)) console.log(`${k} -> ${v}`)
return
}
if (process.argv.includes('--debug-page')) {
const page = process.argv[process.argv.indexOf('--debug-page') + 1]
const built = buildPage(page)
// 只输出页面主体,便于检查结构
console.log(built.html.slice(0, 6000))
return
}
if (process.argv.includes('--debug-frag')) {
const fragPath = process.argv[process.argv.indexOf('--debug-frag') + 1]
const frag = readFileSync(resolve(root, fragPath), 'utf8')
const scope = MOCK.index
console.log(renderWxml(frag, scope))
return
}
mkdirSync(OUT_DIR, { recursive: true })
const built = targets.map((p) => ({ page: p, ...buildPage(p) }))
const nav = built.map((b, idx) =>
`<button class="nav-btn${idx === 0 ? ' active' : ''}" data-target="pg-${b.page}">${b.page}</button>`
).join('')
const sections = built.map((b, idx) => `
<section id="pg-${b.page}" class="preview-panel" style="display:${idx === 0 ? 'block' : 'none'}">
<div class="panel-caption">${b.page}.wxml${b.missing ? ' (缺失)' : ''}</div>
<div class="phone"><div class="phone-screen">${b.html}</div></div>
</section>`).join('\n')
const cssAll = built.map((b) => b.css || '').join('\n')
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>小程序 11 页静态预览 · 善缘丹阳</title>
<style>
:root { --ink:#1f2744; --muted:#7d839b; --line:#e6e9f2; --brand:#ff4f91; }
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background:#f4f5fa; color:var(--ink); }
.banner { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid var(--line);
padding:14px 20px; display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
.banner h1 { font-size:15px; margin:0 16px 0 0; font-weight:650; }
.banner .note { font-size:12px; color:var(--muted); margin-left:auto; }
.nav-btn { border:1px solid var(--line); background:#fff; color:var(--ink); border-radius:16px;
padding:5px 12px; font-size:12px; cursor:pointer; }
.nav-btn.active { background:var(--brand); border-color:var(--brand); color:#fff; }
.stage { padding:28px; display:flex; justify-content:center; }
.preview-panel { width:100%; max-width:420px; }
.panel-caption { font-size:12px; color:var(--muted); margin-bottom:10px; text-align:center; }
.phone { background:#fff; border-radius:36px; padding:12px; box-shadow:0 20px 60px rgba(28,30,88,.18); }
.phone-screen { width:100%; height:760px; overflow-y:auto; border-radius:26px; background:#F4F5FA; }
.wx-page { min-height:100%; }
/* ====== 以下为页面 WXSS 转换结果(rpx 已按 375 宽换算) ====== */
${cssAll}
</style>
</head>
<body>
<div class="banner">
<h1>小程序 11 页 · 静态预览</h1>
${nav}
<span class="note">本预览为 WXML/WXSS 静态还原,非真机渲染,不含真实接口数据</span>
</div>
<div class="stage">${sections}</div>
<script>
const btns = [...document.querySelectorAll('.nav-btn')]
const panels = [...document.querySelectorAll('.preview-panel')]
btns.forEach((b) => b.addEventListener('click', () => {
btns.forEach((x) => x.classList.toggle('active', x === b))
panels.forEach((p) => { p.style.display = p.id === 'pg-' + b.dataset.target.replace('pg-','') ? 'block' : 'none' })
}))
</script>
</body>
</html>`
const outFile = resolve(OUT_DIR, 'preview.html')
writeFileSync(outFile, html, 'utf8')
const ok = built.filter((b) => !b.missing).length
console.log(`已生成: ${outFile}`)
console.log(`页面数: ${ok}/${targets.length}`)
for (const b of built) console.log(` ${b.missing ? '✗' : '✓'} ${b.page}`)
if (ok < targets.length) process.exitCode = 1
}
main()