Files
danyang-match/scripts/audit-image-height.mjs
T
WorkBuddy 1fc34f1bcd fix(miniapp): 修复导航页与微信胶囊重合,并把首页两个栏目卡缩到 1/3
问题 1:五个导航页顶部与右上角胶囊按钮(··· ⊙)重合
- 根因:自定义导航用 env(safe-area-inset-top) 取的是设备物理安全区,
  在 Android 上常为 0,完全不反映胶囊位置;且右对齐元素从未为胶囊让出宽度。
- 新增 miniapp/utils/nav-metrics.js 作为唯一事实源:以
  wx.getMenuButtonBoundingClientRect() 实测胶囊矩形,反推并导出四个 CSS 变量
  --nav-status / --nav-h / --nav-right / --nav-top,并提供无接口时的兜底值。
- nav-bar 组件改为自行获取导航度量(attached + observer 双触发),
  标题按胶囊垂直居中并收窄 max-width;外部传参降级为兜底。
- index / discover / messages / activity / mine 五页根结点注入同一组变量,
  WXSS 里的 env(safe-area-inset-top)、硬编码 88rpx 与右对齐 right:0 全量替换。

问题 2:首页「善缘公益服务」「善缘安全提醒」两个栏目过宽
- 根因:封面 <image> 只声明 width 未声明 height,小程序默认按 240px 计算,
  实际把卡片撑到约 480rpx 高。
- 按「整体等比缩到 1/3」重构:两栏改为 mini-card 小卡,宽度
  calc((100% - 2*gap)/3)、高度写死 168rpx,封面 height:100%;
  公益服务一行三卡横向排布,安全提醒为「标题小卡 + 右侧文案条」,
  长文案收进详情页,安全提示原文保留。

守卫与验证
- 新增 ui-tests/miniapp-capsule-safe-contracts.mjs:
  A 层多机型断言导航行与胶囊垂直居中、右对齐让位、navTop 与兜底值;
  B 层纯函数审计五个自带导航行页面(页面清单由文件系统推导,失配直接报红);
  C 层断言首页小卡宽为 1/3 且显式声明高度,并钉住旧 service-/safety- 选择器回归。
  审计前剥离注释,避免守卫被自身示例文本误伤。
- 新增 scripts/audit-image-height.mjs:按属性名精确比对,审计全仓
  「有 width 无 height」的 <image>,当前 30 个类 0 命中。
- 负向注入 N1~N7(含 nav-safe 未接入、旧选择器回归、极小屏幕、无胶囊兜底等)
  全部被守卫咬住并 exit 1,还原后复跑通过。
- 真编译(wcc/wcsc):WXSS 88 / WXML 86,失败 0;
  tsc --noEmit 通过;UI 契约门控 21/21 PASS(新增第 21 个守卫并抬高 MIN_GUARDS)。
- 渲染探针实测:单卡 213rpx x 168rpx;五页右对齐元素右缘 423~528px,
  均在胶囊右缘 365px 之外。

未纳入本次提交:miniapp/project.config.json(微信开发者工具自动改写,
仅追加若干布尔开关并删掉文件末换行)与生成的预览页 artifacts preview.html
(约 7900 行生成产物),以免淹没代码评审视线。
2026-09-23 09:55:40 +08:00

