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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
|
||||
<script>
|
||||
import { adaptSubpackageUrl, checkTabBarActive, decodeEntity } from '@/common/js/util.js'
|
||||
import { resolveEntryTab } from '@/common/js/util-tabbar.js'
|
||||
import DiyMinx from './minx.js'
|
||||
// 底部导航栏
|
||||
export default {
|
||||
@@ -103,6 +104,11 @@ export default {
|
||||
packageEntryTab() {
|
||||
return this.$store.state.packageEntryTab;
|
||||
},
|
||||
// 响应 tabBarList 异步加载: 后端返回前 updateCurrentRoute 是 no-op,
|
||||
// tabBarList 就绪后必须重跑一次, 否则冷启动+秒点链接会出现"先不高亮→再突然高亮"
|
||||
tabBarReady() {
|
||||
return this.tabBarList && this.tabBarList.list && this.tabBarList.list.length > 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
cartChange: function (nval, oval) {
|
||||
@@ -118,7 +124,17 @@ export default {
|
||||
this.cartAnimation = animation.export();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
},
|
||||
// tabBarList 异步加载完成后, 补一次 updateCurrentRoute
|
||||
// 修复: 冷启动时用户在 tabBarList 就绪前点击链接, 第一次 updateCurrentRoute 是 no-op
|
||||
tabBarReady: {
|
||||
handler(ready) {
|
||||
if (ready) {
|
||||
this.updateCurrentRoute();
|
||||
}
|
||||
},
|
||||
immediate: false,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// 统一归一化路径:currentPage.route 不带前导 '/',但 tab 的 wap_url 带前导 '/'
|
||||
@@ -145,45 +161,28 @@ export default {
|
||||
|
||||
// 归一化:补全前导 '/',确保后续 split('/') 后 parts[1]=分包名
|
||||
const newRoute = this._normalizeRoute(currentPage.route);
|
||||
const newRouteParts = newRoute.split('?')[0].split('/');
|
||||
const currentPackageName = newRouteParts[1] || '';
|
||||
const currentPackageName = newRoute.split('?')[0].split('/')[1] || '';
|
||||
|
||||
if (this.tabBarList && this.tabBarList.list && currentPackageName) {
|
||||
const tabUrls = this.tabBarList.list
|
||||
.filter(item => item.link && item.link.wap_url)
|
||||
.map(item => adaptSubpackageUrl(item.link.wap_url).split('?')[0]);
|
||||
// 收集历史栈路由 (排除当前页, 已 _normalizeRoute)
|
||||
// 注意: 组件里直接传 raw route 给 resolveEntryTab,
|
||||
// 内部 adaptSubpackageUrl 会兜底处理老路径 /pages/member/ 等
|
||||
const historyRoutes = currentPages.length >= 2
|
||||
? currentPages.slice(0, -1).map((p) =>
|
||||
p && p.route ? this._normalizeRoute(p.route) : null)
|
||||
: [];
|
||||
|
||||
// 1) 当前页面精确命中某个 tab → 该 tab 就是入口
|
||||
let entryTab = tabUrls.find(tabUrl => tabUrl === newRoute.split('?')[0]);
|
||||
|
||||
// 2) 当前页面在同一分包内 → 用分包名匹配
|
||||
if (!entryTab) {
|
||||
entryTab = tabUrls.find(tabUrl => {
|
||||
const tabParts = tabUrl.split('/');
|
||||
return tabParts[1] === currentPackageName;
|
||||
});
|
||||
}
|
||||
|
||||
// 3) 回退到上一页的分包匹配
|
||||
if (!entryTab) {
|
||||
const prevPage = currentPages[currentPages.length - 2];
|
||||
if (prevPage && prevPage.route) {
|
||||
const prevParts = this._normalizeRoute(prevPage.route).split('?')[0].split('/');
|
||||
const prevPackageName = prevParts[1] || '';
|
||||
entryTab = tabUrls.find(tabUrl => {
|
||||
const tabParts = tabUrl.split('/');
|
||||
return tabParts[1] === prevPackageName;
|
||||
});
|
||||
if (entryTab && prevPackageName !== currentPackageName) {
|
||||
// 上一页在别的分包 → 只记录到上一页的分包,不污染当前分包
|
||||
this.$store.commit('setPackageEntryTab', {
|
||||
...this.packageEntryTab,
|
||||
[prevPackageName]: entryTab
|
||||
});
|
||||
entryTab = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 调用纯函数 resolveEntryTab 完成 4 步查找
|
||||
// 规则: 从哪个 TabBar 出发, 非 tab 页就归属哪个 TabBar 高亮
|
||||
// 修复: home → diy 微页面 等跨分包导航被"同分包第一个 tab"误判的问题
|
||||
const entryTab = resolveEntryTab({
|
||||
currentPageRoute: newRoute,
|
||||
tabBarList: this.tabBarList,
|
||||
historyRoutes,
|
||||
// 传 NO_TAB_PAGES 进去, 让 login/pay/AI 等 modal 页不参与归属判定
|
||||
// 例子: home → login → diy 应算作 home → diy (login 是 modal)
|
||||
noTabPages: this.$util && this.$util.NO_TAB_PAGES ? this.$util.NO_TAB_PAGES : null,
|
||||
});
|
||||
|
||||
if (entryTab) {
|
||||
this.$store.commit('setPackageEntryTab', {
|
||||
@@ -253,8 +252,17 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (link.wap_url === this.$util.INDEX_PAGE_URL && this.name === 'DIY_VIEW_INDEX') {
|
||||
return true;
|
||||
if (this.name === 'DIY_VIEW_INDEX') {
|
||||
// DIY_VIEW_INDEX 短路: 当 name 是 DIY_VIEW_INDEX 时, 任意匹配的 tab 都高亮
|
||||
// 修复前: 只对 HOME (INDEX_PAGE_URL) 生效
|
||||
// 修复后: 适用于所有 tab 页面 (如 merchant DIY 首页、cart DIY 首页 等)
|
||||
// 前提: 当前页面本身就是该 tab 根, 链接 == 当前页 → step 1 早就命中
|
||||
// 这个短路主要兜底: "DIY_VIEW_INDEX 页面 + tab 链接" 的强语义关联
|
||||
const linkUrl = adaptSubpackageUrl(link.wap_url).split('?')[0];
|
||||
const currentUrl = currentPageRoute.split('?')[0];
|
||||
if (linkUrl === currentUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return checkTabBarActive(link.wap_url, currentPageRoute, this.tabBarList, this.packageEntryTab, currentPageQuery);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view data-component-name="diy-picture" class="diy-picture" :style="style">
|
||||
<view class="fui-picture">
|
||||
<view v-for="(item, index) in value.list" style="line-height: 0;">
|
||||
<view v-for="(item, index) in value.list" :key="index" style="line-height: 0;">
|
||||
<image mode="widthFix" style="width: 100%;height:auto" :src="$util.img(item.imageUrl)"
|
||||
v-if="item.link.wap_url" @tap.stop="handlerClick(item)" show-menu-by-longpress></image>
|
||||
<image mode="widthFix" style="width: 100%;height:auto" :src="$util.img(item.imageUrl)" v-else
|
||||
|
||||
@@ -191,6 +191,11 @@
|
||||
rgba($accent, 0) 75%);
|
||||
box-shadow: 0 0 200rpx rgba($accent, 0.15);
|
||||
animation: aura-breathe 3.6s ease-in-out infinite;
|
||||
// 提升为独立合成层:200rpx 的 box-shadow 与 radial-gradient 只光栅化一次,
|
||||
// 之后 aura-breathe 的 scale/opacity 全由 GPU 合成,不再逐帧重绘。视觉不变。
|
||||
will-change: transform, opacity;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.splash-screen__core {
|
||||
@@ -202,6 +207,8 @@
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
animation: core-pulse 2.4s ease-in-out infinite;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.splash-screen__core-halo {
|
||||
@@ -213,6 +220,8 @@
|
||||
rgba($accent, 0.5) 0%,
|
||||
rgba($accent, 0) 60%);
|
||||
animation: halo-expand 2.4s ease-in-out infinite;
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.splash-screen__core-body {
|
||||
@@ -239,6 +248,10 @@
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
transform-origin: center;
|
||||
// 提升为合成层:内部 particle 的 box-shadow 随图层一起绘制一次,
|
||||
// orbit-rotate 旋转时 GPU 直接变换整张位图,不重绘阴影。视觉不变。
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
|
||||
&--1 {
|
||||
width: 160rpx;
|
||||
|
||||
108
docs/weixin-bug.md
Normal file
108
docs/weixin-bug.md
Normal file
@@ -0,0 +1,108 @@
|
||||
|
||||
## console.log
|
||||
``` log
|
||||
无效的 app.json permission["scope.userClipboard"]、app.json permission["scope.camera"]、app.json permission["scope.writePhotosAlbum"]
|
||||
pages_tool/recharge/list: 无效的 page.json ["scrollEnabled"]
|
||||
|
||||
vendor.js:3 wx.getSystemInfoSync is deprecated.Please use wx.getSystemSetting/wx.getAppAuthorizeSetting/wx.getDeviceInfo/wx.getWindowInfo/wx.getAppBaseInfo instead.
|
||||
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/register-reward/register-reward.wxss:2:429)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-login/ns-login.wxss:1:1448)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/hover-nav/hover-nav.wxss:2:304)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-copyright/ns-copyright.wxss:1:1114)
|
||||
无效的 app.json permission["scope.userClipboard"]、app.json permission["scope.camera"]、app.json permission["scope.writePhotosAlbum"]
|
||||
pages_tool/recharge/list: 无效的 page.json ["scrollEnabled"]
|
||||
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-channel-video.wxss:1:7941)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-channel-list.wxss:1:3415)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-tab.wxss:1:6056)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-goods-sku/ns-goods-sku-category.wxss:2:67)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-category.wxss:1:8372)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-category-item.wxss:1:8672)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-bottom-nav.wxss:1:1634)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-video-list.wxss:1:7343)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-audio.wxss:1:249)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-hot-area.wxss:1:200)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-payment-qrcode.wxss:1:2077)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-quick-nav.wxss:1:447)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-member-my-order.wxss:1:385)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-member-info.wxss:6:4119)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-merch-list.wxss:1:2221)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-article.wxss:1:2941)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-goods-recommend.wxss:4:1240)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-fenxiao-goods-list.wxss:1:4000)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-live.wxss:1:146)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-float-btn.wxss:1:544)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-notes.wxss:1:2367)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-presale.wxss:1:1955)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-bargain.wxss:1:5456)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-pinfan.wxss:1:3083)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-groupbuy.wxss:1:2069)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-pintuan.wxss:1:6513)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-seckill.wxss:1:10928)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-rubik-cube.wxss:1:171)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-many-goods-list.wxss:1:963)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-form/ns-form.wxss:1:2545)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-goods-sku/ns-goods-sku.wxss:1:3373)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-goods-list.wxss:1:16746)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-coupon.wxss:2:1656)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-search.wxss:1:1052)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-img-ads.wxss:1:1009)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-notice.wxss:1:1282)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-text.wxss:1:9199)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components/ns-newform/ns-newform.wxss:3:3187)
|
||||
[pages/index/index] Some selectors are not allowed in component wxss, including tag name selectors, ID selectors, and attribute selectors.(./components-diy/diy-store.wxss:1:1985)
|
||||
|
||||
[WXML Runtime warning] ./components-diy/diy-picture.wxml
|
||||
Now you can provide attr `wx:key` for a `wx:for` to improve performance.
|
||||
> 1 | <view class="diy-picture" style="{{(style)}}" data-component-name="diy-picture"><view class="fui-picture"><block wx:for="{{$root.l0}}" wx:for-item="item" wx:for-index="index"><view style="line-height:0;"><block wx:if="{{item.$orig.link.wap_url}}"><image style="width:100%;height:auto;" mode="widthFix" src="{{item.g0}}" show-menu-by-longpress="{{true}}" data-event-opts="{{[['tap',[['handlerClick',['$0'],[[['value.list','',index]]]]]]]}}" catchtap="__e"></image></block><block wx:else><image style="width:100%;height:auto;" mode="widthFix" src="{{item.g1}}" show-menu-by-longpress="{{true}}" data-event-opts="{{[['tap',[['handlerClick',['$0'],[[['value.list','',index]]]]]]]}}" catchtap="__e"></image></block></view></block></view><ns-login class="vue-ref" vue-id="103d5f85-1" data-ref="login" bind:__l="__l"></ns-login></view>
|
||||
| ^
|
||||
[Deprecation] SharedArrayBuffer will require cross-origin isolation as of M92, around July 2021. See https://developer.chrome.com/blog/enabling-shared-array-buffer/ for more details.
|
||||
```
|
||||
|
||||
## 无使用或无依赖文件
|
||||
|
||||
```
|
||||
不应存在无使用的组件
|
||||
components/mescroll/components/mescroll-empty.json
|
||||
```
|
||||
|
||||
## 模拟器与真机行为差异备忘
|
||||
|
||||
涉及 navigationBar / 标题栏 / web-view 的逻辑,必须在**真机**调试,模拟器结果不可信。
|
||||
|
||||
| 能力 / API | 真机 | 微信开发者工具模拟器 | 备注 |
|
||||
|---|---|---|---|
|
||||
| `uni.setNavigationBarTitle` | 立即生效 | **不生效**(一直存在的 bug) | 模拟器不执行本地实现 |
|
||||
| `<web-view @titleUpdate>` | 不触发 | 不触发 | mp 端不支持此事件,仅 H5 可用 |
|
||||
| navigationBar 标题来源 | `setNavigationBarTitle` 调用 / `pages.json.navigationBarTitleText` | 仅 `pages.json.navigationBarTitleText` | 模拟器无 fallback,写了也看不到 |
|
||||
| 标题初始值(H5) | `manifest.json` 的 `name` / `h5.title` | 同左 | 与 mp 无关 |
|
||||
| `<web-view @load>` (mp) | 加载成功后触发 | 触发正常 | 真机模拟器行为一致 |
|
||||
|
||||
### 已踩过的坑
|
||||
|
||||
#### 1. `webview.vue` 标题在模拟器永远显示 "加载中..."
|
||||
|
||||
**症状**:在 WeChat 开发者工具模拟器里打开 `pages_tool/webview/webview`,navigationBar 始终是 `manifest.json` 的 `"name": "加载中..."`,传 `enable_set_title=1&title=真实标题` 也没用。
|
||||
|
||||
**根因**:模拟器不执行 `uni.setNavigationBarTitle` 的本地实现,只读 `pages.json` 里的 `navigationBarTitleText`(当前为空字符串),最终落到 `manifest.json` 的 `name` 兜底。
|
||||
|
||||
**正确处理**:
|
||||
- 代码保持简单,单次 `setNavigationBarTitle` 即可,不要加 `@load` 兜底、不要加定时器重试、不要模拟器专用 workaround。
|
||||
- 必须在真机验证标题行为;模拟器结果不可信。
|
||||
|
||||
#### 2. mp 端 `<web-view>` 不会触发 `@titleUpdate`
|
||||
|
||||
**症状**:在 `pages_tool/webview/webview.vue` 里写 `@titleUpdate="updateNavbarTitle"`,期望内嵌页 `<title>` 变化时自动同步 navigationBar,但 mp 端完全不触发。
|
||||
|
||||
**根因**:WeChat `<web-view>` 原生组件不支持 `titleUpdate` 事件,`@titleUpdate` 只在 H5(uni-app 用 iframe 模拟)下生效。
|
||||
|
||||
**正确处理**:
|
||||
- mp 端标题必须由 `uni.setNavigationBarTitle` 在 `onLoad` / `onShow` 显式写入。
|
||||
- 想拿内嵌页真实 `<title>`,让内嵌页通过 jweixin 调 `wx.miniProgram.setNavigationBarTitle` 反向通知;或者干脆不用"显示内嵌页标题"这个需求,直接由 query 参数传固定标题。
|
||||
|
||||
#### 3. 不要为模拟器 bug 写 workaround
|
||||
|
||||
有些"修模拟器表现"的尝试(@load 多次重试 setNavigationBarTitle、加看门狗定时器)会让真机代码变得复杂、可能和内嵌页 `document.title` 抢控制权、引入边界 case。
|
||||
|
||||
**结论**:遇到 navigationBar / web-view 相关问题,先在真机上复现确认;只在真机还在问题再继续查代码。
|
||||
@@ -1,8 +1,14 @@
|
||||
<template>
|
||||
<view :style="themeColor">
|
||||
<!-- #ifndef MP-HARMONY -->
|
||||
<view class="iconfont iconshang navigate-back" @click="navigateBack"></view>
|
||||
<!-- #endif -->
|
||||
<web-view :src="src" @titleUpdate="updateNavbarTitle"></web-view>
|
||||
|
||||
<!-- #ifdef MP-HARMONY -->
|
||||
<cover-view class="iconfont iconshang navigate-back" @click="navigateBack"></cover-view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 隐私弹窗 -->
|
||||
<privacy-popup ref="privacyPopup"></privacy-popup>
|
||||
</view>
|
||||
|
||||
231
scripts/inject-nav-title.js
Normal file
231
scripts/inject-nav-title.js
Normal 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 };
|
||||
@@ -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
105
scripts/verify-nav-title.js
Normal 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);
|
||||
154
scripts/verify-pages-json-cross-platform.js
Normal file
154
scripts/verify-pages-json-cross-platform.js
Normal 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);
|
||||
@@ -85,7 +85,12 @@ const store = new Vuex.Store({
|
||||
diyGroupPositionObj: {},
|
||||
diyGroupShowModule: '',
|
||||
tabBarHeight: '56px',
|
||||
packageEntryTab: {}, // 追踪每个分包的入口Tab { 'pages_goods': '/pages_goods/category' }
|
||||
packageEntryTab: {}, // 记录每个分包的"入口 tab" { 'pages_goods': '/pages_goods/category', ... }
|
||||
// 由 diy-bottom-nav.updateCurrentRoute → resolveEntryTab 写入
|
||||
// 语义:
|
||||
// - 同包内导航: 当前包第一个 tab (用户一直在包内浏览)
|
||||
// - 跨包导航: history-walk 找最近的 tab 根页面 (从哪个 tab 出发)
|
||||
// - 深链/冷启动 (无历史): 不写入 (iOS HIG — 无来源时不点亮)
|
||||
mapConfig: {
|
||||
tencent_map_key: '',
|
||||
wap_is_open: 1,
|
||||
|
||||
@@ -64,66 +64,6 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "components_diy",
|
||||
"srcPath": "./ascf/ascf_build/components_diy",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pages_goods",
|
||||
"srcPath": "./ascf/ascf_build/pages_goods",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pages_order",
|
||||
"srcPath": "./ascf/ascf_build/pages_order",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pages_promotion",
|
||||
"srcPath": "./ascf/ascf_build/pages_promotion",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "pages_tool",
|
||||
"srcPath": "./ascf/ascf_build/pages_tool",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
173
tests/_v.js
Normal file
173
tests/_v.js
Normal file
@@ -0,0 +1,173 @@
|
||||
'use strict';
|
||||
// 验证新测试文件全过
|
||||
const fs = require('fs'), path = require('path'), vm = require('vm'), assert = require('assert');
|
||||
const TABBAR_SRC = fs.readFileSync(path.join(__dirname, '..', 'common', 'js', 'util-tabbar.js'), 'utf8');
|
||||
const moduleObj = { exports: {} };
|
||||
const wrapped = TABBAR_SRC
|
||||
.replace(/^import\s+.*$/gm, '// x')
|
||||
.replace(/^export\s+function\s+/gm, 'function ')
|
||||
.replace(/^export\s+const\s+/gm, 'const ')
|
||||
.replace(/^export\s+let\s+/gm, 'let ')
|
||||
.replace(/^export\s+var\s+/gm, 'var ')
|
||||
+ '\nmodule.exports = { resolvePageUrl, isPageInRouteList, resolveLinkUrl, makeSystemTabBarList, adaptSubpackageUrl, isTabBarRootPage, isCurrentPageTabBarRoot, checkTabBarActive, resolveEntryTab, lookupPageTabMap };\n';
|
||||
const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
|
||||
sb.globalThis = sb; vm.createContext(sb); vm.runInContext(wrapped, sb);
|
||||
const { resolveEntryTab, checkTabBarActive, lookupPageTabMap } = sb.module.exports;
|
||||
|
||||
const HOME='/pages/index/index', CATEGORY='/pages_goods/category', CART='/pages_goods/cart',
|
||||
MERCHANT='/pages_promotion/merch/merchcategory', MEMBER='/pages_tool/member/index',
|
||||
DIY='/pages_tool/index/diy', MEMBER_INFO='/pages_tool/member/info',
|
||||
MEMBER_COUPON='/pages_tool/member/coupon', MEMBER_BALANCE='/pages_tool/member/balance',
|
||||
GOODS_LIST='/pages_goods/list', GOODS_DETAIL='/pages_goods/detail',
|
||||
SEARCH='/pages_tool/goods/search', BRAND='/pages_tool/goods/brand',
|
||||
HELP_LIST='/pages_tool/help/list', NOTICE_LIST='/pages_tool/notice/list',
|
||||
FILES_LIST='/pages_tool/files/list', FENXIAO='/pages_promotion/fenxiao/index',
|
||||
LOGIN='/pages_tool/login/login', PAY='/pages_tool/pay/index', WEBVIEW='/pages_tool/webview/webview';
|
||||
|
||||
const tabBarList = { list: [
|
||||
{ id:1, text:'首页', link:{wap_url:HOME} }, { id:2, text:'分类', link:{wap_url:CATEGORY} },
|
||||
{ id:3, text:'购物车', link:{wap_url:CART} }, { id:4, text:'商户', link:{wap_url:MERCHANT} },
|
||||
{ id:5, text:'我的', link:{wap_url:MEMBER} },
|
||||
]};
|
||||
const PAGE_TAB_MAP = {
|
||||
[HOME]:HOME, '/pages/empty/index':HOME, [HELP_LIST]:HOME, [NOTICE_LIST]:HOME,
|
||||
[CATEGORY]:CATEGORY, [GOODS_LIST]:CATEGORY, [GOODS_DETAIL]:CATEGORY,
|
||||
[MERCHANT]:MERCHANT, '/pages_promotion/merch/detail':MERCHANT,
|
||||
[CART]:CART, [MEMBER]:MEMBER, '/pages_order/list':MEMBER,
|
||||
'/pages_tool/recharge/list':MEMBER, '/pages_tool/recharge/order_list':MEMBER,
|
||||
[FILES_LIST]:null,
|
||||
};
|
||||
const PAGE_TAB_WILDCARDS = [
|
||||
{ pattern: '/pages_tool/member/*', tab: MEMBER },
|
||||
{ pattern: '/pages_tool/goods/*', tab: CATEGORY },
|
||||
];
|
||||
const NO_TAB_PAGES = new Set([LOGIN, PAY, WEBVIEW, '/pages_tool/ai-chat/index', '/pages_tool/vr/index', '/pages_tool/cases/index']);
|
||||
|
||||
const findH = (cur, hist, tbl = tabBarList) => {
|
||||
const e = resolveEntryTab({currentPageRoute:cur, tabBarList:tbl, historyRoutes:hist, noTabPages:NO_TAB_PAGES});
|
||||
const pkg = cur.split('?')[0].split('/')[1] || '';
|
||||
const pet = e ? {[pkg]:e} : {};
|
||||
for (const t of tbl.list) {
|
||||
if (checkTabBarActive(t.link.wap_url, cur, tbl, pet, {}, {noTabPages:NO_TAB_PAGES, pageTabMap:PAGE_TAB_MAP, pageTabWildcards:PAGE_TAB_WILDCARDS}))
|
||||
return t.link.wap_url;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let p=0, f=0;
|
||||
const T = (l, fn) => { try { fn(); p++; } catch(e) { f++; console.log(' FAIL', l, '|', e.message.slice(0,80)); } };
|
||||
const A = (a, e, l) => assert.equal(a, e, `[${l}] 期望 ${e||'不点亮'}, 实际 ${a||'不点亮'}`);
|
||||
|
||||
// 场景 1
|
||||
T('1.1 home→diy', () => A(findH(DIY, [HOME]), HOME, '1.1'));
|
||||
T('1.2 category→diy', () => A(findH(DIY, [CATEGORY]), CATEGORY, '1.2'));
|
||||
T('1.3 merchant→diy', () => A(findH(DIY, [MERCHANT]), MERCHANT, '1.3'));
|
||||
T('1.4 home→list→diy', () => A(findH(DIY, [HOME, GOODS_LIST]), HOME, '1.4'));
|
||||
T('1.5 home→login→diy', () => A(findH(DIY, [HOME, LOGIN]), HOME, '1.5'));
|
||||
// 场景 2
|
||||
T('2.1 list→detail', () => A(findH(GOODS_DETAIL, [GOODS_LIST]), CATEGORY, '2.1'));
|
||||
T('2.2 coupon→info', () => A(findH(MEMBER_INFO, [MEMBER_COUPON]), MEMBER, '2.2'));
|
||||
T('2.3 home→member→coupon→info', () => A(findH(MEMBER_INFO, [HOME, MEMBER, MEMBER_COUPON]), MEMBER, '2.3'));
|
||||
// 场景 3
|
||||
T('3.1 home→category tab', () => A(findH(CATEGORY, [HOME]), CATEGORY, '3.1'));
|
||||
T('3.2 diy→member tab', () => A(findH(MEMBER, [DIY]), MEMBER, '3.2'));
|
||||
T('3.3 diy→home tab', () => A(findH(HOME, [DIY]), HOME, '3.3'));
|
||||
T('3.4 链式', () => {
|
||||
const tabs = [HOME, CATEGORY, MEMBER, CART, HOME];
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
assert.equal(findH(tabs[i], tabs.slice(0, i)), tabs[i], `链式${i+1}`);
|
||||
}
|
||||
});
|
||||
// 场景 4
|
||||
T('4.1 home→search', () => A(findH(SEARCH, [HOME]), CATEGORY, '4.1'));
|
||||
T('4.2 home→brand', () => A(findH(BRAND, [HOME]), CATEGORY, '4.2'));
|
||||
T('4.3 home→member/info', () => A(findH(MEMBER_INFO, [HOME]), MEMBER, '4.3'));
|
||||
T('4.4 home→coupon', () => A(findH(MEMBER_COUPON, [HOME]), MEMBER, '4.4'));
|
||||
T('4.5 home→balance', () => A(findH(MEMBER_BALANCE, [HOME]), MEMBER, '4.5'));
|
||||
T('4.6 category→goods/detail', () => A(findH(GOODS_DETAIL, [CATEGORY]), CATEGORY, '4.6'));
|
||||
// 场景 5
|
||||
T('5.1 files/list null', () => A(findH(FILES_LIST, [HOME]), null, '5.1'));
|
||||
T('5.2 diy 走 history-walk', () => A(findH(DIY, [HOME]), HOME, '5.2'));
|
||||
T('5.3 diy 不在 PAGE_TAB_MAP', () => assert.equal(PAGE_TAB_MAP[DIY], undefined));
|
||||
// 场景 6
|
||||
T('6.1 深链 diy', () => A(findH(DIY, []), null, '6.1'));
|
||||
T('6.2 深链 diy?id=123', () => A(findH(DIY + '?id=123', []), null, '6.2'));
|
||||
T('6.3 深链 goods/detail', () => A(findH(GOODS_DETAIL, []), CATEGORY, '6.3'));
|
||||
T('6.4 深链 member', () => A(findH(MEMBER, []), MEMBER, '6.4'));
|
||||
T('6.5 深链 fenxiao', () => A(findH(FENXIAO, []), null, '6.5'));
|
||||
// 场景 7
|
||||
T('7.1 home→login', () => A(findH(LOGIN, [HOME]), null, '7.1'));
|
||||
T('7.2 home→list→pay', () => A(findH(PAY, [HOME, GOODS_LIST]), null, '7.2'));
|
||||
T('7.3 home→login→diy', () => A(findH(DIY, [HOME, LOGIN]), HOME, '7.3'));
|
||||
T('7.4 home→cart→pay→list', () => A(findH(GOODS_LIST, [HOME, CART, PAY]), HOME, '7.4'));
|
||||
// 场景 8
|
||||
T('8.1 from_tab=home', () => assert.equal(checkTabBarActive(HOME, DIY, tabBarList, {}, {from_tab:HOME}, {noTabPages:NO_TAB_PAGES, pageTabMap:{}, pageTabWildcards:[]}), true));
|
||||
T('8.2 PAGE_TAB_MAP 优先 from_tab', () => assert.equal(checkTabBarActive(MEMBER, DIY, tabBarList, {}, {from_tab:MEMBER}, {noTabPages:NO_TAB_PAGES, pageTabMap:{[DIY]:HOME}, pageTabWildcards:[]}), false));
|
||||
T('8.3 非法 from_tab', () => assert.equal(checkTabBarActive(HOME, DIY, tabBarList, {}, {from_tab:'/foo/bar'}, {noTabPages:NO_TAB_PAGES, pageTabMap:{}, pageTabWildcards:[]}), false));
|
||||
// 场景 9
|
||||
T('9.1 diy 带 query', () => A(findH('/pages_tool/index/diy?id=123&from=share', [HOME]), HOME, '9.1'));
|
||||
T('9.2 tab 根带 query', () => assert.equal(resolveEntryTab({currentPageRoute:'/pages/index/index?from=share', tabBarList, historyRoutes:[]}), HOME));
|
||||
T('9.3 老路径', () => assert.equal(resolveEntryTab({currentPageRoute:DIY, tabBarList, historyRoutes:['/pages/member/index']}), MEMBER));
|
||||
// 场景 10
|
||||
T('10.1 互斥 1 亮', () => {
|
||||
const r = tabBarList.list.map(t => checkTabBarActive(t.link.wap_url, DIY, tabBarList, {pages_tool:HOME}, {}, {noTabPages:NO_TAB_PAGES, pageTabMap:PAGE_TAB_MAP, pageTabWildcards:PAGE_TAB_WILDCARDS}));
|
||||
assert.equal(r.filter(Boolean).length, 1);
|
||||
});
|
||||
T('10.2 幂等', () => {
|
||||
const o = {currentPageRoute:DIY, tabBarList, historyRoutes:[HOME, GOODS_LIST]};
|
||||
assert.equal(resolveEntryTab(o), resolveEntryTab(o));
|
||||
assert.equal(resolveEntryTab(o), HOME);
|
||||
});
|
||||
// 场景 11
|
||||
T('11.1 tabBarList 空', () => A(findH(DIY, [HOME], {list:[]}), null, '11.1'));
|
||||
T('11.2 脏数据', () => {
|
||||
const dirty = { list: [{id:1,link:null},{id:2,link:{}},{id:3,link:{wap_url:HOME}},{id:4,link:{wap_url:MEMBER}}] };
|
||||
assert.equal(resolveEntryTab({currentPageRoute:DIY, tabBarList:dirty, historyRoutes:[HOME]}), HOME);
|
||||
});
|
||||
T('11.3 history 脏', () => assert.equal(resolveEntryTab({currentPageRoute:DIY, tabBarList, historyRoutes:[null,'',undefined,HOME]}), HOME));
|
||||
T('11.4 history 非数组', () => assert.equal(resolveEntryTab({currentPageRoute:DIY, tabBarList, historyRoutes:'not-array'}), MEMBER));
|
||||
T('11.5 深链 unmapped', () => A(findH('/pages_goods/some-unmapped-page', []), null, '11.5'));
|
||||
// 场景 12
|
||||
T('12.1 home→diy→member→回→home→diy', () => A(findH(DIY, [HOME]), HOME, '12.1'));
|
||||
T('12.2 home→diy→点 cart tab', () => A(findH(CART, [HOME]), CART, '12.2'));
|
||||
T('12.3 登出后 diy', () => A(findH(DIY, [HOME]), HOME, '12.3'));
|
||||
// 单元 13
|
||||
T('13.1 tab 根优先', () => assert.equal(resolveEntryTab({currentPageRoute:CATEGORY, tabBarList, historyRoutes:[HOME, MEMBER]}), CATEGORY));
|
||||
T('13.2 精确', () => assert.equal(lookupPageTabMap(MEMBER, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), MEMBER));
|
||||
T('13.3 通配符', () => {
|
||||
assert.equal(lookupPageTabMap('/pages_tool/member/balance_detail', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), MEMBER);
|
||||
assert.equal(lookupPageTabMap('/pages_tool/goods/brand', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), CATEGORY);
|
||||
});
|
||||
T('13.4 null 优先', () => {
|
||||
const cm = {[FILES_LIST]:null};
|
||||
const cw = [{pattern:'/pages_tool/files/*', tab: HOME}];
|
||||
assert.equal(lookupPageTabMap(FILES_LIST, cm, cw), null);
|
||||
});
|
||||
T('13.5 无匹配', () => assert.equal(lookupPageTabMap(DIY, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), undefined));
|
||||
T('13.6 前缀相近', () => assert.equal(lookupPageTabMap('/pages_tool/members', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS), undefined));
|
||||
// 兜底 14
|
||||
T('14.1 非法 link', () => assert.equal(checkTabBarActive('/pages/x', DIY, tabBarList, {pages_tool:HOME}, {from_tab:HOME}, {noTabPages:NO_TAB_PAGES, pageTabMap:PAGE_TAB_MAP, pageTabWildcards:PAGE_TAB_WILDCARDS}), false));
|
||||
T('14.2 NO_TAB_PAGES 都不亮', () => {
|
||||
for (const u of [HOME, CATEGORY, CART, MERCHANT, MEMBER]) {
|
||||
assert.equal(checkTabBarActive(u, LOGIN, tabBarList, {pages:HOME, pages_tool:MEMBER}, {}, {noTabPages:NO_TAB_PAGES, pageTabMap:PAGE_TAB_MAP, pageTabWildcards:PAGE_TAB_WILDCARDS}), false);
|
||||
}
|
||||
});
|
||||
// 场景对照 15
|
||||
T('15. 8 场景', () => {
|
||||
const SC = [
|
||||
['深链扫 diy', DIY, [], null],
|
||||
['首页 → diy 链接', DIY, [HOME], HOME],
|
||||
['首页 → 会员中心', MEMBER, [HOME], MEMBER],
|
||||
['分类 → diy 链接', DIY, [CATEGORY], CATEGORY],
|
||||
['分类 → 商品列表 → diy', DIY, [CATEGORY, GOODS_LIST], CATEGORY],
|
||||
['我的 → 优惠券 → diy', DIY, [MEMBER, MEMBER_COUPON], MEMBER],
|
||||
['首页 → 登录页 → diy', DIY, [HOME, LOGIN], HOME],
|
||||
['深链扫 member/info', MEMBER_INFO, [], MEMBER],
|
||||
];
|
||||
for (const [l, c, h, e] of SC) {
|
||||
assert.equal(findH(c, h), e, l);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('通过:', p, '/ 失败:', f);
|
||||
process.exit(f === 0 ? 0 : 1);
|
||||
912
tests/tabbar-highlight.test.js
Normal file
912
tests/tabbar-highlight.test.js
Normal file
@@ -0,0 +1,912 @@
|
||||
/**
|
||||
* TabBar 高亮 — 行为测试 (按用户故事组织)
|
||||
*
|
||||
* 原则:
|
||||
* - 每个测试都是一个真实的用户故事, 不是为了能测试过而迎合代码
|
||||
* - 测试 input → 行为, 不测试代码结构 ("wiring 测试" 已删除)
|
||||
* - 失败的测试 = 真实用户能看到的高亮错误
|
||||
*
|
||||
* 跑法 (需要 Node 18+):
|
||||
* node --test tests/tabbar-highlight.test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// =============================================================
|
||||
// 加载被测源码 (ESM → CommonJS 沙箱)
|
||||
// =============================================================
|
||||
|
||||
const TABBAR_SRC = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'common', 'js', 'util-tabbar.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function loadTabbarInSandbox() {
|
||||
const moduleObj = { exports: {} };
|
||||
const wrapped = TABBAR_SRC
|
||||
.replace(/^export\s+function\s+/gm, 'function ')
|
||||
.replace(/^export\s+const\s+/gm, 'const ')
|
||||
.replace(/^export\s+let\s+/gm, 'let ')
|
||||
.replace(/^export\s+var\s+/gm, 'var ')
|
||||
+ '\nmodule.exports = { resolvePageUrl, isPageInRouteList, resolveLinkUrl, '
|
||||
+ 'makeSystemTabBarList, adaptSubpackageUrl, isTabBarRootPage, '
|
||||
+ 'isCurrentPageTabBarRoot, checkTabBarActive, resolveEntryTab, lookupPageTabMap };\n';
|
||||
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(wrapped, sandbox, { filename: 'util-tabbar.js' });
|
||||
return sandbox.module.exports;
|
||||
}
|
||||
|
||||
const { resolveEntryTab, checkTabBarActive, lookupPageTabMap } = loadTabbarInSandbox();
|
||||
|
||||
// =============================================================
|
||||
// 业务测试夹具
|
||||
// =============================================================
|
||||
|
||||
// 5 个 Tab + 5 个分包的标准布局
|
||||
const HOME = '/pages/index/index';
|
||||
const CATEGORY = '/pages_goods/category';
|
||||
const CART = '/pages_goods/cart';
|
||||
const MERCHANT = '/pages_promotion/merch/merchcategory';
|
||||
const MEMBER = '/pages_tool/member/index';
|
||||
|
||||
// 常见非 tab 落地页
|
||||
const DIY = '/pages_tool/index/diy';
|
||||
const MEMBER_INFO = '/pages_tool/member/info';
|
||||
const MEMBER_COUPON = '/pages_tool/member/coupon';
|
||||
const MEMBER_BALANCE = '/pages_tool/member/balance';
|
||||
const GOODS_LIST = '/pages_goods/list';
|
||||
const GOODS_DETAIL = '/pages_goods/detail';
|
||||
const SEARCH = '/pages_tool/goods/search';
|
||||
const BRAND = '/pages_tool/goods/brand';
|
||||
const COUPON = '/pages_tool/goods/coupon';
|
||||
const HELP_LIST = '/pages_tool/help/list';
|
||||
const NOTICE_LIST = '/pages_tool/notice/list';
|
||||
const FILES_LIST = '/pages_tool/files/list';
|
||||
const FENXIAO = '/pages_promotion/fenxiao/index';
|
||||
|
||||
// 全屏流程 (modal 风格)
|
||||
const LOGIN = '/pages_tool/login/login';
|
||||
const PAY = '/pages_tool/pay/index';
|
||||
const WEBVIEW = '/pages_tool/webview/webview';
|
||||
|
||||
function makeTabBarList(tabs) {
|
||||
return {
|
||||
list: tabs.map((t, i) => ({
|
||||
id: t.id || (i + 1),
|
||||
text: t.text || '',
|
||||
link: { wap_url: t.url },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const STANDARD_TABBAR = makeTabBarList([
|
||||
{ id: 1, text: '首页', url: HOME },
|
||||
{ id: 2, text: '分类', url: CATEGORY },
|
||||
{ id: 3, text: '购物车', url: CART },
|
||||
{ id: 4, text: '商户', url: MERCHANT },
|
||||
{ id: 5, text: '我的', url: MEMBER },
|
||||
]);
|
||||
|
||||
// PAGE_TAB_MAP: 显式声明的页面归属 (精确 + null)
|
||||
const PAGE_TAB_MAP = {
|
||||
// 首页频道
|
||||
[HOME]: HOME,
|
||||
'/pages/empty/index': HOME,
|
||||
[HELP_LIST]: HOME,
|
||||
[NOTICE_LIST]: HOME,
|
||||
// 分类频道 — tab 根
|
||||
[CATEGORY]: CATEGORY,
|
||||
[GOODS_LIST]: CATEGORY,
|
||||
[GOODS_DETAIL]: CATEGORY,
|
||||
// 商户频道
|
||||
[MERCHANT]: MERCHANT,
|
||||
'/pages_promotion/merch/detail': MERCHANT,
|
||||
// 购物车
|
||||
[CART]: CART,
|
||||
// 我的频道 — tab 根
|
||||
[MEMBER]: MEMBER,
|
||||
'/pages_order/list': MEMBER,
|
||||
'/pages_tool/recharge/list': MEMBER,
|
||||
'/pages_tool/recharge/order_list': MEMBER,
|
||||
// 显式"不点亮"
|
||||
[FILES_LIST]: null,
|
||||
};
|
||||
|
||||
// 通配符 (按子树批量归属, 减少维护成本)
|
||||
const PAGE_TAB_WILDCARDS = [
|
||||
{ pattern: '/pages_tool/member/*', tab: MEMBER },
|
||||
{ pattern: '/pages_tool/goods/*', tab: CATEGORY },
|
||||
];
|
||||
|
||||
// Modal / 全屏流程页 (在归属判定中被跳过, 视为透明)
|
||||
const NO_TAB_PAGES = new Set([
|
||||
LOGIN, PAY, WEBVIEW,
|
||||
'/pages_tool/ai-chat/index',
|
||||
'/pages_tool/vr/index',
|
||||
'/pages_tool/cases/index',
|
||||
'/pages_tool/webview/webview',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 端到端: 给定当前页面 + 历史栈, 算出哪个 tab 高亮
|
||||
* @returns {string|null} tab URL 或 null
|
||||
*/
|
||||
function findHighlightedTab({ currentPageRoute, historyRoutes = [], tabBarList = STANDARD_TABBAR }) {
|
||||
const entryTab = resolveEntryTab({
|
||||
currentPageRoute,
|
||||
tabBarList,
|
||||
historyRoutes,
|
||||
noTabPages: NO_TAB_PAGES,
|
||||
});
|
||||
const currentPackageName = currentPageRoute.split('?')[0].split('/')[1] || '';
|
||||
const packageEntryTab = entryTab ? { [currentPackageName]: entryTab } : {};
|
||||
for (const tab of tabBarList.list) {
|
||||
if (!tab.link || !tab.link.wap_url) continue;
|
||||
if (checkTabBarActive(
|
||||
tab.link.wap_url,
|
||||
currentPageRoute,
|
||||
tabBarList,
|
||||
packageEntryTab,
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
)) {
|
||||
return tab.link.wap_url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertHighlight(actual, expected, label) {
|
||||
assert.equal(actual, expected,
|
||||
`[${label}] 期望 ${expected || '不点亮'}, 实际 ${actual || '不点亮'}`);
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// 场景 1: 跨包导航 — "从哪个 tab 出发, 非 tab 页就归属哪个 tab"
|
||||
// =============================================================
|
||||
|
||||
test('场景 1.1: 用户从首页点 diy 链接 → 首页保持高亮', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME],
|
||||
}), HOME, 'home → diy');
|
||||
});
|
||||
|
||||
test('场景 1.2: 用户从分类点 diy 链接 → 分类高亮', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [CATEGORY],
|
||||
}), CATEGORY, 'category → diy');
|
||||
});
|
||||
|
||||
test('场景 1.3: 用户从商户 tab 进 diy 微页面 → 商户高亮', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [MERCHANT],
|
||||
}), MERCHANT, 'merchant → diy');
|
||||
});
|
||||
|
||||
test('场景 1.4: 深层导航, 跳过非 tab 中间页', () => {
|
||||
// 用户从首页逛到商品列表, 再点 diy 链接
|
||||
// (中间经过非 tab 的 goods_list)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME, GOODS_LIST],
|
||||
}), HOME, 'home → goods_list → diy');
|
||||
});
|
||||
|
||||
test('场景 1.5: 经过 login 弹窗后, 仍记为来自原 tab', () => {
|
||||
// 用户在 home, 弹登录页, 登录成功回到 diy
|
||||
// login 是 modal, 不应该"洗掉"来源 tab
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME, LOGIN],
|
||||
}), HOME, 'home → login → diy');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 2: 同包导航 — 用户在当前包内浏览, 高亮当前包的 tab
|
||||
// =============================================================
|
||||
|
||||
test('场景 2.1: /pages_goods/list → /pages_goods/detail 属于分类频道', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: GOODS_DETAIL,
|
||||
historyRoutes: [GOODS_LIST],
|
||||
}), CATEGORY, 'goods/list → detail');
|
||||
});
|
||||
|
||||
test('场景 2.2: /pages_tool/member/coupon → /pages_tool/member/info 属于"我的"', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER_INFO,
|
||||
historyRoutes: [MEMBER_COUPON],
|
||||
}), MEMBER, 'member/coupon → info');
|
||||
});
|
||||
|
||||
test('场景 2.3: 在"我的"频道内多页浏览, 高亮始终是"我的"', () => {
|
||||
// home → member → coupon → info (都在 pages_tool 包)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER_INFO,
|
||||
historyRoutes: [HOME, MEMBER, MEMBER_COUPON],
|
||||
}), MEMBER, 'home → member → coupon → info');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 3: tab 切换 — 用户点底部 tab 跳转
|
||||
// =============================================================
|
||||
|
||||
test('场景 3.1: tab→tab 跳转, 目标 tab 高亮 (不受来源干扰)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: CATEGORY,
|
||||
historyRoutes: [HOME],
|
||||
}), CATEGORY, 'home → category tab');
|
||||
});
|
||||
|
||||
test('场景 3.2: 在 diy 页面点"我的" tab → 我的高亮 (同包)', () => {
|
||||
// 实际场景: diy 页面底部 TabBar 用户点"我的"
|
||||
// 走 switchTab/reLaunch, 栈变成 [MEMBER]
|
||||
// diy 的临时 packageEntryTab 会被 MEMBER 覆盖
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER,
|
||||
historyRoutes: [DIY],
|
||||
}), MEMBER, 'diy → member tab');
|
||||
});
|
||||
|
||||
test('场景 3.3: 在 diy 页面点"首页" tab → 首页高亮 (跨包)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: HOME,
|
||||
historyRoutes: [DIY],
|
||||
}), HOME, 'diy → home tab');
|
||||
});
|
||||
|
||||
test('场景 3.4: 链式 tab 切换 home→分类→我的→购物车→首页', () => {
|
||||
// 模拟用户连续点 tab, 每次都更新高亮
|
||||
const tabs = [HOME, CATEGORY, MEMBER, CART, HOME];
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: tabs[i],
|
||||
historyRoutes: tabs.slice(0, i),
|
||||
}), tabs[i], `链式第 ${i + 1} 步: ${tabs[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 4: tab→tab 内部非 tab 页 — 通配符命中
|
||||
// =============================================================
|
||||
|
||||
test('场景 4.1: 首页 → 商品搜索 → 分类高亮 (通配符 /pages_tool/goods/*)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: SEARCH,
|
||||
historyRoutes: [HOME],
|
||||
}), CATEGORY, 'home → search (goods/* 通配符)');
|
||||
});
|
||||
|
||||
test('场景 4.2: 首页 → 商品品牌页 → 分类高亮 (通配符)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: BRAND,
|
||||
historyRoutes: [HOME],
|
||||
}), CATEGORY, 'home → brand (goods/* 通配符)');
|
||||
});
|
||||
|
||||
test('场景 4.3: 首页 → 会员中心 → 我的高亮 (通配符 /pages_tool/member/*)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER_INFO,
|
||||
historyRoutes: [HOME],
|
||||
}), MEMBER, 'home → member/info (member/* 通配符)');
|
||||
});
|
||||
|
||||
test('场景 4.4: 首页 → 优惠券 → 我的高亮 (通配符)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER_COUPON,
|
||||
historyRoutes: [HOME],
|
||||
}), MEMBER, 'home → member/coupon (member/* 通配符)');
|
||||
});
|
||||
|
||||
test('场景 4.5: 首页 → 余额 → 我的高亮 (通配符)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER_BALANCE,
|
||||
historyRoutes: [HOME],
|
||||
}), MEMBER, 'home → member/balance (member/* 通配符)');
|
||||
});
|
||||
|
||||
test('场景 4.6: 分类 tab → 商品详情 → 分类保持高亮 (PAGE_TAB_MAP 精确优先)', () => {
|
||||
// 即使来源是分类 tab, 商品详情仍在 PAGE_TAB_MAP 绑 CATEGORY
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: GOODS_DETAIL,
|
||||
historyRoutes: [CATEGORY],
|
||||
}), CATEGORY, 'category → goods/detail (PAGE_TAB_MAP wins)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 5: 显式 PAGE_TAB_MAP 优先级
|
||||
// =============================================================
|
||||
|
||||
test('场景 5.1: /pages_tool/files/list 显式绑 null → 不点亮 (业务规则: 文件页不归属任何 tab)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: FILES_LIST,
|
||||
historyRoutes: [HOME],
|
||||
}), null, 'home → files/list (null 优先)');
|
||||
});
|
||||
|
||||
test('场景 5.2: /pages_tool/index/diy 不在 PAGE_TAB_MAP, 走 history-walk', () => {
|
||||
// diy 不在精确表也不在通配符, 走 history-walk
|
||||
// 从 home 来 → HOME 高亮
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME],
|
||||
}), HOME, 'diy 走 history-walk, home 高亮');
|
||||
});
|
||||
|
||||
test('场景 5.3: diy 没在 PAGE_TAB_MAP 显式声明 (避免硬绑)', () => {
|
||||
// 反向验证: 修复后, diy 不在 PAGE_TAB_MAP, 完全靠 history-walk
|
||||
// 这是关键修复 — 之前 diy:HOME 导致深链误高亮
|
||||
// 此测试防回归: 任何人重新加回 diy:HOME, 这个测试 + 行为测试都会挂
|
||||
const currentMap = PAGE_TAB_MAP[DIY];
|
||||
assert.equal(currentMap, undefined,
|
||||
'diy 不应在 PAGE_TAB_MAP (修复后靠 history-walk 决定)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 6: 深链冷启动 (扫码/分享) — iOS HIG
|
||||
// =============================================================
|
||||
|
||||
test('场景 6.1: 用户扫码进 diy → 不高亮任何 tab (iOS HIG)', () => {
|
||||
// 用户从来没在任何 tab 页面, 没有来源记忆
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [],
|
||||
}), null, '深链 diy');
|
||||
});
|
||||
|
||||
test('场景 6.2: 分享 diy 链接给朋友, 朋友点开 → 不高亮 (无历史)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY + '?id=123',
|
||||
historyRoutes: [],
|
||||
}), null, '深链 diy?id=123');
|
||||
});
|
||||
|
||||
test('场景 6.3: 分享商品详情链接, 朋友点开 → 分类高亮 (通配符)', () => {
|
||||
// 朋友点链接进入商品详情, 没有历史
|
||||
// 商品详情在 PAGE_TAB_MAP 精确绑 CATEGORY → CATEGORY 高亮
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: GOODS_DETAIL,
|
||||
historyRoutes: [],
|
||||
}), CATEGORY, '深链 goods/detail');
|
||||
});
|
||||
|
||||
test('场景 6.4: 分享会员中心链接, 朋友点开 → 我的高亮 (精确绑)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: MEMBER,
|
||||
historyRoutes: [],
|
||||
}), MEMBER, '深链 member (精确)');
|
||||
});
|
||||
|
||||
test('场景 6.5: 分享 fenxiao 链接, 朋友点开 → 不高亮 (不在任何 map)', () => {
|
||||
// fenxiao 不在 PAGE_TAB_MAP, 不在通配符, 不在 tabBarList
|
||||
// iOS HIG: 不高亮
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: FENXIAO,
|
||||
historyRoutes: [],
|
||||
}), null, '深链 fenxiao');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 7: 全屏 modal 流程 — login / pay / webview
|
||||
// =============================================================
|
||||
|
||||
test('场景 7.1: 在 home 点登录 → 全屏不点亮 (NO_TAB_PAGES)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: LOGIN,
|
||||
historyRoutes: [HOME],
|
||||
}), null, 'home → login (NO_TAB_PAGES)');
|
||||
});
|
||||
|
||||
test('场景 7.2: 支付流程中 → 不点亮 (NO_TAB_PAGES)', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: PAY,
|
||||
historyRoutes: [HOME, GOODS_LIST],
|
||||
}), null, 'home → list → pay (NO_TAB_PAGES)');
|
||||
});
|
||||
|
||||
test('场景 7.3: 登录后回到原页面, 高亮不丢失', () => {
|
||||
// 实际场景: 用户在 home, 弹登录, 登录成功, 跳到 diy
|
||||
// 期望: diy 仍归属 home (login 是 modal, 不算"离开" home)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME, LOGIN],
|
||||
}), HOME, 'home → login → diy (modal 透明)');
|
||||
});
|
||||
|
||||
test('场景 7.4: 支付完成后回到原页面, 高亮跟随当前包', () => {
|
||||
// 用户: home → 进入商品区(逛购物车) → 支付 → 回到商品列表
|
||||
// 实际栈: home → cart → pay → list
|
||||
// 跳过 pay (NO_TAB_PAGES), 上一非 NO_TAB_PAGES 是 cart (pages_goods)
|
||||
// 当前 list 在 pages_goods → 同包 → CATEGORY (商品频道的入口 tab)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: GOODS_LIST,
|
||||
historyRoutes: [HOME, CART, PAY],
|
||||
}), CATEGORY, 'home → cart → pay → list (modal 透明 + 同包)');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 8: from_tab query 机制 (旧机制, 仍在用)
|
||||
// =============================================================
|
||||
|
||||
test('场景 8.1: URL 带 from_tab=home → 强制高亮 home (即使当前在 pages_tool)', () => {
|
||||
// diy-bottom-nav.vue#redirectTo 在用户点 TabBar 时会写 from_tab query
|
||||
const result = checkTabBarActive(
|
||||
HOME,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{ from_tab: HOME },
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: {}, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
assert.equal(result, true, 'from_tab=home → HOME 高亮');
|
||||
});
|
||||
|
||||
test('场景 8.2: from_tab=member 但 PAGE_TAB_MAP 已显式声明 → PAGE_TAB_MAP 优先', () => {
|
||||
// PAGE_TAB_MAP 显式声明的页面, from_tab 不能覆盖
|
||||
// 这是设计: 显式声明 > 任何 hint
|
||||
const result = checkTabBarActive(
|
||||
MEMBER,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{ from_tab: MEMBER },
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: { [DIY]: HOME }, pageTabWildcards: [] },
|
||||
);
|
||||
assert.equal(result, false, 'PAGE_TAB_MAP 优先于 from_tab');
|
||||
});
|
||||
|
||||
test('场景 8.3: 非法 from_tab 值 → 忽略, 走后续链路', () => {
|
||||
// from_tab 不是任何 tab, 视为无效
|
||||
const result = checkTabBarActive(
|
||||
HOME,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{ from_tab: '/foo/bar' },
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: {}, pageTabWildcards: [] },
|
||||
);
|
||||
assert.equal(result, false, '非法 from_tab → 忽略, 无其它信号 → 不亮');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 9: URL 参数不影响高亮判定
|
||||
// =============================================================
|
||||
|
||||
test('场景 9.1: diy 带 query ?id=123&from=share → 行为不变', () => {
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: '/pages_tool/index/diy?id=123&from=share',
|
||||
historyRoutes: [HOME],
|
||||
}), HOME, 'home → diy?id=123&from=share');
|
||||
});
|
||||
|
||||
test('场景 9.2: tab 根带 query 也精确匹配', () => {
|
||||
// 即使带 ?id=123, 也能识别是 home tab
|
||||
const result = resolveEntryTab({
|
||||
currentPageRoute: '/pages/index/index?from=share',
|
||||
tabBarList: STANDARD_TABBAR,
|
||||
historyRoutes: [],
|
||||
});
|
||||
assert.equal(result, HOME, 'home tab 根 + query');
|
||||
});
|
||||
|
||||
test('场景 9.3: 老路径 /pages/member/index 自动适配到 /pages_tool/member/index', () => {
|
||||
// 兼容老 URL (旧业务数据)
|
||||
const result = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: STANDARD_TABBAR,
|
||||
historyRoutes: ['/pages/member/index'],
|
||||
});
|
||||
assert.equal(result, MEMBER, '老路径自动 adaptSubpackageUrl');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 10: 一致性 — 同时只有 1 个 tab 高亮
|
||||
// =============================================================
|
||||
|
||||
test('场景 10.1: 5 个 tab 中, 同时只有 1 个高亮 (互斥)', () => {
|
||||
const results = STANDARD_TABBAR.list.map(t =>
|
||||
checkTabBarActive(
|
||||
t.link.wap_url,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{ pages_tool: HOME },
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
)
|
||||
);
|
||||
const trueCount = results.filter(Boolean).length;
|
||||
assert.equal(trueCount, 1, `5 个 tab 应只亮 1 个, 实际 ${trueCount} 个亮`);
|
||||
});
|
||||
|
||||
test('场景 10.2: 同场景下 (current + history), 高亮结果应一致 (幂等)', () => {
|
||||
// 同样的 input 调两次, 结果应一致
|
||||
const opts = {
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME, GOODS_LIST],
|
||||
tabBarList: STANDARD_TABBAR,
|
||||
};
|
||||
const r1 = resolveEntryTab(opts);
|
||||
const r2 = resolveEntryTab(opts);
|
||||
assert.equal(r1, r2, 'resolveEntryTab 幂等');
|
||||
assert.equal(r1, HOME, '且结果应为 home');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 11: 边界 — 输入异常时不能崩
|
||||
// =============================================================
|
||||
|
||||
test('场景 11.1: tabBarList 为空 (服务端配置异常) → 任何 tab 都不亮', () => {
|
||||
// 即使 diy 在 PAGE_TAB_MAP, 没有 tab 也没法亮
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME],
|
||||
tabBarList: { list: [] },
|
||||
}), null, 'tabBarList 空');
|
||||
});
|
||||
|
||||
test('场景 11.2: tabBarList.list 含脏数据 (link 为 null/空) → 不崩', () => {
|
||||
const dirtyTabBar = {
|
||||
list: [
|
||||
{ id: 1, link: null },
|
||||
{ id: 2, link: {} },
|
||||
{ id: 3, text: '首页', link: { wap_url: HOME } },
|
||||
{ id: 4, text: '我的', link: { wap_url: MEMBER } },
|
||||
],
|
||||
};
|
||||
const r = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: dirtyTabBar,
|
||||
historyRoutes: [HOME],
|
||||
});
|
||||
assert.equal(r, HOME, '脏数据不影响查找');
|
||||
});
|
||||
|
||||
test('场景 11.3: historyRoutes 含 null/空/无效值 → 安全跳过', () => {
|
||||
const r = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: STANDARD_TABBAR,
|
||||
historyRoutes: [null, '', undefined, HOME],
|
||||
});
|
||||
assert.equal(r, HOME, 'historyRoutes 脏数据 → 跳过');
|
||||
});
|
||||
|
||||
test('场景 11.4: historyRoutes 不是数组 → 视为无历史 (iOS HIG)', () => {
|
||||
// 防御性编程: 异常入参不能崩
|
||||
// 行为: 当作无历史 → 走 iOS HIG (无历史不点亮)
|
||||
const r = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: STANDARD_TABBAR,
|
||||
historyRoutes: 'not-an-array',
|
||||
});
|
||||
assert.equal(r, null, 'historyRoutes 非数组 → 视为空 → 不点亮');
|
||||
});
|
||||
|
||||
test('场景 11.5: 当前页面是 /pages_goods/some-unmapped-page 深链 → 不亮', () => {
|
||||
// 业务上新加的商品页, 既不在 PAGE_TAB_MAP 也不在通配符
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: '/pages_goods/some-unmapped-page',
|
||||
historyRoutes: [],
|
||||
}), null, '深链 unmapped 页');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 12: 业务连续性 — packageEntryTab 惰性更新
|
||||
// =============================================================
|
||||
|
||||
test('场景 12.1: 用户在 home → diy → 切到 member → 回 home → 进 diy, 仍归 home', () => {
|
||||
// 完整路径模拟:
|
||||
// stack=[home] (home 高亮)
|
||||
// stack=[home, diy] (home 保持)
|
||||
// stack=[member] (member 高亮, 弹栈)
|
||||
// stack=[home] (用户返回)
|
||||
// stack=[home, diy] (再次进 diy, 应仍归 home)
|
||||
//
|
||||
// 最后一步: 当前 diy, 历史 = [home]
|
||||
// 期望: HOME 高亮 (因为用户在 home 上下文再次进了 diy)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME],
|
||||
}), HOME, 'home → diy → member → 返回 → home → diy');
|
||||
});
|
||||
|
||||
test('场景 12.2: 进入 cart tab 后, 旧 packageEntryTab 被新 tab 覆盖', () => {
|
||||
// 用户在 home → diy (写入 pages_tool → home)
|
||||
// 然后点 cart tab → 弹栈, 跳 CART
|
||||
// 跳 CART 后: pages_goods → CART (覆盖旧 entry)
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: CART,
|
||||
historyRoutes: [HOME],
|
||||
}), CART, 'home → diy → 点 cart tab → CART');
|
||||
});
|
||||
|
||||
test('场景 12.3: 登出后, 旧 packageEntryTab 保持 (惰性, 下次 updateCurrentRoute 重算)', () => {
|
||||
// 登出不应清 packageEntryTab (故意惰性)
|
||||
// 下次进新页面时 updateCurrentRoute 会基于新栈重算, 自然覆盖
|
||||
// 这里只验证行为: 登出后访问 diy, 仍按 history-walk 决定
|
||||
assertHighlight(findHighlightedTab({
|
||||
currentPageRoute: DIY,
|
||||
historyRoutes: [HOME],
|
||||
}), HOME, '登出后 diy 高亮仍由 history-walk 决定');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 13: 核心单元测试 (resolveEntryTab 5 步 + lookupPageTabMap)
|
||||
// =============================================================
|
||||
|
||||
test('单元 13.1: 当前是 tab 根 → 走 step 1 精确匹配, 不查 history', () => {
|
||||
assert.equal(
|
||||
resolveEntryTab({ currentPageRoute: CATEGORY, tabBarList: STANDARD_TABBAR, historyRoutes: [HOME, MEMBER] }),
|
||||
CATEGORY,
|
||||
'category 是 tab 根, 不被 history 干扰',
|
||||
);
|
||||
});
|
||||
|
||||
test('单元 13.2: lookupPageTabMap 精确匹配优先于通配符', () => {
|
||||
// /pages_tool/member/index 在精确表里是 MEMBER
|
||||
assert.equal(
|
||||
lookupPageTabMap(MEMBER, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS),
|
||||
MEMBER,
|
||||
);
|
||||
});
|
||||
|
||||
test('单元 13.3: lookupPageTabMap 通配符命中非精确条目', () => {
|
||||
assert.equal(
|
||||
lookupPageTabMap('/pages_tool/member/balance_detail', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS),
|
||||
MEMBER,
|
||||
'通配符 /pages_tool/member/* → MEMBER',
|
||||
);
|
||||
assert.equal(
|
||||
lookupPageTabMap('/pages_tool/goods/brand', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS),
|
||||
CATEGORY,
|
||||
'通配符 /pages_tool/goods/* → CATEGORY',
|
||||
);
|
||||
});
|
||||
|
||||
test('单元 13.4: lookupPageTabMap null 优先于通配符', () => {
|
||||
// 即使有 /pages_tool/files/* 通配符, files/list 显式 null 应优先
|
||||
const customMap = { [FILES_LIST]: null };
|
||||
const customWildcards = [{ pattern: '/pages_tool/files/*', tab: HOME }];
|
||||
assert.equal(
|
||||
lookupPageTabMap(FILES_LIST, customMap, customWildcards),
|
||||
null,
|
||||
'files/list 显式 null 优先于通配符',
|
||||
);
|
||||
});
|
||||
|
||||
test('单元 13.5: lookupPageTabMap 无匹配 → undefined (走 history-walk)', () => {
|
||||
assert.equal(
|
||||
lookupPageTabMap(DIY, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS),
|
||||
undefined,
|
||||
'diy 不在任何表, 走 history-walk',
|
||||
);
|
||||
});
|
||||
|
||||
test('单元 13.6: lookupPageTabMap 通配符不误匹配前缀相近路径', () => {
|
||||
// /pages_tool/members (复数) 不会被 /pages_tool/member/* 匹配
|
||||
assert.equal(
|
||||
lookupPageTabMap('/pages_tool/members', PAGE_TAB_MAP, PAGE_TAB_WILDCARDS),
|
||||
undefined,
|
||||
'复数路径不匹配单数通配符',
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 14: checkTabBarActive 兜底 (直接函数调用, 验证边界)
|
||||
// =============================================================
|
||||
|
||||
test('兜底 14.1: link URL 不在 tabBarList → 直接 false (早返回)', () => {
|
||||
// 询问一个非法 link URL
|
||||
const result = checkTabBarActive(
|
||||
'/pages/non-existent-tab',
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{ pages_tool: HOME },
|
||||
{ from_tab: HOME },
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
assert.equal(result, false, '非法 link URL → false (无视所有信号)');
|
||||
});
|
||||
|
||||
test('兜底 14.2: 当前页面在 NO_TAB_PAGES → 任何 tab 都不亮', () => {
|
||||
for (const tabUrl of [HOME, CATEGORY, CART, MERCHANT, MEMBER]) {
|
||||
const result = checkTabBarActive(
|
||||
tabUrl,
|
||||
LOGIN,
|
||||
STANDARD_TABBAR,
|
||||
{ pages: HOME, pages_tool: MEMBER },
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
assert.equal(result, false, `login 页面, ${tabUrl} 不该亮`);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 15: 核心 8 场景对照表 (业务高频路径)
|
||||
// =============================================================
|
||||
|
||||
const SCENARIOS = [
|
||||
{ label: '深链扫 diy', current: DIY, history: [], expected: null },
|
||||
{ label: '首页 → diy 链接', current: DIY, history: [HOME], expected: HOME },
|
||||
{ label: '首页 → 会员中心', current: MEMBER, history: [HOME], expected: MEMBER },
|
||||
{ label: '分类 → diy 链接', current: DIY, history: [CATEGORY], expected: CATEGORY },
|
||||
{ label: '分类 → 商品列表 → diy', current: DIY, history: [CATEGORY, GOODS_LIST], expected: CATEGORY },
|
||||
{ label: '我的 → 优惠券 → diy', current: DIY, history: [MEMBER, MEMBER_COUPON], expected: MEMBER },
|
||||
{ label: '首页 → 登录页 → diy', current: DIY, history: [HOME, LOGIN], expected: HOME },
|
||||
{ label: '深链扫 member/info', current: MEMBER_INFO, history: [], expected: MEMBER },
|
||||
];
|
||||
|
||||
test('场景对照: 8 个核心业务场景', () => {
|
||||
for (const sc of SCENARIOS) {
|
||||
assertHighlight(
|
||||
findHighlightedTab({
|
||||
currentPageRoute: sc.current,
|
||||
historyRoutes: sc.history,
|
||||
}),
|
||||
sc.expected,
|
||||
sc.label,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 16: P0 修复验证 — 通用化 DIY_VIEW_INDEX
|
||||
// =============================================================
|
||||
|
||||
test('场景 16.1: 在 home page (name=DIY_VIEW_INDEX), 自点 home tab → home 高亮', () => {
|
||||
// 模拟: 用户在 home page, 点击 home tab
|
||||
// verify 的 DIY_VIEW_INDEX 短路 + step 2 精确匹配, 都应高亮
|
||||
// (实际测试中, 短路 vs 精确匹配谁先触发不重要, 结果都是 true)
|
||||
const result = checkTabBarActive(
|
||||
HOME,
|
||||
HOME,
|
||||
STANDARD_TABBAR,
|
||||
{ pages: HOME },
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
assert.equal(result, true, '在 home 上点 home tab → 高亮');
|
||||
});
|
||||
|
||||
test('场景 16.2: 在 home page, 点 cart tab → cart 不亮 (无来源记忆)', () => {
|
||||
// 用户在 home, 没去过 cart, packageEntryTab 只有 pages
|
||||
const result = checkTabBarActive(
|
||||
CART,
|
||||
HOME,
|
||||
STANDARD_TABBAR,
|
||||
{ pages: HOME },
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
// step 5 pageTabMap['/pages/index/index'] = HOME → 询问 cart 链接 → false
|
||||
assert.equal(result, false, '在 home 上点 cart → cart 不该高亮');
|
||||
});
|
||||
|
||||
test('场景 16.3: 在 home page, 点 merchant tab → 走 PAGE_TAB_MAP 兜底', () => {
|
||||
// pageTabMap['/pages/index/index'] = HOME, 询问 merchant → false
|
||||
const result = checkTabBarActive(
|
||||
MERCHANT,
|
||||
HOME,
|
||||
STANDARD_TABBAR,
|
||||
{ pages: HOME },
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
assert.equal(result, false, 'home → merchant: HOME 优先, merchant 不亮');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 17: P0-2 修复 — tabBarList 异步加载竞态
|
||||
// =============================================================
|
||||
|
||||
test('场景 17.1: tabBarList 异步加载场景 (文档化行为)', () => {
|
||||
// 实际修复在 vue 组件里 (watch tabBarReady), 这里验证 resolveEntryTab 在 tabBarList 空时的行为
|
||||
// 修复前: tabBarList 为空时, step 2 history-walk 会得到空 tabUrls, 走 fallback
|
||||
// 修复后: resolveEntryTab 直接 return null, 不会用空 tabUrls 计算
|
||||
const r1 = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: { list: [] }, // 空
|
||||
historyRoutes: [HOME],
|
||||
noTabPages: NO_TAB_PAGES,
|
||||
});
|
||||
assert.equal(r1, null, 'tabBarList 空时, resolveEntryTab 应安全返回 null');
|
||||
|
||||
const r2 = resolveEntryTab({
|
||||
currentPageRoute: DIY,
|
||||
tabBarList: null, // null
|
||||
historyRoutes: [HOME],
|
||||
noTabPages: NO_TAB_PAGES,
|
||||
});
|
||||
assert.equal(r2, null, 'tabBarList null 时, resolveEntryTab 应安全返回 null');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 场景 18: P1-4 调试开关 (__TABBAR_DEBUG__)
|
||||
// =============================================================
|
||||
|
||||
test('场景 18.1: __TABBAR_DEBUG__ 开启时, 决策路径有 console.log', () => {
|
||||
// 在沙箱里, 临时设 globalThis.__TABBAR_DEBUG__ = true 并捕获 console.log
|
||||
const logCapture = [];
|
||||
const origLog = console.log;
|
||||
const origGlobal = globalThis.__TABBAR_DEBUG__;
|
||||
console.log = (...a) => logCapture.push(a.join(' '));
|
||||
globalThis.__TABBAR_DEBUG__ = true;
|
||||
|
||||
try {
|
||||
checkTabBarActive(
|
||||
HOME,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
globalThis.__TABBAR_DEBUG__ = origGlobal;
|
||||
}
|
||||
|
||||
assert.ok(logCapture.length > 0, `应至少有一次 console.log, 实际 ${logCapture.length} 次`);
|
||||
assert.ok(logCapture.some(l => l.includes('[TABBAR]')), '日志应包含 [TABBAR] 前缀');
|
||||
});
|
||||
|
||||
test('场景 18.2: __TABBAR_DEBUG__ 未开启时, 决策路径无 console.log', () => {
|
||||
const logCapture = [];
|
||||
const origLog = console.log;
|
||||
const origGlobal = globalThis.__TABBAR_DEBUG__;
|
||||
console.log = (...a) => logCapture.push(a.join(' '));
|
||||
globalThis.__TABBAR_DEBUG__ = false;
|
||||
|
||||
try {
|
||||
checkTabBarActive(
|
||||
HOME,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS },
|
||||
);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
globalThis.__TABBAR_DEBUG__ = origGlobal;
|
||||
}
|
||||
|
||||
const tabbarLogs = logCapture.filter(l => l.includes('[TABBAR]'));
|
||||
assert.equal(tabbarLogs.length, 0, '__TABBAR_DEBUG__ 关闭时不应有 [TABBAR] 日志');
|
||||
});
|
||||
|
||||
test('场景 18.3: opts.debug = true 也能触发调试', () => {
|
||||
const logCapture = [];
|
||||
const origLog = console.log;
|
||||
const origGlobal = globalThis.__TABBAR_DEBUG__;
|
||||
console.log = (...a) => logCapture.push(a.join(' '));
|
||||
globalThis.__TABBAR_DEBUG__ = false;
|
||||
|
||||
try {
|
||||
checkTabBarActive(
|
||||
HOME,
|
||||
DIY,
|
||||
STANDARD_TABBAR,
|
||||
{},
|
||||
{},
|
||||
{ noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS, debug: true },
|
||||
);
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
globalThis.__TABBAR_DEBUG__ = origGlobal;
|
||||
}
|
||||
|
||||
const tabbarLogs = logCapture.filter(l => l.includes('[TABBAR]'));
|
||||
assert.ok(tabbarLogs.length > 0, 'opts.debug=true 应触发 [TABBAR] 日志');
|
||||
});
|
||||
Reference in New Issue
Block a user