Files
lucky_shop/common/js/util-tabbar.js
ZF sun c8fada4b9d refactor(util): 拆分跳转工具集到独立文件
将原util.js中的跳转相关工具函数拆分为util-navigate.js和util-tabbar.js两个独立模块,重构原有代码以:
1.  避免循环依赖问题
2.  提升代码可维护性和复用性
3.  保留原有API兼容性,无需修改业务调用代码
4.  实现纯函数化设计,方便测试和tree-shaking
2026-07-15 11:00:49 +08:00

444 lines
16 KiB
JavaScript

/**
* util-tabbar.js
*
* TabBar / 子包路径相关工具集 — 从 util.js 拆分出来
*
* 包含的函数(全部服务于 redirectTo / diyRedirectTo 的 tabBar 命中判断):
* - resolvePageUrl : 根据 page_key + params 解析得到实际跳转 URL
* - isPageInRouteList : 判断当前路由是否命中 page_routes 列表
* - resolveLinkUrl : 解析 link 对象得到最终 URL(合并 wap_url query 进 page_params)
* - systemTabBarList : 当前最新的 tabBar.list 配置
* - adaptSubpackageUrl : 子包路径前缀映射(/pages/order/ → /pages_order/)
* - _normalizePath : 归一化任意路径(私有,内部用)
* - isTabBarRootPage : 判断给定路径是否是 TabBar 根页面
* - isCurrentPageTabBarRoot : 判断**当前**页面是否是 TabBar 根页面(依赖 getCurrentPages/store)
* - checkTabBarActive : 检查 tabBar 是否激活
*
* 设计原则:
* 1. 不依赖 util.js,避免循环依赖
* 2. 纯函数为主(isCurrentPageTabBarRoot 除外,它依赖 getCurrentPages 和 store,
* 业务方在调用时手动注入 store 即可)
* 3. PAGE_KEY_MAP / NO_TAB_PAGES / PAGE_TAB_MAP / TAB_BAR_KEYS 仍然在 util.js(业务配置)
* util-tabbar.js 接受外部传入,提高复用性
*/
import { _toParamValueString } from './util-navigate.js';
// =============================================================
// PAGE_KEY → 实际 URL 解析
// =============================================================
/**
* 根据 page_key + params 解析得到实际跳转 URL
* @param {string} page_key 页面语义常量, 如 PAGE_GOODS_LIST
* @param {Object} params 页面参数(JSON 对象)
* @param {Object} [pageKeyMap={}] page_key → 实际路径的映射表(由调用方注入,通常是 PAGE_KEY_MAP)
* @returns {string|null} 实际 URL(已拼接参数)或 null(未匹配)
*/
export function resolvePageUrl(page_key, params = {}, pageKeyMap = {}) {
if (!page_key || !pageKeyMap[page_key]) {
return null;
}
let url = pageKeyMap[page_key];
if (params && typeof params === 'object' && Object.keys(params).length > 0) {
const parts = [];
Object.keys(params).forEach(function (key) {
const value = params[key];
if (value === null || value === undefined || value === '') return;
// 对象/数组走 JSON.stringify, 避免 String({}) 退化成 "[object Object]"
// 消费端 resolveLinkUrl 已用 JSON.parse 还原
const encodedValue = _toParamValueString(value);
// H5: 浏览器不自动编码, 需要手动 encodeURIComponent
// 小程序 / App: uni.navigateTo 会自动编码, 这里不能再 encode, 否则双重编码
// #ifdef H5
parts.push(key + '=' + encodeURIComponent(encodedValue));
// #endif
// #ifndef H5
parts.push(key + '=' + encodedValue);
// #endif
});
if (parts.length > 0) {
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + parts.join('&');
}
}
return url;
}
/**
* 判断当前路由是否命中 page_routes 列表
* 支持 3 种写法, 可混合使用:
* 1) PAGE_KEY_MAP 的 key → 如 "PAGE_INDEX"
* 2) 实际页面路径 → 如 "/pages/index/index"
* 3) 通配符路径 → 如 "/pages_tool/*" 或 "PAGE_ORDER_*"
*
* @param {string} currentRoute 当前页面路径(会自动补 / 前缀)
* @param {string[]} routeList 后台配置的 page_routes 数组
* @param {Object} [pageKeyMap={}] PAGE_KEY_MAP
* @returns {boolean}
*/
export function isPageInRouteList(currentRoute, routeList, pageKeyMap = {}) {
if (!currentRoute || !routeList || !Array.isArray(routeList) || routeList.length === 0) {
return false;
}
const route = currentRoute.startsWith('/') ? currentRoute : '/' + currentRoute;
for (const item of routeList) {
if (!item || typeof item !== 'string') continue;
const entry = item.trim();
if (!entry) continue;
// 1) 通配符 /pages_tool/* 或 PAGE_ORDER_*
if (entry.endsWith('/*')) {
const prefix = entry.slice(0, -1);
if (route.startsWith(prefix)) return true;
continue;
}
if (entry.endsWith('_*') && pageKeyMap) {
const keyPrefix = entry.slice(0, -1);
const matchedPaths = [];
for (const k of Object.keys(pageKeyMap)) {
if (k.startsWith(keyPrefix) && pageKeyMap[k]) {
matchedPaths.push(pageKeyMap[k]);
}
}
for (const p of matchedPaths) {
const pn = p.startsWith('/') ? p : '/' + p;
const dirPrefix = pn.substring(0, pn.lastIndexOf('/') + 1);
if (route.startsWith(dirPrefix)) return true;
if (route === pn) return true;
}
continue;
}
// 2) PAGE_KEY 名称
if (pageKeyMap && pageKeyMap[entry]) {
let mapped = pageKeyMap[entry];
mapped = mapped.startsWith('/') ? mapped : '/' + mapped;
if (route === mapped) return true;
continue;
}
// 3) 实际路径
if (entry.startsWith('/')) {
if (route === entry) return true;
continue;
}
}
return false;
}
// =============================================================
// resolveLinkUrl
// =============================================================
/**
* 解析 link 对象得到最终可跳转的 URL
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
* 优先级: page_params > wap_url query (新机制优先)
*
* @param {Object} link
* @param {Object} [opts]
* @param {Object} [opts.pageKeyMap={}] PAGE_KEY_MAP
* @param {Function} [opts.parsePageParams] page_params 解析函数(默认用 util-navigate 的)
* @param {Function} [opts.resolvePageUrlFn] page_key → URL 函数(默认用本文件内)
* @param {Function} [opts.decodeEntity] HTML 实体解码函数
* @returns {string|null}
*/
export function resolveLinkUrl(link, opts = {}) {
const {
pageKeyMap = {},
parsePageParams = null,
resolvePageUrlFn = null,
decodeEntity = null,
} = opts;
if (!link) return null;
// 0. 把 wap_url 中的 query 合并到 page_params
if (link.wap_url) {
const _decode = decodeEntity || ((s) => s);
const decodedWap = _decode(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach((kv) => {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
// 解析 page_params (可能是字符串或对象) — 走统一 helper
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const pageParams = _parse(link.page_params);
// 合并: page_params 覆盖 urlParams (新机制优先)
link.page_params = Object.assign({}, urlParams, pageParams);
// 把 link.wap_url 改为不含 query 的 path
link.wap_url = path;
}
}
// 1. 优先使用 page_key(新机制)
if (link.page_key) {
const _parse = parsePageParams || ((p) => (p && typeof p === 'object') ? p : {});
const _resolvePageUrl = resolvePageUrlFn || ((key, params) => resolvePageUrl(key, params, pageKeyMap));
const params = _parse(link.page_params);
const url = _resolvePageUrl(link.page_key, params);
if (url) return url;
}
// 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path)
if (link.wap_url) {
return link.wap_url;
}
return null;
}
// =============================================================
// TabBar 列表与适配
// =============================================================
/**
* 当前最新的 tabBar.list
* 注意: 该函数需要业务方传入 4 个常量(INDEX/CATEGORY/CONTACT/MEMBER)
* 通常由 util.js 在 import 后调用 _setSystemTabBarList 注入
*/
export function makeSystemTabBarList(indexUrl, categoryUrl, contactUrl, memberUrl) {
return [indexUrl, categoryUrl, contactUrl, memberUrl];
}
/**
* 适配子包路径
* 把老路径 /pages/xxx/ 映射到新路径 /pages_xxx/
*
* @param {string} url
* @returns {string}
*/
export function adaptSubpackageUrl(url) {
if (!url) return url;
// 定义前缀映射表(key=旧前缀, value=新分包前缀)
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map((key) =>
key.replace(/\//g, '\\/').replace(/\*/g, '.*'),
).join('|') + ')',
);
function replacePrefix(str) {
return str.replace(regex, (match) => {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
// 兜底: 把 3 个及以上的连续 `/` 收敛为 2 个, 只动 path 部分
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// =============================================================
// TabBar 命中判断(私有辅助)
// =============================================================
/**
* 归一化任意路径: 确保以 '/' 开头, 去除 query, 适配子包
* (私有, 仅本模块内部使用)
*/
function _normalizePath(path) {
if (!path) return '';
const normalized = path.startsWith('/') ? path : '/' + path;
return adaptSubpackageUrl(normalized).split('?')[0];
}
/**
* 从归一化路径提取分包名(parts[1])
* (私有, 仅本模块内部使用)
*/
function _getPackageName(normalizedPath) {
const parts = normalizedPath.split('/');
return parts[1] || '';
}
/**
* 给定当前页面路径 + tab 配置, 返回当前页面所属的 tab URL
* 优先级: 精确匹配 > packageEntryTab > 分包名回退匹配
* (私有)
*/
function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) {
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return null;
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.map((item) => _normalizePath(item.link.wap_url));
const normalizedCurrent = _normalizePath(currentPagePath);
// 1) 精确匹配 → 当前页面就是 tab 页面
const exactMatch = tabUrls.find((url) => url === normalizedCurrent);
if (exactMatch) return exactMatch;
// 2) 通过 packageEntryTab(由 updateCurrentRoute/redirectTo 写入)
const packageName = _getPackageName(normalizedCurrent);
if (packageEntryTab && typeof packageEntryTab === 'object' && packageEntryTab[packageName]) {
const entryTab = packageEntryTab[packageName];
const normalizedEntry = _normalizePath(entryTab);
if (tabUrls.includes(normalizedEntry)) return normalizedEntry;
}
// 3) 回退: 同分包内的第一个 tab(基于分包名匹配)
return tabUrls.find((url) => _getPackageName(url) === packageName) || null;
}
/**
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
*
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
* @returns {boolean} 如果是 TabBar 根页面返回 true
*/
export function isTabBarRootPage(pagePath, tabBarList) {
if (!pagePath) return false;
if (!tabBarList || !tabBarList.list || !Array.isArray(tabBarList.list)) return false;
const normalizedCurrent = _normalizePath(pagePath);
if (!normalizedCurrent) return false;
return tabBarList.list.some((item) => {
if (!item || !item.link || !item.link.wap_url) return false;
const normalizedTab = _normalizePath(item.link.wap_url);
return normalizedTab === normalizedCurrent;
});
}
/**
* 判断**当前**页面是否是 TabBar 根页面
* 封装了 getCurrentPages() + isTabBarRootPage 的组合, 供业务组件在弹窗/操作前判断
*
* 调用方需要注入 getCurrentPages 和 store:
* isCurrentPageTabBarRoot({
* getCurrentPages: typeof getCurrentPages === 'function' ? getCurrentPages : null,
* store,
* tabBarList: null, // 不传则从 store.state.tabBarList 取
* })
*
* @param {Object} [opts={}]
* @param {Function} [opts.getCurrentPages]
* @param {Object} [opts.store]
* @param {Object} [opts.tabBarList]
* @returns {boolean}
*/
export function isCurrentPageTabBarRoot(opts = {}) {
try {
const gcp = opts.getCurrentPages || (typeof getCurrentPages === 'function' ? getCurrentPages : null);
if (!gcp) return false;
const pages = gcp();
if (!pages || !pages.length) return false;
const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '');
if (!currentRoute) return false;
const tabBarList = opts.tabBarList
|| (opts.store && opts.store.state ? opts.store.state.tabBarList : null);
return isTabBarRootPage(currentRoute, tabBarList);
} catch (e) {
return false;
}
}
/**
* 检查 tabBar 是否激活
*
* @param {string} linkUrl 链接路径 /pages/goods/category
* @param {string} currentPageRoute 当前页面路径 /pages_goods/category
* @param {Object} tabBarList tabBar 配置对象, 用于动态判断
* @param {Object} packageEntryTab 追踪每个分包的入口 Tab { 'pages_goods': '/pages_goods/category' }
* @param {Object} [currentPageQuery={}] 当前页面 query 参数, 用于读取 from_tab 来源记忆
* @param {Object} [opts={}]
* @param {Set} [opts.noTabPages] 独立流程页集合(NO_TAB_PAGES)
* @param {Object} [opts.pageTabMap] 页面级 Tab 精确绑定表(PAGE_TAB_MAP)
* @returns {boolean} 是否激活
*/
export function checkTabBarActive(
linkUrl,
currentPageRoute,
tabBarList,
packageEntryTab,
currentPageQuery = {},
opts = {},
) {
const { noTabPages = null, pageTabMap = {} } = opts;
// 1) 归一化 linkUrl: 统一前导 '/' + 子包路径适配 + 去除 query
const normalizedLink = _normalizePath(linkUrl);
const normalizedCurrent = _normalizePath(currentPageRoute);
if (!normalizedLink) return false;
// 2) 精确匹配: 当前页面就是 linkUrl 指向的页面
if (normalizedLink === normalizedCurrent) return true;
// 3) 独立流程页: 不点亮任何 Tab
if (noTabPages && noTabPages.has && noTabPages.has(normalizedCurrent)) return false;
// 4) 若传入 tabBarList, 使用动态判断逻辑
if (tabBarList && tabBarList.list && Array.isArray(tabBarList.list)) {
const tabUrls = tabBarList.list
.filter((item) => item.link && item.link.wap_url)
.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 (mappedTabUrl === null) return false;
// 6) 来源记忆兜底: 从哪个 Tab 跳转过来, 就保持那个 Tab 高亮
const fromTab = currentPageQuery.from_tab;
if (fromTab) {
const normalizedFromTab = _normalizePath(fromTab);
if (tabUrls.includes(normalizedFromTab)) {
return normalizedLink === normalizedFromTab;
}
}
// 7) 分包入口追踪: 通过 updateCurrentRoute 写入的入口 tab
const matchingTabUrl = _findMatchingTabUrl(currentPageRoute, tabBarList, packageEntryTab);
if (matchingTabUrl) return normalizedLink === matchingTabUrl;
// 8) 同分包回退: linkUrl 和当前页面在同一分包 → 高亮
const linkPackage = _getPackageName(normalizedLink);
const currentPackage = _getPackageName(normalizedCurrent);
return linkPackage && currentPackage && linkPackage === currentPackage;
}
return false;
}