#!/usr/bin/env node /** * 小程序构建产物 UI 层级审计工具 * 用法: node scripts/ui-audit-mp-weixin.mjs [--dist ] [--report ] * 默认 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 (!/ 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)