Files
danyang-match/scripts/preview-miniapp.mjs
T
Codex ce172ffd4f feat(ui): activity 页迁 TS + 修复过期契约 + 新增 11 页静态预览器
## 背景
2026-09-13 UI 重设计方案的 13 个契约门禁中 3 个为红,逐一查明性质后分类处理。

## 1. activity 页迁移 TypeScript(11/11 页现已全 TS)
- activity.js → activity.ts(139 行),业务逻辑零改动,仅补类型
- 此前 STATUS.md 记「9/11 TS-only、3 页待迁」,但 messages/matchmaker
  已在 4f91be0 迁完,实际只剩 activity 一页,本提交后清零

## 2. miniapp-reference-contracts 契约自身过期(非代码问题)
- 契约引用 messages.js / matchmaker.js / activity.js,但页面已 TS 化,
  readFileSync 抛裸 ENOENT 栈,看不出「契约引用了陈旧文件名」这一真实原因
- 改为统一走 readPageLogic(page):断言 .ts 存在 + .js 不存在(锁单一运行源),
  找不到文件时给出可读断言而非崩溃
- 核心 11 页循环内统一加 TS-only 校验,防止 JS 回流

## 3. phase07-home-contracts 契约断言架构写反(非代码问题)
- 契约要求 HomeApplicationService 字面出现 userIdentityMapper.findByUserId,
  但实际是 capabilityService.isIdentityVerified(userId)(HomeApplicationService:116)
- 这符合 AGENTS.md #26「前端入口可见性由服务端 CapabilityService 返回决定」,
  让首页穿透 Capability 直接持有 mapper 反而是架构重复
- 且契约自身另一条已断言 CapabilityService 必须有 isIdentityVerified,
  两条要求互相矛盾
- 改为:查库断言落在 caps 侧;首页断言必须委托 capabilityService.isIdentityVerified;
  新增 doesNotMatch(home, /userIdentityMapper/) 禁止首页绕过 Capability
- 保留原有 doesNotMatch(home, /setVerified\(true\)/) 防硬编码断言

## 4. 新增 11 页静态预览器(本机无微信开发者工具)
- scripts/preview-miniapp.mjs:把 WXML + WXSS 转成可浏览器打开的静态 HTML
- 能力:wx:if/wx:elif/wx:else 链、wx:for 列表、嵌套三元、比较/取反运算、
  自定义组件按属性展开、slot 替换、rpx→px 换算、组件样式收集
- mock 数据严格对齐 types/index.d.ts 与各页 WXML 实际读取字段
  (如 regionName/heightCm/educationCode、status: registration_open),
  不用想象字段,避免误导验收判断
- 产出 artifacts/ui-redesign/miniapp/preview.html(11 页可切换,284K)
- 明确边界:非真机渲染、不执行页面 JS、不连后端,
  只用于视觉走查,不得作为功能验收证据

## 验证
- 契约门禁 12/13 通过(唯一红的 ui-document-baseline-contracts 涉及
  STATUS.md 验收口径,待用户看真机效果后裁决,本轮不动)
- 11 页渲染体量:116/70/47/56/94/68/39/39/41/37/38 个类名元素,
  零残留 {{}} 插值、零残留原始组件标签
- 小程序 tsc --noEmit 通过
2026-09-16 12:33:48 +08:00

791 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,
quickEntries: [
{ code: 'find_object', label: '找对象', enabled: true, tone: 'rose', iconAsset: '/assets/icons/home/find-object.png' },
{ code: 'matchmaker', label: '公益红娘', enabled: true, tone: 'purple', iconAsset: '/assets/icons/home/matchmaker.png' },
{ code: 'activities', label: '公益活动', enabled: true, tone: 'blue', iconAsset: '/assets/icons/home/activity.png' },
{ code: 'counseling', label: '婚恋咨询', enabled: true, tone: 'pink', iconAsset: '/assets/icons/home/counseling.png' },
{ code: 'classroom', label: '婚恋课堂', enabled: true, tone: 'amber', iconAsset: '/assets/icons/home/classroom.png' },
{ code: 'premarital', label: '婚前辅导', enabled: true, tone: 'indigo', iconAsset: '/assets/icons/home/premarital.png' },
{ code: 'anti_fraud', label: '婚恋反诈', enabled: true, tone: 'red', iconAsset: '/assets/icons/home/anti-fraud.png' },
{ code: 'service_station', label: '善缘服务站', enabled: true, tone: 'green', iconAsset: '/assets/icons/home/service-station.png' },
],
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
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()