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 通过
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,45 @@
|
||||
* - 单向意向保密:本页不接入互选意向提交(仅在 activity-matches 页由用户主动操作,AGENTS 第 6 条)
|
||||
* - 下拉刷新 + onReachBottom 加载更多
|
||||
*/
|
||||
const { activityStore, appStore, authStore } = require('../../stores');
|
||||
const { to } = require('../../constants');
|
||||
import { activityStore, appStore, authStore } from '../../stores';
|
||||
import { to } from '../../constants';
|
||||
|
||||
interface ActivityCategory {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表项。沿用共享 Activity 类型,并允许服务端附加分类字段用于本地筛选。
|
||||
* 分类字段在共享类型中尚未收敛,此处显式声明,避免用 any 绕过类型检查。
|
||||
*/
|
||||
interface ActivityListItem {
|
||||
activityId?: string;
|
||||
id?: string;
|
||||
activityType?: string;
|
||||
category?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ActivityPageData {
|
||||
activities: ActivityListItem[];
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
firstLoaded: boolean;
|
||||
activeCategory: string;
|
||||
categories: ActivityCategory[];
|
||||
emptyConfig: {
|
||||
title: string;
|
||||
description: string;
|
||||
illustration: string;
|
||||
actions: Array<{ text: string; event: string; variant: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
activities: [],
|
||||
activities: [] as ActivityListItem[],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: true,
|
||||
@@ -34,11 +67,11 @@ Page({
|
||||
{ text: '我的活动', event: 'my', variant: 'secondary' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as ActivityPageData,
|
||||
|
||||
onLoad() {
|
||||
if (!authStore.state.isLoggedIn) return;
|
||||
this._unsub = activityStore.bindToPage(this, {
|
||||
(this as any)._unsub = activityStore.bindToPage(this, {
|
||||
activities: 'activities',
|
||||
activitiesHasMore: 'activitiesHasMore',
|
||||
});
|
||||
@@ -54,11 +87,11 @@ Page({
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (this._unsub) this._unsub();
|
||||
if ((this as any)._unsub) (this as any)._unsub();
|
||||
},
|
||||
|
||||
/** 首次或下拉刷新拉取活动列表 */
|
||||
async fetchActivities(first = false) {
|
||||
async fetchActivities(_first = false) {
|
||||
if (this.data.loading) return;
|
||||
this.setData({ loading: true });
|
||||
try {
|
||||
@@ -102,13 +135,13 @@ Page({
|
||||
this.loadMore();
|
||||
},
|
||||
|
||||
onCategoryTap(e) {
|
||||
this.setData({ activeCategory: e.currentTarget.dataset.code });
|
||||
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
|
||||
this.setData({ activeCategory: e.currentTarget.dataset.code as string });
|
||||
this.applyActivityFilter();
|
||||
},
|
||||
|
||||
applyActivityFilter() {
|
||||
const source = activityStore.state.activities || [];
|
||||
const source = (activityStore.state.activities || []) as ActivityListItem[];
|
||||
const { activeCategory } = this.data;
|
||||
this.setData({
|
||||
activities: activeCategory === 'all'
|
||||
@@ -118,14 +151,14 @@ Page({
|
||||
},
|
||||
|
||||
/** 点击活动卡片 → 跳转详情 */
|
||||
onActivityTap(e) {
|
||||
onActivityTap(e: any) {
|
||||
const activityId = e.detail.activityId;
|
||||
if (!activityId) return;
|
||||
wx.navigateTo({ url: to.activityDetail(activityId) });
|
||||
},
|
||||
|
||||
/** 空状态:去我的活动 */
|
||||
onEmptyAction(e) {
|
||||
onEmptyAction(e: any) {
|
||||
const { event } = e.detail;
|
||||
if (event === 'my') {
|
||||
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
|
||||
@@ -0,0 +1,790 @@
|
||||
#!/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()
|
||||
@@ -4,6 +4,25 @@ import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const read = (path) => readFileSync(resolve(root, path), 'utf8')
|
||||
/**
|
||||
* 需要读取页面运行源时先断言存在。
|
||||
* 直接 readFileSync 会抛裸 ENOENT 栈,看不出「契约引用了陈旧文件名」这一真实原因——
|
||||
* 页面在 2026-09-14 全量 TS 化后,本契约曾因仍引用 .js 而崩溃(而非给出可读断言)。
|
||||
*/
|
||||
const readSource = (path) => {
|
||||
assert.ok(existsSync(resolve(root, path)), `契约引用的运行源不存在: ${path}(页面已迁移?请更新契约路径)`)
|
||||
return read(path)
|
||||
}
|
||||
/** 断言页面运行源为 TypeScript 且不存在同名 .js 双源 */
|
||||
const readPageLogic = (page) => {
|
||||
const ts = `miniapp/pages/${page}/${page}.ts`
|
||||
assert.equal(
|
||||
existsSync(resolve(root, `miniapp/pages/${page}/${page}.js`)),
|
||||
false,
|
||||
`${page} 存在 .js 与 .ts 双源,违反单一运行源约定`,
|
||||
)
|
||||
return readSource(ts)
|
||||
}
|
||||
const app = JSON.parse(read('miniapp/app.json'))
|
||||
const corePages = [
|
||||
'index',
|
||||
@@ -43,6 +62,8 @@ for (const page of corePages) {
|
||||
const template = read(base + '.wxml')
|
||||
assert.match(template, /reference-page/, `${page} 未接入参考图页面骨架`)
|
||||
assert.doesNotMatch(template, /[\u{1F300}-\u{1FAFF}]/u, `${page} 含 Emoji`)
|
||||
// 首批 11 页必须为 TypeScript 单一运行源(readPageLogic 会同时断言 .js 不存在)
|
||||
readPageLogic(page)
|
||||
}
|
||||
|
||||
const homeLogic = read('miniapp/pages/index/index.ts')
|
||||
@@ -60,7 +81,7 @@ assert.match(discoverTemplate, /candidate-list/, '找对象页缺少参考图候
|
||||
assert.match(discoverTemplate, /\/assets\/icons\/ui\/search\.png/, '找对象页必须使用独立搜索图标')
|
||||
|
||||
const messagesTemplate = read('miniapp/pages/messages/messages.wxml')
|
||||
const messagesLogic = read('miniapp/pages/messages/messages.js')
|
||||
const messagesLogic = readPageLogic('messages')
|
||||
assert.match(messagesTemplate, /message-shortcuts/, '消息页缺少四项快捷入口')
|
||||
assert.match(messagesTemplate, /conversation-list/, '消息页缺少会话列表容器')
|
||||
assert.match(messagesTemplate, /ui-conv__badge|unreadTotal/, '消息页缺少未读提示')
|
||||
@@ -69,7 +90,7 @@ assert.match(messagesLogic, /canSendMessage/, '消息页进入聊天前必须校
|
||||
|
||||
const activityTemplate = read('miniapp/pages/activity/activity.wxml')
|
||||
const activityCard = read('miniapp/components/activity-card/index.wxml')
|
||||
const activityLogic = read('miniapp/pages/activity/activity.js')
|
||||
const activityLogic = readPageLogic('activity')
|
||||
assert.match(activityTemplate, /activity-categories/, '活动页缺少活动分类')
|
||||
assert.match(activityTemplate, /activity-calendar/, '活动页缺少日历入口')
|
||||
assert.match(activityCard, /ui-act__capacity/, '活动卡缺少人数和容量')
|
||||
@@ -85,7 +106,7 @@ for (const label of ['我的红娘', '我的咨询', '我的活动', '我的预
|
||||
}
|
||||
|
||||
const matchmakerTemplate = read('miniapp/pages/matchmaker/matchmaker.wxml')
|
||||
const matchmakerLogic = read('miniapp/pages/matchmaker/matchmaker.js')
|
||||
const matchmakerLogic = readPageLogic('matchmaker')
|
||||
const counselingTemplate = read('miniapp/pages/counseling-intro/counseling-intro.wxml')
|
||||
const premaritalTemplate = read('miniapp/pages/premarital-intro/premarital-intro.wxml')
|
||||
for (const template of [matchmakerTemplate, counselingTemplate, premaritalTemplate]) {
|
||||
@@ -104,7 +125,7 @@ assert.match(premaritalTemplate, /预约辅导/, '婚前辅导页缺少预约按
|
||||
assert.match(premaritalTemplate, /服务状态/, '婚前辅导页缺少服务状态')
|
||||
|
||||
const contentTemplate = read('miniapp/pages/content/content.wxml')
|
||||
const contentLogic = read('miniapp/pages/content/content.ts')
|
||||
const contentLogic = readPageLogic('content')
|
||||
assert.match(contentTemplate, /content-categories/, '婚恋课堂缺少分类导航')
|
||||
assert.match(contentTemplate, /content-list/, '婚恋课堂缺少内容列表')
|
||||
assert.match(contentTemplate, /viewCount/, '婚恋课堂缺少真实浏览量')
|
||||
@@ -120,7 +141,7 @@ assert.match(antiFraudTemplate, /举报可疑行为/, '婚恋反诈缺少举报
|
||||
assert.match(antiFraudTemplate, /安全求助/, '婚恋反诈缺少求助入口')
|
||||
|
||||
const stationTemplate = read('miniapp/pages/service-station/service-station.wxml')
|
||||
const stationLogic = read('miniapp/pages/service-station/service-station.ts')
|
||||
const stationLogic = readPageLogic('service-station')
|
||||
for (const label of ['地址', '联系电话', '开放时间', '服务项目', '地图定位', '预约到站服务']) {
|
||||
assert.ok(stationTemplate.includes(label), `服务站缺少 ${label}`)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,11 @@ for (const label of labels) {
|
||||
previous = position
|
||||
}
|
||||
|
||||
assert.match(home, /userIdentityMapper\.findByUserId/, '首页实名认证状态必须来自数据库')
|
||||
// 首页实名状态必须来自数据库,但按 AGENTS.md #26 只能经由 CapabilityService 委托,
|
||||
// 首页不得自行持有 userIdentityMapper 复制业务规则。查库断言落在 caps(CapabilityService)侧。
|
||||
assert.match(caps, /userIdentityMapper\.findByUserId/, 'Capability 实名状态必须来自数据库')
|
||||
assert.match(home, /capabilityService\.isIdentityVerified/, '首页实名状态必须委托 CapabilityService(AGENTS.md #26)')
|
||||
assert.doesNotMatch(home, /userIdentityMapper/, '首页不得绕过 CapabilityService 直接查实名表')
|
||||
assert.match(home, /recommendationEngineService\.getDailyRecommendations/, '首页推荐必须来自正式推荐引擎')
|
||||
assert.match(home, /contentArticleMapper\.findPublishedAll/, '首页课堂内容必须来自内容模块')
|
||||
assert.match(home, /serviceLocationMapper\.selectOne/, '首页服务站必须来自服务站模块')
|
||||
|
||||
Reference in New Issue
Block a user