feat: 小程序端 UI 优化与纯客户端化改造
- 移除微信订阅推送功能(云函数/订阅管理页),回归纯客户端零后端架构 - 新增今日黄历默认展示卡片 TodayAlmanacCard(9 语言翻译) - i18n 修复:启用 globalInjection 修复 $t 未注入,插值消息全部改为 Messages Functions 适配小程序端(63 处) - 修复底部导航切换失效与运势页数据不刷新 - 统一设计令牌(深褐主色 + 印红强调色),组件 UI 规范重构 - 图标 iconfont 化(FontAwesome 子集化,修复 Android 豆腐块) - 新增 postcss-px2rpx 机型自适应(仅 mp-weixin 构建生效) - 新增 5 个用户旅程 E2E 测试(31 用例)与验收报告 - 补充微信小程序项目配置(appid)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
小程序 iconfont 生成工具(FontAwesome 子集化 + base64 输出)
|
||||
|
||||
用法: python3 scripts/iconfont-build.py <woff2路径> <输出css路径> [--names name1,name2,...]
|
||||
示例: python3 scripts/iconfont-build.py node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2 src/styles/iconfont.css --names calendar,star,heart
|
||||
|
||||
功能:
|
||||
1. 将 woff2 转为 ttf
|
||||
2. 按 --names 指定的图标名子集化(保留最小字形,减小包体)
|
||||
3. 生成 base64 内联 @font-face CSS(font-family: 'fa-icons')
|
||||
4. 输出 unicode 映射 JSON(供 Icon 组件使用)
|
||||
|
||||
图标名 -> unicode 映射(FontAwesome v6 fa-solid):
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools import subset
|
||||
|
||||
# FontAwesome v6 fa-solid 图标名 -> unicode 码点
|
||||
ICON_NAMES = {
|
||||
'arrow-up': 0xF062,
|
||||
'arrow-down': 0xF063,
|
||||
'arrow-left': 0xF060,
|
||||
'arrow-right': 0xF061,
|
||||
'check': 0xF00C,
|
||||
'close': 0xF00D,
|
||||
'right': 0xF054,
|
||||
'checkbox-checked': 0xF14A,
|
||||
'checkbox-unchecked': 0xF0C8,
|
||||
'plus': 0xF067,
|
||||
'minus': 0xF068,
|
||||
'search': 0xF002,
|
||||
'calendar': 0xF073,
|
||||
'star': 0xF005,
|
||||
'heart': 0xF004,
|
||||
'settings': 0xF013,
|
||||
'home': 0xF015,
|
||||
'user': 0xF007,
|
||||
'info': 0xF129,
|
||||
'warning': 0xF071,
|
||||
'refresh': 0xF021,
|
||||
'download': 0xF019,
|
||||
'upload': 0xF093,
|
||||
'share': 0xF064,
|
||||
'copy': 0xF0C5,
|
||||
'delete': 0xF1F8,
|
||||
'edit': 0xF044,
|
||||
'filter': 0xF0B0,
|
||||
'sort': 0xF0DC,
|
||||
'export': 0xF14D,
|
||||
'history': 0xF1DA,
|
||||
'bookmark': 0xF02E,
|
||||
'chevron-up': 0xF077,
|
||||
'chevron-down': 0xF078,
|
||||
'chevron-left': 0xF053,
|
||||
'chevron-right': 0xF054,
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='FontAwesome iconfont 子集化构建')
|
||||
parser.add_argument('woff2', help='fa-solid woff2 字体路径')
|
||||
parser.add_argument('output_css', help='输出 CSS 路径')
|
||||
parser.add_argument('--names', help='逗号分隔的图标名(默认全部)')
|
||||
parser.add_argument('--font-family', default='fa-icons', help='字体族名')
|
||||
args = parser.parse_args()
|
||||
|
||||
# 解析图标名
|
||||
if args.names:
|
||||
names = [n.strip() for n in args.names.split(',') if n.strip()]
|
||||
else:
|
||||
names = list(ICON_NAMES.keys())
|
||||
|
||||
# 收集 unicode
|
||||
unicodes = []
|
||||
mapping = {}
|
||||
missing = []
|
||||
for n in names:
|
||||
if n not in ICON_NAMES:
|
||||
print(f'⚠️ 未知图标名: {n}'); continue
|
||||
unicodes.append(ICON_NAMES[n])
|
||||
mapping[n] = ICON_NAMES[n]
|
||||
|
||||
# 加载并验证 cmap
|
||||
tmp_ttf = Path('/tmp/fa-icons-subset.ttf')
|
||||
font = TTFont(args.woff2)
|
||||
cmap = font.getBestCmap()
|
||||
for n, cp in mapping.items():
|
||||
if cp not in cmap:
|
||||
missing.append(f'{n}(U+{cp:04X})')
|
||||
if missing:
|
||||
print(f'⚠️ 缺失字形: {", ".join(missing)}')
|
||||
print(f'图标数: {len(mapping)}')
|
||||
|
||||
# 子集化
|
||||
options = subset.Options()
|
||||
options.desubroutinize = True
|
||||
options.ignore_missing_glyphs = True
|
||||
options.drop_tables += ['GSUB', 'GPOS', 'GDEF', 'kern', 'cmap'] if False else []
|
||||
sub = subset.Subsetter(options=options)
|
||||
sub.populate(unicodes=unicodes)
|
||||
sub.subset(font)
|
||||
font.save(str(tmp_ttf))
|
||||
|
||||
# base64
|
||||
b64 = base64.b64encode(tmp_ttf.read_bytes()).decode()
|
||||
print(f'子集 ttf: {(tmp_ttf.stat().st_size/1024):.0f}KB | base64: {(len(b64)/1024):.0f}KB')
|
||||
|
||||
# 生成 CSS
|
||||
css = f"""/* 自动生成: scripts/iconfont-build.py —— FontAwesome 子集化图标字体(仅含所需字形) */
|
||||
@font-face {{
|
||||
font-family: '{args.font_family}';
|
||||
src: url(data:font/ttf;charset=utf-8;base64,{b64}) format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}}
|
||||
"""
|
||||
out = Path(args.output_css)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(css)
|
||||
print(f'CSS 已生成: {out} ({len(css)/1024:.0f}KB)')
|
||||
|
||||
# 输出映射 JSON(供 Icon 组件)
|
||||
json_path = Path(args.output_css).with_suffix('.json')
|
||||
json_path.write_text(json.dumps(mapping, ensure_ascii=False, indent=2))
|
||||
print(f'映射已生成: {json_path}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/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)
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UI 视觉验收截图工具(H5 端渲染,uni-app 同源码同样式)
|
||||
* 用法: node scripts/ui-screenshots.mjs [--base <url>] [--out <dir>]
|
||||
* 默认 base: http://localhost:5175 out: ../docs/reports/screenshots
|
||||
*
|
||||
* 对每个核心页面截图并检查关键 UI 元素,输出 UI 验收结果(JSON + 摘要)。
|
||||
*/
|
||||
import { chromium } from 'playwright'
|
||||
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const base = args.includes('--base') ? args[args.indexOf('--base') + 1] : 'http://localhost:5175'
|
||||
const outDir = resolve(args.includes('--out') ? args[args.indexOf('--out') + 1] : '../docs/reports/screenshots')
|
||||
|
||||
// 页面定义: 路径 + 关键 UI 元素检查(body 关键词,语言兼容)+ class 检查
|
||||
const pages = [
|
||||
{
|
||||
name: '01-almanac-search',
|
||||
path: '/pages/almanac-search/index',
|
||||
keywords: [/今日黄历|Today/, /宜|Suitable/, /忌|Unsuitable/, /搜索天数|Search Days/, /搜索模板|Template/],
|
||||
classes: ['.today-almanac', '.template-panel', '.search-condition-panel', '.bottom-navigation'],
|
||||
},
|
||||
{
|
||||
name: '02-ziwei',
|
||||
path: '/pages/ziwei/index',
|
||||
keywords: [/紫微斗数|Zi Wei Dou Shu/, /出生信息|Birth Info/, /排盘|Chart/],
|
||||
classes: ['.bottom-navigation'],
|
||||
},
|
||||
{
|
||||
name: '03-fortune',
|
||||
path: '/pages/fortune/index',
|
||||
keywords: [/运势|Fortune/],
|
||||
classes: ['.bottom-navigation'],
|
||||
},
|
||||
]
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const report = []
|
||||
let passAll = true
|
||||
|
||||
for (const p of pages) {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 })
|
||||
const shotPath = join(outDir, `${p.name}.png`)
|
||||
const checks = []
|
||||
try {
|
||||
await page.goto(`${base}/#${p.path}`, { waitUntil: 'networkidle', timeout: 30000 })
|
||||
await page.waitForTimeout(1500)
|
||||
// 关键词检查(body 内容,语言兼容)
|
||||
const bodyText = (await page.textContent('body')) || ''
|
||||
for (const kw of p.keywords) {
|
||||
const pass = kw.test(bodyText)
|
||||
checks.push({ label: `关键词 ${kw}`, pass })
|
||||
if (!pass) passAll = false
|
||||
}
|
||||
// class 检查
|
||||
for (const sel of p.classes || []) {
|
||||
const cnt = await page.locator(sel).count()
|
||||
const pass = cnt > 0
|
||||
checks.push({ label: `class ${sel}`, pass })
|
||||
if (!pass) passAll = false
|
||||
}
|
||||
await page.screenshot({ path: shotPath, fullPage: false })
|
||||
report.push({ page: p.name, path: p.path, checks, screenshot: shotPath })
|
||||
console.log(`✅ ${p.name}: 截图成功,${checks.filter((c) => c.pass).length}/${checks.length} 元素通过`)
|
||||
} catch (e) {
|
||||
passAll = false
|
||||
report.push({ page: p.name, path: p.path, checks, error: String(e).slice(0, 120) })
|
||||
console.log(`❌ ${p.name}: 加载失败 ${String(e).slice(0, 100)}`)
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
|
||||
// 输出 JSON 报告
|
||||
const jsonPath = join(outDir, 'ui-verification.json')
|
||||
writeFileSync(jsonPath, JSON.stringify({ base, generatedAt: new Date().toISOString(), passAll, report }, null, 2))
|
||||
console.log(`\n${passAll ? '✅ 全部页面 UI 元素通过' : '❌ 存在未通过元素'}`)
|
||||
console.log(`截图目录: ${outDir}`)
|
||||
process.exit(passAll ? 0 : 1)
|
||||
Reference in New Issue
Block a user