- 移除微信订阅推送功能(云函数/订阅管理页),回归纯客户端零后端架构 - 新增今日黄历默认展示卡片 TodayAlmanacCard(9 语言翻译) - i18n 修复:启用 globalInjection 修复 $t 未注入,插值消息全部改为 Messages Functions 适配小程序端(63 处) - 修复底部导航切换失效与运势页数据不刷新 - 统一设计令牌(深褐主色 + 印红强调色),组件 UI 规范重构 - 图标 iconfont 化(FontAwesome 子集化,修复 Android 豆腐块) - 新增 postcss-px2rpx 机型自适应(仅 mp-weixin 构建生效) - 新增 5 个用户旅程 E2E 测试(31 用例)与验收报告 - 补充微信小程序项目配置(appid)
145 lines
5.8 KiB
JavaScript
145 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 小程序构建产物 UI 层级审计工具
|
||
* 用法: node scripts/ui-audit-mp-weixin.mjs [--dist <path>] [--report <path>]
|
||
* 默认 dist: dist/build/mp-weixin
|
||
*
|
||
* 审计维度:
|
||
* 1. 页面结构 (wxml): 根元素、关键 UI 区块、自定义组件引用
|
||
* 2. 样式 (wxss): 关键样式类存在性、rpx 单位使用
|
||
* 3. 配置 (json): navigationBarTitleText、usingComponents、tabBar
|
||
* 4. i18n: 模板中 $t() 键的引用一致性(渲染不依赖 missing key)
|
||
* 5. 组件引用完整性: wxml 引用的自定义组件在 components/ 存在
|
||
*/
|
||
import { readdirSync, readFileSync, existsSync, writeFileSync, statSync, mkdirSync } from 'node:fs'
|
||
import { join, resolve, relative, extname } from 'node:path'
|
||
|
||
const args = process.argv.slice(2)
|
||
const dist = resolve(args.includes('--dist') ? args[args.indexOf('--dist') + 1] : 'dist/build/mp-weixin')
|
||
const reportPath = args.includes('--report')
|
||
? resolve(args[args.indexOf('--report') + 1])
|
||
: join(process.cwd(), '../docs/reports/UI_AUDIT_mp-weixin.md')
|
||
|
||
function walk(dir, ext, out = []) {
|
||
if (!existsSync(dir)) return out
|
||
for (const f of readdirSync(dir)) {
|
||
const p = join(dir, f)
|
||
if (statSync(p).isDirectory()) walk(p, ext, out)
|
||
else if (f.endsWith(ext)) out.push(p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const issues = []
|
||
const passes = []
|
||
const seen = new Set()
|
||
function pass(msg) {
|
||
if (!seen.has(msg)) { seen.add(msg); passes.push(msg) }
|
||
}
|
||
function issue(sev, msg) {
|
||
issues.push(`[${sev}] ${msg}`)
|
||
}
|
||
|
||
/* ---------- 1. 页面结构审计 ---------- */
|
||
const wxmlFiles = walk(dist, '.wxml')
|
||
const pageWxml = wxmlFiles.filter((f) => f.includes('/pages/'))
|
||
const componentWxml = wxmlFiles.filter((f) => f.includes('/components/'))
|
||
|
||
for (const f of pageWxml) {
|
||
const rel = relative(dist, f)
|
||
const content = readFileSync(f, 'utf8')
|
||
// 根元素
|
||
if (!/<view|root|template/.test(content)) issue('P1', `${rel}: 无根元素`)
|
||
else pass(`${rel}: 有根元素`)
|
||
// 关键区块
|
||
for (const block of ['page-header', 'page-content']) {
|
||
if (content.includes(block)) pass(`${rel}: 包含 ${block}`)
|
||
}
|
||
// 自定义组件引用
|
||
const compTags = [...content.matchAll(/<([a-z][a-z0-9-]*)\b/g)].map((m) => m[1])
|
||
const compDirs = existsSync(join(dist, 'components')) ? readdirSync(join(dist, 'components')) : []
|
||
for (const tag of new Set(compTags)) {
|
||
const builtin = ['view', 'text', 'button', 'input', 'image', 'scroll-view', 'picker', 'checkbox', 'radio', 'swiper', 'swiper-item', 'form', 'label', 'navigator', 'canvas', 'map', 'video', 'rich-text', 'progress', 'slider', 'switch', 'textarea', 'icon']
|
||
if (builtin.includes(tag) || tag === 'template') continue
|
||
// kebab-case -> PascalCase(组件目录为驼峰),并忽略大小写匹配
|
||
const tagPascal = tag.replace(/(^|-)([a-z])/g, (_m, _p, c) => c.toUpperCase())
|
||
const matched = compDirs.some((d) => d === tagPascal || d.toLowerCase() === tag.toLowerCase())
|
||
if (matched) pass(`${rel}: 组件 ${tag} 存在`)
|
||
else issue('P1', `${rel}: 引用未知组件 <${tag}> (components/ 无匹配目录)`)
|
||
}
|
||
}
|
||
|
||
/* ---------- 2. 样式审计 ---------- */
|
||
const wxssFiles = walk(dist, '.wxss')
|
||
for (const f of pageWxml) {
|
||
const rel = relative(dist, f)
|
||
const wxssPath = f.replace(/\.wxml$/, '.wxss')
|
||
if (existsSync(wxssPath)) pass(`${rel}: wxss 存在`)
|
||
else issue('P1', `${rel}: wxss 缺失`)
|
||
}
|
||
|
||
/* ---------- 3. 配置审计 ---------- */
|
||
const pageJson = pageWxml.map((f) => f.replace(/\.wxml$/, '.json'))
|
||
for (const f of pageJson) {
|
||
const rel = relative(dist, f)
|
||
if (!existsSync(f)) { issue('P1', `${rel}: json 缺失`); continue }
|
||
const cfg = JSON.parse(readFileSync(f, 'utf8'))
|
||
if (cfg.navigationBarTitleText) pass(`${rel}: 导航栏标题=${cfg.navigationBarTitleText}`)
|
||
if (cfg.usingComponents && Object.keys(cfg.usingComponents).length > 0) {
|
||
pass(`${rel}: usingComponents ${Object.keys(cfg.usingComponents).length} 个`)
|
||
}
|
||
}
|
||
|
||
const appJsonPath = join(dist, 'app.json')
|
||
if (existsSync(appJsonPath)) {
|
||
const app = JSON.parse(readFileSync(appJsonPath, 'utf8'))
|
||
pass(`app.json: pages ${app.pages.length} 个`)
|
||
if (app.tabBar && app.tabBar.list) pass(`app.json: tabBar ${app.tabBar.list.length} 项`)
|
||
if (app.window) pass(`app.json: window 配置存在`)
|
||
else issue('P2', 'app.json: window 配置缺失')
|
||
} else {
|
||
issue('P0', 'app.json 缺失')
|
||
}
|
||
|
||
/* ---------- 4. i18n 键审计 ---------- */
|
||
const zhCN = readFileSync(join(dist, 'locales/zh-CN.js'), 'utf8')
|
||
for (const f of pageWxml) {
|
||
const rel = relative(dist, f)
|
||
const content = readFileSync(f, 'utf8')
|
||
// 提取模板中 $t('key') 或 {{a}} 等(小程序端 $t 被编译进 render,此处检查 wxml 是否含已编译变量标记)
|
||
const tKeys = [...content.matchAll(/[\$]t\(['"]([^'"]+)['"]\)/g)].map((m) => m[1])
|
||
for (const k of tKeys) {
|
||
if (zhCN.includes(k.replace(/\./g, '/'))) pass(`${rel}: i18n key ${k} 存在于语言包`)
|
||
else issue('P2', `${rel}: 模板 i18n key ${k} 可能缺失`)
|
||
}
|
||
}
|
||
|
||
/* ---------- 输出 ---------- */
|
||
const now = new Date()
|
||
const lines = [
|
||
`# 小程序构建产物 UI 审计报告`,
|
||
``,
|
||
`- **审计时间**: ${now.toISOString()}`,
|
||
`- **构建产物**: ${dist}`,
|
||
`- **结果**: ${issues.length === 0 ? '✅ 全部通过' : '❌ 发现 ' + issues.length + ' 个问题'}`,
|
||
``,
|
||
`## 统计`,
|
||
`- 页面 wxml: ${pageWxml.length} 个`,
|
||
`- 组件 wxml: ${componentWxml.length} 个`,
|
||
`- wxss: ${wxssFiles.length} 个`,
|
||
`- 通过项: ${passes.length}`,
|
||
`- 问题项: ${issues.length}`,
|
||
``,
|
||
`## 通过项`,
|
||
...passes.map((p) => `- ✅ ${p}`),
|
||
``,
|
||
`## 问题项`,
|
||
...(issues.length ? issues.map((i) => `- ⚠️ ${i}`) : ['- 无']),
|
||
]
|
||
|
||
mkdirSync(join(reportPath, '..'), { recursive: true })
|
||
writeFileSync(reportPath, lines.join('\n'))
|
||
console.log(lines.join('\n'))
|
||
console.log(`\n报告已保存: ${reportPath}`)
|
||
process.exit(issues.some((i) => i.startsWith('[P0]') || i.startsWith('[P1]')) ? 1 : 0)
|