#!/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) : '') // ============================================================ // 资源路径改写 // WXML/WXSS 写的是小程序根绝对路径 /assets/...;浏览器以 file:// 打开本预览时 // /assets 会被解析到文件系统根目录 → 图片全部 404(表现为破图)。 // preview.html 位于 artifacts/ui-redesign/miniapp/,向上三级即仓库根。 // ============================================================ const ASSET_PREFIX = '../../../miniapp' const rewriteAssetUrls = (s) => s .replace(/(\bsrc=")\/assets\//g, `$1${ASSET_PREFIX}/assets/`) .replace(/(\bsrc=')\/assets\//g, `$1${ASSET_PREFIX}/assets/`) .replace(/url\((["']?)\/assets\//g, `url($1${ASSET_PREFIX}/assets/`) // ============================================================ // 原生 tabBar 复刻 // 小程序 tabBar 由 app.json 声明、由客户端原生渲染,**不存在于任何 WXML**。 // 静态预览若不补这一层,会让人误判为「底部导航图标丢了」。 // 5 项与 app.json 的 tabBar.list 一一对应(含选中态图标)。 // ============================================================ const TAB_ACTIVE_BY_PAGE = { index: 'home', discover: 'discover', messages: 'message', activity: 'activity', mine: 'mine', } const TAB_ITEMS = [ { key: 'home', label: '首页' }, { key: 'discover', label: '找对象' }, { key: 'message', label: '消息' }, { key: 'activity', label: '活动' }, { key: 'mine', label: '我的' }, ] function renderTabbar(page) { const active = TAB_ACTIVE_BY_PAGE[page] if (!active) return '' const items = TAB_ITEMS.map((it) => { const on = it.key === active const file = on ? `${it.key}-active.png` : `${it.key}.png` return `
` }).join('') return `` } // ============================================================ // 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 '' 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) 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(/${page}.wxml 不存在
`, 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 = `渲染失败: ${e.message}`
}
return { html: `