diff --git a/common/js/util-tabbar.js b/common/js/util-tabbar.js index 97b347f..3dd2087 100644 --- a/common/js/util-tabbar.js +++ b/common/js/util-tabbar.js @@ -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} [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; diff --git a/common/js/util.js b/common/js/util.js index 05caeb7..116c47e 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -225,80 +225,57 @@ export const NO_TAB_PAGES = new Set([ ]); // 页面级 TabBar 精确归属映射(key: 页面路径, value: TabBar 根路径) -// 未在此映射中的页面,优先走 NO_TAB_PAGES 判断,其次走同分包回退逻辑 +// value 为 null 表示"该页明确不点亮任何 Tab" (e.g. 全屏流程页) +// 未在此映射中的页面, 走 NO_TAB_PAGES 判断 + 下方 PAGE_TAB_WILDCARDS 通配符 + history-walk 同/跨包策略 export const PAGE_TAB_MAP = { // 首页 '/pages/index/index': TAB_BAR_KEYS.HOME, '/pages/empty/index': TAB_BAR_KEYS.HOME, - // 分类 + // 分类 — tab 根页面 (子页面用通配符, 见 PAGE_TAB_WILDCARDS) '/pages_goods/category': TAB_BAR_KEYS.CATEGORY, '/pages_goods/list': TAB_BAR_KEYS.CATEGORY, '/pages_goods/detail': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/search': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/brand': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/coupon': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/coupon_receive': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/evaluate': TAB_BAR_KEYS.CATEGORY, - '/pages_tool/goods/not_exist': TAB_BAR_KEYS.CATEGORY, - // 入驻企业 + // 入驻企业 — tab 根页面 (子页面用通配符) '/pages_promotion/merch/merchcategory': TAB_BAR_KEYS.MERCHANT, '/pages_promotion/merch/detail': TAB_BAR_KEYS.MERCHANT, - // 购物车 + // 购物车 (单页) '/pages_goods/cart': TAB_BAR_KEYS.CART, - // 我的(会员中心及子页面) + // 我的 (会员中心 tab 根页面; 子页面用通配符) '/pages_tool/member/index': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/info': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/info_edit': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/address': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/address_edit': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/account': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/account_edit': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/balance': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/balance_detail': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/point': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/point_detail': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/coupon': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/level': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/level_growth_rules': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/card': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/card_buy': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/card_log': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/card_agreement': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/invite_friends': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/collection': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/footprint': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/contact': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/modify_face': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/pay_password': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/apply_withdrawal': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/withdrawal': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/withdrawal_detail': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/cancellation': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/cancelstatus': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/cancelsuccess': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/cancelrefuse': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/assets': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/signin': TAB_BAR_KEYS.MEMBER, - '/pages_tool/member/bind_mobile': TAB_BAR_KEYS.MEMBER, - // 我的频道下的其他功能页 + // 我的频道下的其他独立功能页 '/pages_order/list': TAB_BAR_KEYS.MEMBER, '/pages_tool/recharge/list': TAB_BAR_KEYS.MEMBER, '/pages_tool/recharge/order_list': TAB_BAR_KEYS.MEMBER, - // 帮助/公告/文章列表属于首页频道 + // 帮助/公告/文章列表 (单页, 归首页频道) '/pages_tool/help/list': TAB_BAR_KEYS.HOME, '/pages_tool/notice/list': TAB_BAR_KEYS.HOME, '/pages_tool/article/list': TAB_BAR_KEYS.HOME, - // 文件列表(视业务可归首页,暂不归任何 Tab) - '/pages_tool/files/list': null + // 文件列表 (视业务可归首页, 暂不归任何 Tab) + '/pages_tool/files/list': null, + + // 注: /pages_tool/index/diy 不在此映射中 + // 走 history-walk 自动归属: home → diy → HOME; 深链 → diy → 不点亮 }; +// 页面级 TabBar 通配符映射 (按子树批量归属, 减少维护成本) +// 匹配规则: pattern 以 '/*' 结尾, '*' 匹配"前缀之后的至少一个字符段" +// 优先级: PAGE_TAB_MAP 精确匹配 > PAGE_TAB_WILDCARDS 通配符 +// 查找函数: util-tabbar.js#lookupPageTabMap +export const PAGE_TAB_WILDCARDS = [ + // 我的频道 — /pages_tool/member/* 下所有子页面 (30+ 页压缩为 1 行) + { pattern: '/pages_tool/member/*', tab: TAB_BAR_KEYS.MEMBER }, + + // 商品工具 — /pages_tool/goods/* 下所有子页面 (search/brand/coupon/...) + { pattern: '/pages_tool/goods/*', tab: TAB_BAR_KEYS.CATEGORY }, +]; + // ============================================================= // 基于 util-tabbar / util-navigate 的本文件 wrapper @@ -403,6 +380,7 @@ export const checkTabBarActive = (linkUrl, currentPageRoute, tabBarList, package { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, + pageTabWildcards: PAGE_TAB_WILDCARDS, }, ); }; @@ -428,6 +406,7 @@ export default { TAB_BAR_KEYS, NO_TAB_PAGES, PAGE_TAB_MAP, + PAGE_TAB_WILDCARDS, systemTabBarList, isHarmonyQuickAppWebview, diff --git a/components-diy/diy-bottom-nav.vue b/components-diy/diy-bottom-nav.vue index cb54961..d6f04ce 100644 --- a/components-diy/diy-bottom-nav.vue +++ b/components-diy/diy-bottom-nav.vue @@ -45,6 +45,7 @@