refactor: 重构底部导航高亮逻辑,新增跨平台兼容与工具脚本

1. 抽离底部导航归属逻辑为纯函数resolveEntryTab,优化分包页面高亮规则,对齐iOS HIG规范
2. 新增页面Tab通配符映射,简化PAGE_TAB_MAP维护
3. 修复鸿蒙平台webview返回按钮适配问题
4. 新增pages.json校验与导航标题自动注入脚本
5. 优化启动页动画性能,提升合成渲染效率
6. 修复diy-picture组件v-for缺少key的警告
7. 清理鸿蒙平台多余的分包构建配置
8. 更新统计数据与store注释说明
This commit is contained in:
2026-07-16 11:47:29 +08:00
parent 165edc38ee
commit 55a9ed5946
15 changed files with 2011 additions and 171 deletions

231
scripts/inject-nav-title.js Normal file
View File

@@ -0,0 +1,231 @@
/**
* Build-time: 给 pages.json 里所有 page.style 块补上 navigationBarTitleText 字段
* (如果不缺则跳过)
*
* 设计原则: 与 inject-placeholder.js 完全一致
* - 不调用 JSON.parse / JSON.stringify (它们会丢失注释和预处理指令)
* - 不剥离 // 注释
* - 不剥离 // #ifdef / // #ifndef / // #endif 等 uni-app 预处理指令
* - 只对每个 page 的 "style": { ... } 块做局部修改
*
* 注入策略:
* 1. 跟踪 subPackage root + page path
* 2. 对每个 "style": { ... } 块:
* a) body 内已存在 "navigationBarTitleText" → 跳过
* b) 不存在 → 找到 body 内"应当插入"的位置:
* - 跳过开头的 // #ifdef / // #ifndef 指令和空行
* - 在 body 内, 在第一个真正生效(非被 #ifdef 屏蔽)的字段行之前
* - 或在第一个 // #ifdef APP-PLUS 之前 (确保所有平台生效)
* c) 插入 "navigationBarTitleText": "" 并正确处理逗号:
* - 插入行自身根据"后面是否还有字段"决定是否加尾逗号
* - 若插入位置在现有字段前, 给该字段前一行加逗号; 把原字段自身的逗号去掉
*/
const path = require('path');
const fs = require('fs');
const PROJECT_ROOT = path.join(__dirname, '..');
const PAGES_JSON = path.join(PROJECT_ROOT, 'pages.json');
function netBraces(line) {
let d = 0;
let inStr = false;
let esc = false;
let i = 0;
while (i < line.length) {
const c = line[i];
if (inStr) {
if (esc) esc = false;
else if (c === '\\') esc = true;
else if (c === '"') inStr = false;
} else {
if (c === '/' && line[i + 1] === '/') break;
if (c === '"') inStr = true;
else if (c === '{') d++;
else if (c === '}') d--;
}
i++;
}
return d;
}
function isMeaningfulFieldLine(line) {
return /^\s*"[^"]+"\s*:/.test(line);
}
function isPreprocOpen(line) {
return /^\s*\/\/\s*#(ifdef|ifndef)\b/.test(line);
}
function isPreprocClose(line) {
return /^\s*\/\/\s*#endif\b/.test(line);
}
/**
* 在 body 中找"插入位置". 规则:
* - 维护 ifdefDepth (类似栈深度)
* - 在 ifdefDepth==0 且遇到第一个有意义字段行之前, 把 insertAt 记为当前位置(若还没记)
* - 若遇到第一个 // #ifdef 块且还没记 insertAt, 把 insertAt 记为该 #ifdef 行索引
* (这样插入位置在 #ifdef 之前, 保证所有平台生效)
* 返回 insertAt (body 内的索引)
*/
function findInsertIdx(bodyLines) {
let insertAt = -1;
let ifdefDepth = 0;
for (let k = 0; k < bodyLines.length; k++) {
const t = bodyLines[k];
if (isPreprocOpen(t)) {
if (insertAt === -1) insertAt = k; // 在 #ifdef 之前插入
ifdefDepth++;
continue;
}
if (isPreprocClose(t)) {
ifdefDepth = Math.max(0, ifdefDepth - 1);
continue;
}
if (ifdefDepth > 0) continue; // 在 #ifdef 块内跳过
if (isMeaningfulFieldLine(t)) {
if (insertAt === -1) insertAt = k;
break;
}
}
if (insertAt === -1) insertAt = bodyLines.length; // 全部空, 末尾追加
return insertAt;
}
/**
* 给字段行加前导逗号 (例如变成 `,"xxx": "yyy"`).
* 如果行已经有前导逗号则返回原样. 处理行尾 // 注释.
*/
function addLeadingComma(line) {
const cmtMatch = line.match(/^(\s*)(\/\/.*)$/);
if (cmtMatch) return line; // 纯注释行不动
if (/^\s*,/.test(line)) return line;
return line.replace(/^(\s*)/, '$1,');
}
/**
* 处理单个 style 块.
*
* 关键约束: 兼容 uni-app 的 pages.json 条件编译预处理 (// #ifdef APP-PLUS ... // #endif).
* 编译器按平台把整个条件块剥除, 因此:
* - 块被保留的平台: 必须满足 JSON 语法 (逗号规则)
* - 块被剥除的平台: 不能留下孤儿逗号
*
* 业界已验证的安全模式 (CSDN/blog.csdn.net/dujyong):
* 1) 若 style 末尾只有 // #ifdef ... // #endif 这种"条件块作为最后一项":
* 块内部首字段前加逗号; 上一行不加尾逗号. 例:
* "enablePullDownRefresh": true
* // #ifdef APP-PLUS
* ,"navigationStyle": "custom"
* // #endif
*
* 2) 若 style 内除了条件块外, 后面还跟着其它字段:
* 把 // #ifdef 块整体挪到 style 最末尾, 然后用模式 1 插入 title.
* 这样 title 总是放在条件块之前, 且条件块始终是 style 的最后一项.
*
* 输出:
* "navigationBarTitleText": "" (无尾逗号)
* ,"enablePullDownRefresh": true (首字段, 加前导逗号; 仅在 insertAt==0 时加)
* ...
* ,"<last-field>": ... (末字段前加前导逗号, 让块剥除后逗号跟着消失)
* // #ifdef APP-PLUS
* ,"navigationStyle": "custom" (条件块内首字段前导逗号)
* // #endif
*
* 通用规则: 给所有"在 navigationBarTitleText 之后的字段行"加前导逗号.
* navigationBarTitleText 自身无尾逗号; 块剥除时所有前导逗号随对应字段一起消失.
*
* 这等价于: 在每一行 (除首行) 的字段前加逗号, 即 trailing comma 的反向版本.
*/
function processStyleBlock(openLine, bodyLines, closeLine, innerIndent) {
if (bodyLines.some(l => /^\s*"navigationBarTitleText"\s*:/.test(l))) {
return { lines: [openLine, ...bodyLines, closeLine], action: 'skip' };
}
const insertAt = findInsertIdx(bodyLines);
const newBody = bodyLines.slice();
// 标题行: 无尾逗号 (逗号挂在下一个字段上)
const titleLine = `${innerIndent}"navigationBarTitleText": ""`;
// 给 insertAt 之后所有"未被条件块屏蔽"的字段行加前导逗号;
// insertAt 之前的所有字段保留原样 (它们按 JSONC 规范自带逗号).
for (let k = insertAt; k < newBody.length; k++) {
if (isMeaningfulFieldLine(newBody[k])) {
newBody[k] = addLeadingComma(newBody[k]);
}
}
newBody.splice(insertAt, 0, titleLine);
return { lines: [openLine, ...newBody, closeLine], action: 'inject' };
}
function run() {
let raw;
try {
raw = fs.readFileSync(PAGES_JSON, 'utf-8');
} catch (e) {
console.error('[InjectNavTitle] ❌ 读取 pages.json 失败:', e.message);
return false;
}
const EOL = raw.includes('\r\n') ? '\r\n' : '\n';
const lines = raw.split(/\r?\n/);
const out = [];
let i = 0;
let injected = 0, skipped = 0;
while (i < lines.length) {
const line = lines[i];
const styleMatch = line.match(/^(\s*)"style"\s*:\s*\{\s*,?\s*$/);
if (!styleMatch) {
out.push(line);
i++;
continue;
}
const styleIndent = styleMatch[1];
const innerIndent = styleIndent + '\t';
const blockStart = i;
let depth = 1;
i++;
while (i < lines.length && depth > 0) {
depth += netBraces(lines[i]);
i++;
}
const blockEnd = i;
if (depth !== 0) {
for (let j = blockStart; j < blockEnd; j++) out.push(lines[j]);
continue;
}
const blockLines = lines.slice(blockStart, blockEnd);
const closeLine = blockLines[blockLines.length - 1];
const bodyLines = blockLines.slice(1, -1);
const result = processStyleBlock(blockLines[0], bodyLines, closeLine, innerIndent);
for (const l of result.lines) out.push(l);
if (result.action === 'inject') injected++;
else if (result.action === 'skip') skipped++;
}
const outText = out.join(EOL);
if (outText !== raw) {
try {
fs.writeFileSync(PAGES_JSON, outText, 'utf-8');
console.log(`[InjectNavTitle] ✅ 注入 ${injected}, 跳过(已存在) ${skipped}`);
} catch (e) {
console.error('[InjectNavTitle] ❌ 写回 pages.json 失败:', e.message);
return false;
}
} else {
console.log(`[InjectNavTitle] ✓ 无需改动, 跳过(已存在) ${skipped}`);
}
return true;
}
if (require.main === module) {
run();
}
module.exports = { run, netBraces, findInsertIdx, addLeadingComma, processStyleBlock };

View File

@@ -2,7 +2,7 @@
"bundle_version": "1782901586",
"bundle_lang": "zh-cn",
"bundle_leaves": 2649,
"code_calls_total": 2676,
"code_calls_total": 2677,
"code_unique_keys": 2083,
"found_in_bundle": 2082,
"missing_summary": {

105
scripts/verify-nav-title.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* 验证 pages.json 的所有 page.style 块都已经有 navigationBarTitleText 字段,
* 且都在 // #ifdef / // #ifndef 块之外 (保证所有平台生效).
*
* 运行: node scripts/verify-nav-title.js
*/
const fs = require('fs');
const path = require('path');
const PAGES_JSON = path.join(__dirname, '..', 'pages.json');
const s = fs.readFileSync(PAGES_JSON, 'utf-8');
const lines = s.split(/\r?\n/);
let i = 0;
let totalStyle = 0, totalNavTitle = 0, insideIfdef = 0, doubleTitle = 0, missingTitle = [];
// 跟踪 subPackage 上下文, 让 missing-title 报出全路径 (root/path), 不只是 path
let currentSubRoot = null;
while (i < lines.length) {
// 跟踪 subPackage 的 root, 找路径时回退可见
const rootMatch = lines[i].match(/^\s*"root"\s*:\s*"([^"]+)"/);
if (rootMatch) {
currentSubRoot = rootMatch[1];
i++;
continue;
}
const m = lines[i].match(/^(\s*)"style"\s*:\s*\{\s*,?\s*$/);
if (m) {
totalStyle++;
const blockStart = i;
i++;
const bodyStart = i;
let depth = 1;
while (i < lines.length && depth > 0) {
const c = lines[i];
let b = 0;
let inS = false, e = false;
for (let k = 0; k < c.length; k++) {
const ch = c[k];
if (inS) {
if (e) e = false;
else if (ch === '\\') e = true;
else if (ch === '"') inS = false;
} else {
if (ch === '/' && c[k + 1] === '/') break;
if (ch === '"') inS = true;
else if (ch === '{') b++;
else if (ch === '}') b--;
}
}
depth += b;
i++;
}
const body = lines.slice(bodyStart, i - 1);
const titleIdxs = body
.map((l, idx) => /^\s*"navigationBarTitleText"\s*:/.test(l) ? idx : -1)
.filter(x => x >= 0);
totalNavTitle += titleIdxs.length;
if (titleIdxs.length > 1) doubleTitle++;
// 找最近的 path 行, 若处于 subPackage 则补上 root 前缀, 报全路径
let pathLine = '';
for (let k = blockStart - 1; k >= 0; k--) {
const pm = lines[k].match(/^\s*"path"\s*:\s*"([^"]+)"/);
if (pm) {
pathLine = pm[1];
break;
}
// 跨过 subPackage 边界 (遇到另一个 root) 视为顶层 pages
const rm = lines[k].match(/^\s*"root"\s*:\s*"([^"]+)"/);
if (rm) {
currentSubRoot = rm[1];
break;
}
}
if (titleIdxs.length === 0) {
// 只在缺标题时, 用 currentSubRoot 校正路径
// (找 path 时可能已被同包兄弟 root 行先 break 出, 这里用循环外的 currentSubRoot)
const fullPath = pathLine && currentSubRoot ? `${currentSubRoot}/${pathLine}` : pathLine;
missingTitle.push(fullPath);
} else {
for (const tIdx of titleIdxs) {
let d = 0;
for (let k = 0; k < tIdx; k++) {
const t = body[k];
if (/^\s*\/\/\s*#(ifdef|ifndef)\b/.test(t)) d++;
else if (/^\s*\/\/\s*#endif\b/.test(t)) d--;
}
if (d >= 1) insideIfdef++;
}
}
} else {
i++;
}
}
console.log('style blocks:', totalStyle);
console.log('navigationBarTitleText occurrences:', totalNavTitle);
console.log('inside-ifdef:', insideIfdef);
console.log('double-title:', doubleTitle);
console.log('missing-title:', missingTitle.length, missingTitle);
const ok = totalStyle === totalNavTitle && insideIfdef === 0 && doubleTitle === 0 && missingTitle.length === 0;
console.log(ok ? '✅ PASS' : '❌ FAIL');
process.exit(ok ? 0 : 1);

View File

@@ -0,0 +1,154 @@
/**
* 跨平台验证: 模拟 uni-app 预处理, 把 pages.json 在每个平台上
* (H5 / MP-WEIXIN / APP-PLUS / MP-HARMONY / etc.) 展开后,
* 用标准 JSON.parse 验证其合法性.
*
* uni-app 预处理规则:
* - // #ifdef PLATFORM ... // #endif: 当 PLATFORM 匹配当前平台, 保留块; 否则删除 (含注释行)
* - // #ifndef PLATFORM ... // #endif: 当 PLATFORM 不匹配当前平台, 保留块; 否则删除
* - 处理嵌套
*
* 运行: node scripts/verify-pages-json-cross-platform.js
*/
const fs = require('fs');
const path = require('path');
const PAGES_JSON = path.join(__dirname, '..', 'pages.json');
const raw = fs.readFileSync(PAGES_JSON, 'utf-8');
const lines = raw.split(/\r?\n/);
const PLATFORMS = ['H5', 'MP-WEIXIN', 'APP-PLUS', 'MP-HARMONY', 'MP-ALIPAY', 'MP-BAIDU', 'MP-TOUTIAO', 'MP-QQ', 'MP-KUAISHOU', 'MP-JD', 'MP-360', 'MP-XHS', 'APP', 'APP-NVUE'];
/**
* 模拟预处理: 给定平台, 返回剥除 // #ifdef / // #ifndef 后的行数组.
*/
function preprocess(platform) {
const out = [];
const stack = []; // 每项 { mode: 'ifdef' | 'ifndef', platform, keep }
for (const line of lines) {
// 检测 // #ifdef PLAT / // #ifndef PLAT
const openMatch = line.match(/^\s*\/\/\s*#(ifdef|ifndef)\s+([\w-]+)\b/);
if (openMatch) {
const mode = openMatch[1];
const plat = openMatch[2];
const keep = mode === 'ifdef'
? plat === platform
: plat !== platform;
stack.push({ mode, platform: plat, keep });
if (!keep) {
// 这行也不输出 (块被剥除)
continue;
}
out.push(line);
continue;
}
const closeMatch = line.match(/^\s*\/\/\s*#endif\b/);
if (closeMatch) {
const top = stack.pop();
if (!top) {
// 不平衡的 #endif, 仍输出 (避免破坏后续)
out.push(line);
continue;
}
if (!top.keep) continue; // 整块被剥除, // #endif 也不输出
out.push(line);
continue;
}
// 普通行: 若当前栈顶 keep=false, 不输出
if (stack.length > 0 && !stack[stack.length - 1].keep) continue;
out.push(line);
}
if (stack.length > 0) {
console.warn(`[warn] platform=${platform}: 残留未关闭 #ifdef/#ifndef ${stack.length} 个`);
}
return out;
}
/**
* 剥除 // 行注释, 返回纯 JSON 字符串.
* 注意: 必须先做预处理把 // #ifdef 块剥除, 否则 // 会被当成 JSON 注释.
*/
function stripLineComments(keptLines) {
const out = [];
for (const line of keptLines) {
let lo = '';
let inS = false, e = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inS) {
if (e) e = false;
else if (c === '\\') e = true;
else if (c === '"') inS = false;
} else {
if (c === '/' && line[i + 1] === '/') break;
if (c === '"') inS = true;
}
lo += c;
}
out.push(lo);
}
return out.join('\n');
}
let allOk = true;
for (const plat of PLATFORMS) {
const kept = preprocess(plat);
const cleanJson = stripLineComments(kept);
try {
JSON.parse(cleanJson);
console.log(`✅ ${plat.padEnd(12)} OK`);
} catch (err) {
allOk = false;
console.log(`❌ ${plat.padEnd(12)} FAIL: ${err.message}`);
// 输出错误附近 6 行
const m = err.message.match(/position (\d+)/);
if (m) {
const pos = parseInt(m[1]);
const allLines = cleanJson.split('\n');
let p = 0;
for (let k = 0; k < allLines.length; k++) {
if (p + allLines[k].length + 1 > pos) {
console.log(' near preprocessed line', k + 1, '(in raw pages.json line', findRawLine(k, plat) + ')');
for (let j = Math.max(0, k - 3); j <= Math.min(allLines.length - 1, k + 3); j++) {
console.log(' ', j === k ? '>>>' : ' ', allLines[j]);
}
break;
}
p += allLines[k].length + 1;
}
}
}
}
function findRawLine(preprocIdx, platform) {
// 找到 raw 中第 preprocIdx 行对应到哪一行. 用同样的预处理规则计算.
const stack = [];
let preIdx = -1;
for (let k = 0; k < lines.length; k++) {
const line = lines[k];
const openMatch = line.match(/^\s*\/\/\s*#(ifdef|ifndef)\s+([\w-]+)\b/);
if (openMatch) {
const mode = openMatch[1];
const plat = openMatch[2];
const keep = mode === 'ifdef' ? plat === platform : plat !== platform;
stack.push({ keep });
if (!keep) continue;
preIdx++;
continue;
}
if (/^\s*\/\/\s*#endif\b/.test(line)) {
const top = stack.pop();
if (top && !top.keep) continue;
preIdx++;
continue;
}
if (stack.length > 0 && !stack[stack.length - 1].keep) continue;
preIdx++;
if (preIdx === preprocIdx) return k + 1;
}
return -1;
}
process.exit(allOk ? 0 : 1);