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

View File

@@ -287,9 +287,126 @@ function _getPackageName(normalizedPath) {
return parts[1] || '';
}
/**
* 解析"当前分包的入口 tab URL"
*
* 把 diy-bottom-nav.vue#updateCurrentRoute 里的查找逻辑抽成纯函数,
* 方便单元测试覆盖所有跳转场景, 同时让组件代码变薄.
*
* 查找优先级 (2026-07-16 重构, 对齐 iOS HIG):
* 1) 当前页面精确命中某个 tab 根页面 → 它就是入口 (tab→tab 跳转)
* 2) 同包内导航 (上一包 == 当前包) → 用当前包的第一个 tab
* 例: /pages_goods/list → /pages_goods/detail → CATEGORY 高亮
* 3) 跨包导航 (上一包 != 当前包) → 历史栈倒序查找最近一次访问的 TabBar 页
* 例: home → diy → HOME 高亮 (从哪个 tab 出发, 非 tab 页就归属哪个 tab)
* 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
* 5) 深链无历史 → 返回 null (iOS HIG: 无来源时不点亮)
*
* 与 checkTabBarActive 的关系:
* - resolveEntryTab 写入 packageEntryTab[currentPackage]
* - checkTabBarActive 通过 _findMatchingTabUrl 读取
* - 两者都不会做"无历史时的同分包兜底", 保证 iOS HIG 行为
*
* @param {Object} opts
* @param {string} opts.currentPageRoute 当前页面路由 (已 _normalizeRoute, 不含 query)
* @param {Object} opts.tabBarList tabBar 配置对象 ($store.state.tabBarList)
* @param {string[]} [opts.historyRoutes=[]] 历史栈路由列表 (不含 query, 不含当前页;
* 已 _normalizeRoute 但未 adaptSubpackageUrl, 函数内部会适配)
* @param {Set<string>} [opts.noTabPages] NO_TAB_PAGES 集合 (含 login/pay/AI 等全屏流程页)
* 这些页视为"modal", 不参与包归属判定
* @returns {string|null} 入口 tab URL (已适配子包路径、不含 query) 或 null
*/
export function resolveEntryTab(opts = {}) {
const {
currentPageRoute = '',
tabBarList = null,
historyRoutes = [],
noTabPages = null,
} = opts;
if (!currentPageRoute || !tabBarList || !Array.isArray(tabBarList.list)) {
return null;
}
const tabUrls = tabBarList.list
.filter((item) => item && item.link && item.link.wap_url)
.map((item) => adaptSubpackageUrl(item.link.wap_url).split('?')[0]);
if (tabUrls.length === 0) return null;
const normalizedCurrent = currentPageRoute.split('?')[0];
const currentPackageName = _getPackageName(normalizedCurrent);
// 1) 当前页面精确命中某个 tab → 它就是入口
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 工具: 判定一个路由是否"参与归属判定"
// 跳过 null/空 + NO_TAB_PAGES (login/pay/AI 等 modal 页, 不算中间步骤)
const isSkippable = (route) => {
if (!route) return true;
if (noTabPages && typeof noTabPages.has === 'function') {
const normalized = adaptSubpackageUrl(route).split('?')[0];
if (noTabPages.has(normalized)) return true;
}
return false;
};
// 找出"最近的上一页"属于哪个包 (跳过 null/空 + NO_TAB_PAGES)
let lastPackageName = '';
let hasPrevPage = false;
if (Array.isArray(historyRoutes) && historyRoutes.length > 0) {
for (let i = historyRoutes.length - 1; i >= 0; i--) {
const route = historyRoutes[i];
if (isSkippable(route)) continue;
lastPackageName = _getPackageName(adaptSubpackageUrl(route).split('?')[0]);
hasPrevPage = true;
break;
}
}
const isSamePackage = hasPrevPage && lastPackageName === currentPackageName;
const isCrossPackage = hasPrevPage && lastPackageName !== currentPackageName;
// 2) 同包内导航: 用当前包的第一个 tab
// 业务约定: 包内"主入口" = tabBarList 中该包出现的第一个 tab
// 例: pages_goods 有 [CATEGORY, CART] 两个 tab, 用户在 goods 频道内任何页面都归 CATEGORY
// 理由: CATEGORY 是商品频道的入口, 比 CART (特定子场景) 更"通用", 视觉一致
// 注意: 业务侧 tab 配置顺序很关键, 若有 pages_goods 出现 3+ tab, 主入口必须是业务上正确的那个
if (isSamePackage) {
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
if (samePackage) return samePackage;
}
// 3) 跨包导航: 历史栈倒序查找最近一次访问的 TabBar 页 → 来源 tab
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
// 注意: 跳过 NO_TAB_PAGES, 因为 login/pay 等是 modal, 不是真实的导航步骤
if (isCrossPackage) {
for (let i = historyRoutes.length - 1; i >= 0; i--) {
const route = historyRoutes[i];
if (isSkippable(route)) continue;
const normalized = adaptSubpackageUrl(route).split('?')[0];
const exactTab = tabUrls.find((url) => url === normalized);
if (exactTab) return exactTab;
}
// 4) 跨包但历史栈中无 tab → 退到当前包第一个 tab
const samePackage = tabUrls.find((url) => _getPackageName(url) === currentPackageName);
if (samePackage) return samePackage;
}
// 5) 深链无历史 / 跳过 NO_TAB_PAGES 后没找到 → 返回 null
return null;
}
/**
* 给定当前页面路径 + tab 配置, 返回当前页面所属的 tab URL
* 优先级: 精确匹配 > packageEntryTab > 分包名回退匹配
* 优先级: 精确匹配 > packageEntryTab
*
* 注意 (2026-07-16): 旧版本在 packageEntryTab 没命中时会回退到"同分包第一个 tab",
* 这导致深链冷启动时 /pages_tool/* 页被错误标成"我的"。
* 现在改为"找不到就返回 null", 后续 checkTabBarActive 也不会再兜底,
* 行为对齐 iOS HIG (深链无历史时, TabBar 不点亮)。
*
* (私有)
*/
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
@@ -313,8 +430,63 @@ function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
}
// 3) 回退: 同分包内的第一个 tab(基于分包名匹配)
return tabUrls.find((url) => _getPackageName(url) === packageName) || null;
// 3) 不再做"同分包兜底" — 找不到就返回 null
// 深链冷启动场景下无历史可参考, iOS HIG 推荐 TabBar 不点亮
return null;
}
/**
* 通配符路径匹配 — 支持 '/pages_tool/member/*' 形式
*
* 规则:
* - pattern 必须以 '/*' 结尾
* - '*' 匹配"前缀之后的至少一个字符段"
* - 例: '/pages_tool/member/*' 匹配 '/pages_tool/member/info' 但不匹配 '/pages_tool/member'
*
* @param {string} path 当前路径 (已 _normalizePath)
* @param {string} pattern 通配符 pattern
* @returns {boolean}
*/
function _matchWildcardPath(path, pattern) {
if (!path || !pattern) return false;
if (!pattern.endsWith('/*')) return false;
const prefix = pattern.slice(0, -2); // 去掉 '/*'
// 必须有至少一个字符在 '/*' 之后, 即 path 比 prefix 长, 且以 prefix + '/' 开头
return path.length > prefix.length + 1 && path.startsWith(prefix + '/');
}
/**
* 在 PAGE_TAB_MAP (精确) + PAGE_TAB_WILDCARDS (通配) 中查找当前页面对应的 tab
*
* 优先级: 精确匹配 > 通配符匹配
* 返回值可以是:
* - tab URL 字符串 → 命中, 高亮该 tab
* - null → 显式声明"无 tab", 一定不高亮
* - undefined → 未声明, 调用方走兜底逻辑 (history-walk / 无 tab)
*
* @param {string} currentPath 当前页面路径 (已 _normalizePath)
* @param {Object} pageTabMap 精确绑定表 (PAGE_TAB_MAP)
* @param {Array<{pattern: string, tab: string}>} pageTabWildcards 通配符列表 (PAGE_TAB_WILDCARDS)
* @returns {string|null|undefined}
*/
export function lookupPageTabMap(currentPath, pageTabMap, pageTabWildcards) {
if (!currentPath) return undefined;
// 1) 精确匹配
if (pageTabMap && Object.prototype.hasOwnProperty.call(pageTabMap, currentPath)) {
return pageTabMap[currentPath];
}
// 2) 通配符匹配
if (Array.isArray(pageTabWildcards)) {
for (const entry of pageTabWildcards) {
if (entry && entry.pattern && _matchWildcardPath(currentPath, entry.pattern)) {
return entry.tab;
}
}
}
return undefined;
}
/**
@@ -380,6 +552,7 @@ export function isCurrentPageTabBarRoot(opts = {}) {
* @param {Object} [opts={}]
* @param {Set} [opts.noTabPages] 独立流程页集合(NO_TAB_PAGES)
* @param {Object} [opts.pageTabMap] 页面级 Tab 精确绑定表(PAGE_TAB_MAP)
* @param {Array<{pattern: string, tab: string}>} [opts.pageTabWildcards=[]] 页面级 Tab 通配符表(PAGE_TAB_WILDCARDS)
* @returns {boolean} 是否激活
*/
export function checkTabBarActive(
@@ -390,19 +563,37 @@ export function checkTabBarActive(
currentPageQuery = {},
opts = {},
) {
const { noTabPages = null, pageTabMap = {} } = opts;
const { noTabPages = null, pageTabMap = {}, pageTabWildcards = [] } = opts;
// __TABBAR_DEBUG__: 开启后打印每一步决策, 用于排查"为什么我的 tab 不亮"
// 开启方式 (任选一种):
// - 全局: window.__TABBAR_DEBUG__ = true
// - 本次: opts.debug = true
const DEBUG = (typeof globalThis !== 'undefined' && globalThis.__TABBAR_DEBUG__)
|| opts.debug === true;
const _log = DEBUG ? (...a) => console.log('[TABBAR]', ...a) : () => {};
_log('checkTabBarActive called', { linkUrl, currentPageRoute, hasTabBarList: !!tabBarList });
// 1) 归一化 linkUrl: 统一前导 '/' + 子包路径适配 + 去除 query
const normalizedLink = _normalizePath(linkUrl);
const normalizedCurrent = _normalizePath(currentPageRoute);
if (!normalizedLink) return false;
if (!normalizedLink) {
_log('step 1: no linkUrl → false');
return false;
}
// 2) 精确匹配: 当前页面就是 linkUrl 指向的页面
if (normalizedLink === normalizedCurrent) return true;
if (normalizedLink === normalizedCurrent) {
_log('step 2: exact match → true', { link: normalizedLink });
return true;
}
// 3) 独立流程页: 不点亮任何 Tab
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) return false;
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) {
_log('step 3: NO_TAB_PAGES hit → false', { current: normalizedCurrent });
return false;
}
// 4) 若传入 tabBarList, 使用动态判断逻辑
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
@@ -411,32 +602,47 @@ export function checkTabBarActive(
.map((item) => _normalizePath(item.link.wap_url));
// 检查当前 linkUrl 是否是合法的 tab URL
if (!tabUrls.includes(normalizedLink)) return false;
// 5) 页面级 Tab 精确绑定
const mappedTabUrl = pageTabMap[normalizedCurrent];
if (mappedTabUrl) {
return normalizedLink === _normalizePath(mappedTabUrl);
if (!tabUrls.includes(normalizedLink)) {
_log('step 4: link not in tabUrls → false', { link: normalizedLink });
return false;
}
if (mappedTabUrl === null) return false;
// 5) 页面级 Tab 绑定 (精确 + 通配符)
// 返回值可能是: tab URL | null (显式无 tab) | undefined (未声明)
const mappedTabUrl = lookupPageTabMap(normalizedCurrent, pageTabMap, pageTabWildcards);
if (mappedTabUrl !== undefined) {
_log('step 5: pageTabMap hit', { current: normalizedCurrent, mapped: mappedTabUrl });
// null = 显式声明"无 tab", 字符串 = 该 tab 高亮
return mappedTabUrl !== null && normalizedLink === _normalizePath(mappedTabUrl);
}
_log('step 5: pageTabMap miss → 进入 step 6/7');
// 6) 来源记忆兜底: 从哪个 Tab 跳转过来, 就保持那个 Tab 高亮
const fromTab = currentPageQuery.from_tab;
if (fromTab) {
const normalizedFromTab = _normalizePath(fromTab);
if (tabUrls.includes(normalizedFromTab)) {
return normalizedLink === normalizedFromTab;
const hit = normalizedLink === normalizedFromTab;
_log('step 6: from_tab query', { fromTab, hit });
return hit;
}
}
_log('step 6: no from_tab');
// 7) 分包入口追踪: 通过 updateCurrentRoute 写入的入口 tab
// 7) 分包入口追踪: 通过 updateCurrentRoute/resolveEntryTab 写入的入口 tab
// 注意: _findMatchingTabUrl 不会做"同分包兜底",找不到就返回 null
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
if (matchingTabUrl) return normalizedLink === matchingTabUrl;
if (matchingTabUrl) {
const hit = normalizedLink === matchingTabUrl;
_log('step 7: packageEntryTab', { matchingTabUrl, hit });
return hit;
}
_log('step 7: packageEntryTab miss → step 8');
// 8) 同分包回退: linkUrl 和当前页面在同一分包 → 高亮
const linkPackage = _getPackageName(normalizedLink);
const currentPackage = _getPackageName(normalizedCurrent);
return linkPackage && currentPackage && linkPackage === currentPackage;
// 8) 不再做"同分包兜底" — 找不到任何来源 tab 时不高亮
// 深链冷启动 / 扫码进非 tab 页: TabBar 全不亮, 对齐 iOS HIG
_log('step 8: no source → false (iOS HIG)');
return false;
}
return false;