115 lines
4.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 审计:找出「挂在 <image> 上、只写 width 不写 height」的样式类。
*
* 为什么值得单独审计(2026-09-23 真实缺陷):
* 小程序 <image> 在未指定高度时取**默认 240px(=480rpx)**。首页「善缘公益服务」
* 「善缘安全提醒」两张卡只给封面写了 width,卡片因此被撑到约 480rpx 高 —— 用户反馈的
* 「这两栏占用过大」即此。此类写法没有任何编译/类型报错,只能靠约定的静态审计抓。
*
* 判据(两道过滤,避免误报):
* 1) 规则体里有 width 但没有 height(min-height 不算 —— 它是下限,挡不住 240px 默认值);
* 2) 选择器里的类名**确实出现在某个 `<image class="...">` 上**(只在 .wxss 里找会把
* 容器类、`<text>` 图标类一起算进来,实测误报率极高)。
*
* 用法:node scripts/audit-image-height.mjs
* 退出码:0 = 无命中;1 = 命中(可接 CI,但目前故意只作为审计工具,不挂进门禁 ——
* 门禁侧只锁「首页两张卡」这一处已确认的缺陷,见 ui-tests/miniapp-capsule-safe-contracts.mjs)
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
function walk(dir, extensions, out = []) {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules') continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, extensions, out)
else if (extensions.some((ext) => entry.endsWith(ext))) out.push(full)
}
return out
}
/** 收集所有出现在 <image> 上的静态类名 */
function collectImageClasses() {
const classes = new Set()
for (const file of walk(resolve(root, 'miniapp'), ['.wxml'])) {
const src = readFileSync(file, 'utf8')
for (const tag of src.matchAll(/<image\b[^>]*>/g)) {
const classAttr = tag[0].match(/\sclass="([^"]*)"/)
if (!classAttr) continue
// 动态类名(含 {{}})无法静态判断,跳过
if (classAttr[1].includes('{{')) continue
for (const name of classAttr[1].split(/\s+/).filter(Boolean)) classes.add(name)
}
}
return classes
}
const imageClasses = collectImageClasses()
const ROLE = /([^{}]+)\{([^{}]*)\}/g
/** 按声明拆分并精确比对属性名 —— 不能用 /\bheight:/:`min-height` 里的 "height" 前是连字符,
* 同样满足 \b,会把「只写 min-height」的缺陷写成合法(首版就踩了这个坑,自检立刻报出来)。 */
function declaresProp(body, prop) {
return body
.split(';')
.map((d) => d.split(':')[0].trim())
.some((name) => name === prop)
}
/** 纯函数:在给定 CSS 文本里找出「挂在 <image> 上、有 width 无 height」的规则 */
function analyze(css, imageClassSet) {
const hits = []
for (const m of css.matchAll(ROLE)) {
const selector = m[1].split('\n').pop().trim().replace(/\s+/g, ' ')
const body = m[2]
const classes = [...selector.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((x) => x[1])
if (!classes.some((name) => imageClassSet.has(name))) continue
if (!declaresProp(body, 'width')) continue
if (declaresProp(body, 'height')) continue
hits.push(`${selector} {${body.trim().replace(/\s+/g, ' ')}}`)
}
return hits
}
// ---- 自检:证明这条审计真的有判别力(含修复前真实写法)----
const SELF_CHECK = [
{
name: '正向:修复前的真实写法(只写 width + min-height,挡不住 image 默认 240px)',
css: '.service-cover { width: 210rpx; min-height: 160rpx; flex: none; }',
classes: new Set(['service-cover']),
expect: 1,
},
{
name: '反向:写了显式 height 就不算问题',
css: '.mini-card-cover { width: 100%; height: 100%; display: block; }',
classes: new Set(['mini-card-cover']),
expect: 0,
},
{
name: '反向:类名没出现在 <image> 上(容器类 / <text> 图标类)不算',
css: '.list-icon { width: 48rpx; text-align: center; }',
classes: new Set(['service-cover']),
expect: 0,
},
]
for (const c of SELF_CHECK) {
const got = analyze(c.css, c.classes).length
if (got !== c.expect) {
console.error(`自检失败 —— ${c.name}\n期望 ${c.expect} 条,实得 ${got} 条`)
process.exit(1)
}
}
const hits = []
for (const file of walk(resolve(root, 'miniapp'), ['.wxss'])) {
const found = analyze(readFileSync(file, 'utf8'), imageClasses)
for (const hit of found) hits.push(`${relative(root, file).replace(/\\/g, '/')} ${hit}`)
}
console.log(`<image> 类共 ${imageClasses.size} 个;命中「有 width 无 height」${hits.length} 条`)
for (const hit of hits) console.log(' -', hit)
if (hits.length) process.exitCode = 1