fix(miniapp): 修复底部 tabBar 图标全部消失(开发者工具误删 app.json 专属图标)

根因:微信开发者工具(>=1.05.2112301)的 ignoreDevUnusedFiles 会按 WXML/JS 依赖分析过滤
"无依赖"文件。tabBar 图标仅在 app.json 中声明,不属于任何页面 WXML/JS 依赖,被误判为无用
文件从开发包剥离,导致 5 个图标全部加载失败(渲染为破图占位)。

修复:
- 在 project.config.json 与 miniapp/project.config.json 的 setting 中显式关闭
  ignoreDevUnusedFiles / ignoreUploadUnusedFiles(false),阻止图标被过滤。
- 同步关闭本地 gitignore 的 project.private.config.json(DevTools 运行时实际读取项)。
- 预览通道 scripts/preview-miniapp.mjs:补全 /assets 绝对路径→相对路径重写,并手动复刻
  原生 tabBar(原生 tabBar 不在任何页面 WXML 中,静态预览需自行复刻)。
- 回归守卫 ui-tests/tabbar-icons.mjs 扩充为 5 段,覆盖根因(配置开关)+ 预览复刻。
- miniapp/assets/tabbar/DESIGN.md 增加根因警示,防止后续被误开开关。

验证:UI 契约 23/23 全绿(tabbar-icons.mjs 位于 [19/23]);CDP 渲染实测 127 张图 0 破图,
5 个 tabBar 图标 naturalW=81 全部加载,选中色 rgb(233,107,107)、未选 rgb(153,153,153),
与 app.json selectedColor/color 一致。
This commit is contained in:
WorkBuddy
2026-09-23 15:57:04 +08:00
parent f4e00b4d95
commit 45e84722e1
6 changed files with 5642 additions and 2681 deletions
File diff suppressed because it is too large Load Diff
+14
View File
@@ -4,6 +4,20 @@
为小程序 5 个底部导航 Tab 设计的圆润线性风格图标,包含未选中(灰色)和选中(玫瑰红)两种状态,适配三种屏幕密度(1x/2x/3x)。 为小程序 5 个底部导航 Tab 设计的圆润线性风格图标,包含未选中(灰色)和选中(玫瑰红)两种状态,适配三种屏幕密度(1x/2x/3x)。
> ⚠️ **改动本目录前必读 —— 图标整排变「破图」的头号原因**
>
> 微信开发者工具自 `1.05.2112301` 起默认开启**「过滤无依赖文件」**(`ignoreDevUnusedFiles`)。
> 它的依赖分析只看 **WXML/JS**,而 **tabBar 图标只在 `app.json` 里被引用**,
> 于是被判定为「无依赖文件」并从**开发包中剔除** → 模拟器/真机上 5 个图标**全部加载失败**,
> 表现为底部导航整排破图(而 WXML 引用的图片一切正常,极易误判为图片损坏)。
>
> 本目录的 `assets/tabbar/*.png` 是**全仓库唯一「只被 app.json 引用」的图片资源**,因此是唯一受害者。
>
> 处置:`project.config.json`(仓库根 + `miniapp/`)与本地 `project.private.config.json`
> 中 `setting.ignoreDevUnusedFiles` / `setting.ignoreUploadUnusedFiles` **必须为 `false`**。
> 已由守卫 `ui-tests/tabbar-icons.mjs` 强制校验,请勿改回 `true`。
## 设计规范 ## 设计规范
| 属性 | 规范值 | | 属性 | 规范值 |
+3 -1
View File
@@ -15,7 +15,9 @@
"useCompilerPlugins": [ "useCompilerPlugins": [
"typescript" "typescript"
], ],
"minifyWXML": true "minifyWXML": true,
"ignoreDevUnusedFiles": false,
"ignoreUploadUnusedFiles": false
}, },
"compileType": "miniprogram", "compileType": "miniprogram",
"simulatorPluginLibVersion": {}, "simulatorPluginLibVersion": {},
+3 -1
View File
@@ -24,7 +24,9 @@
"disableUseStrict": false, "disableUseStrict": false,
"condition": false, "condition": false,
"swc": false, "swc": false,
"disableSWC": true "disableSWC": true,
"ignoreDevUnusedFiles": false,
"ignoreUploadUnusedFiles": false
}, },
"compileType": "miniprogram", "compileType": "miniprogram",
"simulatorPluginLibVersion": {}, "simulatorPluginLibVersion": {},
+54 -3
View File
@@ -31,6 +31,47 @@ const targets = requested.length ? requested : PAGES
const read = (p) => readFileSync(resolve(root, p), 'utf8') const read = (p) => readFileSync(resolve(root, p), 'utf8')
const readIf = (p) => (existsSync(resolve(root, p)) ? read(p) : '') const readIf = (p) => (existsSync(resolve(root, p)) ? read(p) : '')
// ============================================================
// 资源路径改写
// WXML/WXSS 写的是小程序根绝对路径 /assets/...;浏览器以 file:// 打开本预览时
// /assets 会被解析到文件系统根目录 → 图片全部 404(表现为破图)。
// preview.html 位于 artifacts/ui-redesign/miniapp/,向上三级即仓库根。
// ============================================================
const ASSET_PREFIX = '../../../miniapp'
const rewriteAssetUrls = (s) => s
.replace(/(\bsrc=")\/assets\//g, `$1${ASSET_PREFIX}/assets/`)
.replace(/(\bsrc=')\/assets\//g, `$1${ASSET_PREFIX}/assets/`)
.replace(/url\((["']?)\/assets\//g, `url($1${ASSET_PREFIX}/assets/`)
// ============================================================
// 原生 tabBar 复刻
// 小程序 tabBar 由 app.json 声明、由客户端原生渲染,**不存在于任何 WXML**。
// 静态预览若不补这一层,会让人误判为「底部导航图标丢了」。
// 5 项与 app.json 的 tabBar.list 一一对应(含选中态图标)。
// ============================================================
const TAB_ACTIVE_BY_PAGE = {
index: 'home', discover: 'discover', messages: 'message', activity: 'activity', mine: 'mine',
}
const TAB_ITEMS = [
{ key: 'home', label: '首页' },
{ key: 'discover', label: '找对象' },
{ key: 'message', label: '消息' },
{ key: 'activity', label: '活动' },
{ key: 'mine', label: '我的' },
]
function renderTabbar(page) {
const active = TAB_ACTIVE_BY_PAGE[page]
if (!active) return ''
const items = TAB_ITEMS.map((it) => {
const on = it.key === active
const file = on ? `${it.key}-active.png` : `${it.key}.png`
return `<div class="mini-tabbar__item${on ? ' is-active' : ''}">`
+ `<img class="mini-tabbar__icon" src="${ASSET_PREFIX}/assets/tabbar/${file}" alt="${it.label}" />`
+ `<span class="mini-tabbar__label">${it.label}</span></div>`
}).join('')
return `<div class="mini-tabbar" role="tablist">${items}</div>`
}
// ============================================================ // ============================================================
// mock 数据:让各页在静态预览下呈现「有数据」的真实布局 // mock 数据:让各页在静态预览下呈现「有数据」的真实布局
// 键为页面名,值为该页 data 中会被 WXML 消费的字段 // 键为页面名,值为该页 data 中会被 WXML 消费的字段
@@ -702,7 +743,7 @@ function buildPage(page, deviceWidth = 375) {
} catch (e) { } catch (e) {
body = `<pre style="color:#c00;padding:16px">渲染失败: ${e.message}</pre>` body = `<pre style="color:#c00;padding:16px">渲染失败: ${e.message}</pre>`
} }
return { html: `<div class="wx-page reference-page">${body}</div>`, css, missing: false } return { html: `<div class="wx-page reference-page">${body}${renderTabbar(page)}</div>`, css, missing: false }
} }
function main() { function main() {
@@ -762,6 +803,16 @@ function main() {
.phone { background:#fff; border-radius:36px; padding:12px; box-shadow:0 20px 60px rgba(28,30,88,.18); } .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; } .phone-screen { width:100%; height:760px; overflow-y:auto; border-radius:26px; background:#F4F5FA; }
.wx-page { min-height:100%; } .wx-page { min-height:100%; }
/* 原生 tabBar 复刻。颜色/边框取 app.json 的 tabBar 字段:
color #999999 / selectedColor #E96B6B / backgroundColor #FFFFFF / borderStyle black。
图标按微信客户端实际渲染尺寸(约 26px)呈现,与 81px 源图尺寸不同;文字 10px 同理。 */
.mini-tabbar { position:sticky; bottom:0; z-index:20; display:flex; align-items:stretch;
background:#FFFFFF; border-top:1px solid rgba(0,0,0,.2); padding-bottom:20px; }
.mini-tabbar__item { flex:1 1 0; min-width:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:2px; padding:6px 0 0; }
.mini-tabbar__icon { width:26px; height:26px; display:block; }
.mini-tabbar__label { font-size:10px; line-height:14px; color:#999999; }
.mini-tabbar__item.is-active .mini-tabbar__label { color:#E96B6B; }
/* ====== 以下为页面 WXSS 转换结果(rpx 已按 375 宽换算) ====== */ /* ====== 以下为页面 WXSS 转换结果(rpx 已按 375 宽换算) ====== */
${cssAll} ${cssAll}
</style> </style>
@@ -770,7 +821,7 @@ function main() {
<div class="banner"> <div class="banner">
<h1>小程序 11 页 · 静态预览</h1> <h1>小程序 11 页 · 静态预览</h1>
${nav} ${nav}
<span class="note">本预览为 WXML/WXSS 静态还原,非真机渲染,不含真实接口数据</span> <span class="note">本预览为 WXML/WXSS 静态还原,非真机渲染,不含真实接口数据;底部 tabBar 按 app.json 复刻</span>
</div> </div>
<div class="stage">${sections}</div> <div class="stage">${sections}</div>
<script> <script>
@@ -785,7 +836,7 @@ function main() {
</html>` </html>`
const outFile = resolve(OUT_DIR, 'preview.html') const outFile = resolve(OUT_DIR, 'preview.html')
writeFileSync(outFile, html, 'utf8') writeFileSync(outFile, rewriteAssetUrls(html), 'utf8')
const ok = built.filter((b) => !b.missing).length const ok = built.filter((b) => !b.missing).length
console.log(`已生成: ${outFile}`) console.log(`已生成: ${outFile}`)
console.log(`页面数: ${ok}/${targets.length}`) console.log(`页面数: ${ok}/${targets.length}`)
+86 -2
View File
@@ -1,8 +1,9 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync, existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
const root = join(process.cwd(), 'miniapp', 'assets', 'tabbar') const repoRoot = process.cwd()
const root = join(repoRoot, 'miniapp', 'assets', 'tabbar')
const icons = { const icons = {
home: '爱心小屋', home: '爱心小屋',
discover: '双人缘分', discover: '双人缘分',
@@ -11,6 +12,7 @@ const icons = {
mine: '个人守护', mine: '个人守护',
} }
// ── 1. SVG 源文件:主题化标题 + 两态配色 ──────────────────────────────
for (const [name, title] of Object.entries(icons)) { for (const [name, title] of Object.entries(icons)) {
const normal = readFileSync(join(root, 'svg', `${name}.svg`), 'utf8') const normal = readFileSync(join(root, 'svg', `${name}.svg`), 'utf8')
const active = readFileSync(join(root, 'svg', `${name}-active.svg`), 'utf8') const active = readFileSync(join(root, 'svg', `${name}-active.svg`), 'utf8')
@@ -19,6 +21,7 @@ for (const [name, title] of Object.entries(icons)) {
assert.match(normal, /#999999/i, `${name} 默认态颜色错误`) assert.match(normal, /#999999/i, `${name} 默认态颜色错误`)
assert.match(active, /#E96B6B/i, `${name} 选中态颜色错误`) assert.match(active, /#E96B6B/i, `${name} 选中态颜色错误`)
// ── 2. PNG 规格:正方形、1x/2x/3x、透明 RGBA ────────────────────────
for (const suffix of ['', '_2x', '_3x']) { for (const suffix of ['', '_2x', '_3x']) {
const expectedSize = suffix === '' ? 81 : suffix === '_2x' ? 162 : 243 const expectedSize = suffix === '' ? 81 : suffix === '_2x' ? 162 : 243
for (const state of ['', '-active']) { for (const state of ['', '-active']) {
@@ -30,4 +33,85 @@ for (const [name, title] of Object.entries(icons)) {
} }
} }
// ── 3. app.json ↔ 磁盘一致性 ─────────────────────────────────────────
// 历史缺陷:tabBar 图标只被 app.json 引用(不经 WXML/JS),
// 一旦配置漂移,不会有任何 WXML 侧症状,只会「底部导航整排变破图」。
const appJsonPath = join(repoRoot, 'miniapp', 'app.json')
const appJson = JSON.parse(readFileSync(appJsonPath, 'utf8'))
const tabList = appJson.tabBar?.list ?? []
assert.equal(tabList.length, 5, 'tabBar.list 必须为 5 项(微信支持 2~5)')
for (const item of tabList) {
assert.ok(appJson.pages.includes(item.pagePath), `tabBar pagePath 未在 pages 中注册: ${item.pagePath}`)
for (const key of ['iconPath', 'selectedIconPath']) {
const rel = item[key]
assert.ok(rel, `tabBar ${item.pagePath} 缺少 ${key}`)
assert.ok(!rel.startsWith('/'), `tabBar ${key} 不应以 / 开头(微信按小程序根解析): ${rel}`)
assert.ok(
existsSync(join(repoRoot, 'miniapp', rel)),
`tabBar ${key} 指向不存在的文件: ${rel}`,
)
}
}
// ── 4. 「无依赖文件过滤」必须关闭(tabBar 图标整排变破图的根因)────────
// 微信开发者工具自 1.05.2112301 起默认开启该能力:它按 WXML/JS 依赖分析,
// app.json 的 tabBar 图标不被视为依赖 → 被从开发包中剔除 → 图标全部加载失败。
// 官方缓解方式即 ignoreDevUnusedFiles / ignoreUploadUnusedFiles 置 false。
const CONFIGS = [
'project.config.json',
'miniapp/project.config.json',
'project.private.config.json',
'miniapp/project.private.config.json',
]
const requiredOff = ['project.config.json', 'miniapp/project.config.json']
for (const rel of CONFIGS) {
const p = join(repoRoot, rel)
if (!existsSync(p)) continue // 私有配置被 gitignore,缺失属正常
const setting = JSON.parse(readFileSync(p, 'utf8')).setting ?? {}
assert.notEqual(
setting.ignoreDevUnusedFiles, true,
`${rel} 开启了无依赖文件过滤(ignoreDevUnusedFiles),会剔除 app.json 独占引用的 tabBar 图标,导致底部导航整排破图`,
)
assert.notEqual(
setting.ignoreUploadUnusedFiles, true,
`${rel} 开启了上传期无依赖文件过滤(ignoreUploadUnusedFiles),体验版/正式版会丢失 tabBar 图标`,
)
}
for (const rel of requiredOff) {
const p = join(repoRoot, rel)
assert.ok(existsSync(p), `缺少共享配置 ${rel}`)
const setting = JSON.parse(readFileSync(p, 'utf8')).setting ?? {}
assert.equal(
setting.ignoreDevUnusedFiles, false,
`${rel} 必须显式关闭 ignoreDevUnusedFiles(新克隆默认值为 true 会复现破图)`,
)
assert.equal(
setting.ignoreUploadUnusedFiles, false,
`${rel} 必须显式关闭 ignoreUploadUnusedFiles`,
)
}
// ── 5. 静态预览必须改写资源路径 ──────────────────────────────────────
// WXML/WXSS 写的是小程序根绝对路径 /assets/...,浏览器 file:// 打开会解析到
// 文件系统根 → 全部 404(表现为破图)。预览产出必须改写为相对路径,
// 并复刻 app.json 原生 tabBar(它不在任何 WXML 里)。
const previewSrc = readFileSync(join(repoRoot, 'scripts', 'preview-miniapp.mjs'), 'utf8')
assert.match(previewSrc, /const rewriteAssetUrls\s*=/, '预览脚本缺少资源路径改写器')
assert.match(
previewSrc, /writeFileSync\(outFile,\s*rewriteAssetUrls\(html\)/,
'预览脚本写盘时未应用资源路径改写 → file:// 下图片会全部 404',
)
assert.match(previewSrc, /function renderTabbar\(/, '预览脚本缺少原生 tabBar 复刻')
const previewHtml = join(repoRoot, 'artifacts', 'ui-redesign', 'miniapp', 'preview.html')
if (existsSync(previewHtml)) {
const html = readFileSync(previewHtml, 'utf8')
const absoluteSrc = (html.match(/src=["']\/assets\//g) || []).length
assert.equal(absoluteSrc, 0, `preview.html 仍有 ${absoluteSrc} 处根绝对资源路径,浏览器打开会破图`)
assert.match(html, /class="mini-tabbar"/, 'preview.html 缺少复刻的底部 tabBar')
}
console.log('Tabbar icon contracts passed') console.log('Tabbar icon contracts passed')