From c8fada4b9d3bf62e14e17488d0ae9563203560bd Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 15 Jul 2026 11:00:49 +0800 Subject: [PATCH] =?UTF-8?q?refactor(util):=20=E6=8B=86=E5=88=86=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC=E5=B7=A5=E5=85=B7=E9=9B=86=E5=88=B0=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将原util.js中的跳转相关工具函数拆分为util-navigate.js和util-tabbar.js两个独立模块,重构原有代码以: 1. 避免循环依赖问题 2. 提升代码可维护性和复用性 3. 保留原有API兼容性,无需修改业务调用代码 4. 实现纯函数化设计,方便测试和tree-shaking --- common/js/util-navigate.js | 593 ++++++++++++++++++++++++++++++ common/js/util-tabbar.js | 443 +++++++++++++++++++++++ common/js/util.js | 713 ++++++------------------------------- 3 files changed, 1148 insertions(+), 601 deletions(-) create mode 100644 common/js/util-navigate.js create mode 100644 common/js/util-tabbar.js diff --git a/common/js/util-navigate.js b/common/js/util-navigate.js new file mode 100644 index 0000000..75b4be8 --- /dev/null +++ b/common/js/util-navigate.js @@ -0,0 +1,593 @@ +/** + * util-navigate.js + * + * 跳转链路专用的工具集 — 从 util.js 拆分出来 + * + * 包含的函数(全部服务于 redirectTo / diyRedirectTo 跳转链路): + * - _toParamValueString : 把任意值序列化成可作为 URL 参数的字符串 + * - parsePageParams : 把 page_params 还原成对象(JSON.parse 安全封装) + * - resolveLinkUrl : 解析 link 对象得到最终可跳转的 URL + * - cleanParamUrls : 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段 + * - normalizeUrl : 严格规范化 URL(不补协议/不补 host) + * - appendQueryParams : 给 URL 追加 query 参数 + * - getPlatform : 跨平台检测(h5 / mp-weixin / mp-harmony / quickapp) + * - shouldEncodeUrl : 是否需要主动 encodeURIComponent(4 平台都 true) + * - _safeEncodeForPlatform : 跨平台统一的 safeEncode(decode roundtrip + encode 一次) + * - _navigateTo : 跨平台跳转 API 调度 + * + * 设计原则: + * 1. 不依赖 util.js,避免循环依赖 + * 2. 全部为普通函数(不挂在 default export 对象上),方便 tree-shaking 和测试 + * 3. 行为完全兼容 util.js 旧版,util.js 通过 import 把这些方法挂载到 default export 上 + * (所有调用方 this.$util.cleanParamUrls / this.$util.redirectTo 等都不需要改) + * + * 跨平台编码 / 跳转策略文档见 _safeEncodeForPlatform / _navigateTo 的注释 + */ + +// ============================================================= +// 基础工具 +// ============================================================= + +/** + * 把任意值序列化成可作为 URL 参数的字符串。 + * - 对象/数组: JSON.stringify + * - null/undefined: '' + * - 其他: String(v) + * + * @param {*} v + * @returns {string} + */ +export function _toParamValueString(v) { + if (v === null || v === undefined) return ''; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +} + +/** + * 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。 + * 容错策略: 任何解析失败都安全降级到 defaultValue,不抛异常。 + * + * @example + * parsePageParams('{"a":1}') // { a: 1 } + * parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回) + * parsePageParams('[object Object]') // {} (老 bug 残留安全降级) + * parsePageParams('') // {} + * parsePageParams(null) // {} + * parsePageParams(undefined) // {} + * parsePageParams('not json', {x:1}) // { x: 1 } (解析失败走默认值) + * + * @param {*} raw + * @param {Object} [defaultValue={}] + * @returns {Object} + */ +export function parsePageParams(raw, defaultValue = {}) { + if (raw == null) return Object.assign({}, defaultValue); + if (typeof raw === 'object') return raw; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null + ? parsed + : Object.assign({}, defaultValue); + } catch (e) { + return Object.assign({}, defaultValue); + } + } + return Object.assign({}, defaultValue); +} + +// ============================================================= +// 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 + * @returns {string|null} + */ +export function resolveLinkUrl(link) { + if (!link) return null; + // 0. 把 wap_url 中的 query 合并到 page_params + if (link.wap_url) { + const decodedWap = decodeEntity(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; + }); + const pageParams = parsePageParams(link.page_params); + link.page_params = Object.assign({}, urlParams, pageParams); + link.wap_url = path; + } + } + // 1. 优先使用 page_key(新机制) + if (link.page_key) { + // 这里原本会调 resolvePageUrl(link.page_key, params), + // 但 util-navigate.js 不依赖 util.js,因此返回 link.wap_url + // (util.js 在 import 此模块后会用 page_key 重新解析) + const params = parsePageParams(link.page_params); + // 注: 完整 page_key 解析留给 util.js (它有 resolvePageUrl) + // 这里只做"wap_url 已被 resolveLinkUrl 处理过" 的返回 + } + // 2. 回退: 旧的 wap_url(已被处理为不含 query 的 path) + if (link.wap_url) { + return link.wap_url; + } + return null; +} + +/** + * decodeEntity — HTML 实体解码 + * (从 util.js 顶层 export 搬过来, 跟其他跳转工具一起) + * + * 解码 &xxx; / &#xN; 等 HTML 实体 + * 容错: 任何解析失败都返回原文 + * + * @param {string} str + * @param {boolean} [amp=true] 是否解码 & + * @returns {string} + */ +export function decodeEntity(str, amp) { + const html4Entities = { + nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', + brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', + not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', + sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', + sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', + iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', + aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', + euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', + ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', + times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', + thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', + delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', + kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', + pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', + phi: 'φ', chi: 'ψ', omega: 'ω', + }; + amp = amp === undefined ? true : amp; + let i = str.indexOf('&'); + while (i !== -1) { + const j = str.indexOf(';', i + 3); + let code; + if (j === -1) break; + if (str[i + 1] === '#') { + code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j)); + if (!isNaN(code)) { + str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1); + } + } else { + code = str.substring(i + 1, j); + if (html4Entities[code] || (code === 'amp' && amp)) { + str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1); + } + } + i = str.indexOf('&', i + 1); + } + return str; +} + +// ============================================================= +// cleanParamUrls +// ============================================================= + +/** + * 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段: + * - 去除反引号 / 各种空白 / 零宽字符 + * - 修复 scheme 后多余斜杠(https:////host → https://host) + * + * "看起来像 URL" 的判定: 含 `://`(大小写不敏感) + * - 命中: 走清理 + * - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤) + * + * 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖 + * + * @param {*} value 任意值 + * @returns {*} 与输入同结构的清理后值 + */ +export function cleanParamUrls(value) { + // 内部递归函数,通过闭包引用自身,避免 this 绑定问题 + const _clean = (v) => { + if (v === null || v === undefined) return v; + if (typeof v === 'string') { + if (/:\/\//i.test(v)) { + return v + .split('').filter((ch) => + ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r', + ).join('') + .replace(/^(https?:\/\/)\/+/i, '$1'); + } + return v; + } + if (Array.isArray(v)) { + return v.map((item) => _clean(item)); + } + if (typeof v === 'object') { + const out = {}; + for (const k of Object.keys(v)) { + out[k] = _clean(v[k]); + } + return out; + } + return v; + }; + return _clean(value); +} + +// ============================================================= +// normalizeUrl +// ============================================================= + +/** + * 严格 URL 规范化(只规范化已有部分,绝不自动补充) + * + * 策略: + * 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底) + * 2) 不补 https:// — 如果原串没有协议,保持没有 + * 3) 不补 host — 如果原串没有 host,保持没有 + * 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序 + * 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 '' + * + * @param {string} urlString + * @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 '' + */ +export function normalizeUrl(urlString) { + if (!urlString || typeof urlString !== 'string') return ''; + + // 1) 预清理: 反引号 / 各种空白 / BOM / 零宽字符 + let url = urlString + .split('').filter((ch) => { + const code = ch.charCodeAt(0); + if (code <= 0x20) return false; + if (ch === '`') return false; + if (code === 0x3000) return false; + if (code === 0xFEFF || code === 0x200B || + code === 0x200C || code === 0x200D || + code === 0x2060) return false; + return true; + }).join(''); + + // 2) 解析协议(可选,允许缺失) + const protocolMatch = url.match(/^(https?):\/\//i); + let protocol = ''; + let rest = url; + if (protocolMatch) { + protocol = protocolMatch[1].toLowerCase() + '://'; + rest = url.substring(protocolMatch[0].length); + // scheme 后不能紧跟 '/',即不允许 https:////host + if (rest.charAt(0) === '/') { + console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString); + return ''; + } + } else { + rest = url; + } + + // 3) 解析 hash + const hashIndex = rest.indexOf('#'); + let hash = ''; + let mainPart = rest; + if (hashIndex !== -1) { + hash = rest.substring(hashIndex); + mainPart = rest.substring(0, hashIndex); + } + + // 4) 解析 query + const queryIndex = mainPart.indexOf('?'); + let query = ''; + let pathPart = mainPart; + if (queryIndex !== -1) { + query = mainPart.substring(queryIndex); // 包含 '?' + pathPart = mainPart.substring(0, queryIndex); + } + + // 5) 解析 host + let hostname = ''; + let port = ''; + let pathname = pathPart; + if (protocol) { + const firstSlash = pathPart.indexOf('/'); + const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash); + pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash); + if (hostPort) { + const colonIdx = hostPort.lastIndexOf(':'); + if (colonIdx !== -1) { + hostname = hostPort.substring(0, colonIdx); + port = hostPort.substring(colonIdx + 1); + } else { + hostname = hostPort; + } + } + } + + // 6) 规范化 host + let lowerHostname = ''; + if (hostname) { + lowerHostname = hostname.toLowerCase(); + if ((protocol === 'http://' && port === '80') || + (protocol === 'https://' && port === '443')) { + port = ''; + } + if (/[\/\s]/.test(lowerHostname)) { + console.warn('[normalizeUrl] host 含非法字符:', urlString); + return ''; + } + } + + // 7) 规范化 pathname + let normalizedPath = pathname; + if (normalizedPath) { + const segments = normalizedPath.split('/'); + const resolved = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (seg === '' || seg === '.') { + if (i === 0) resolved.push(''); + continue; + } + if (seg === '..') { + if (resolved.length > 1) resolved.pop(); + continue; + } + resolved.push(seg); + } + normalizedPath = resolved.join('/') || '/'; + } + + // 8) 规范化 query + let normalizedQuery = ''; + if (query) { + const qs = query.substring(1); + if (qs) { + const pairs = qs.split('&').map((pair) => { + const eqIdx = pair.indexOf('='); + return eqIdx === -1 + ? { k: pair, v: '' } + : { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) }; + }); + pairs.sort((a, b) => a.k.localeCompare(b.k)); + normalizedQuery = '?' + pairs.map((p) => p.k + '=' + p.v).join('&'); + } + } + + // 9) 拼回 + const portStr = port ? ':' + port : ''; + const hostPart = lowerHostname ? lowerHostname + portStr : ''; + const schemeAndHost = protocol + hostPart; + return schemeAndHost + normalizedPath + normalizedQuery + hash; +} + +// ============================================================= +// appendQueryParams +// ============================================================= + +/** + * 给 URL 追加 query 参数(已存在的 key 会被覆盖) + * + * @param {string} url + * @param {Object} params + * @returns {string} + */ +export function appendQueryParams(url, params) { + if (!url) return ''; + if (!params) return url; + + const hashIndex = url.indexOf('#'); + let hash = ''; + let targetUrl = url; + if (hashIndex !== -1) { + hash = url.substring(hashIndex); + targetUrl = url.substring(0, hashIndex); + } + + const separator = targetUrl.includes('?') ? '&' : '?'; + const queryString = Object.entries(params) + .filter(([, value]) => value !== undefined && value !== null) + // 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]" + .map(([key, value]) => `${key}=${_toParamValueString(value)}`) + .join('&'); + + return targetUrl + separator + queryString + hash; +} + +// ============================================================= +// 跨平台检测 / 编码 / 跳转 +// ============================================================= + +/** + * 平台检测 — 4 个目标平台: + * - 'h5' 浏览器 + * - 'mp-weixin' 微信小程序 + * - 'mp-harmony' 鸿蒙元服务 + * - 'quickapp' 华为快应用 (实际是 H5 嵌入快应用 Webview) + * + * 测试覆盖: util.js 在 import 后,可在 default export 上设 __platform 强制指定 + */ +export function getPlatform() { + // 1) 测试覆盖: 通过模块级 _testPlatform 注入 + if (typeof globalThis !== 'undefined' && globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__) { + return globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__; + } + + // 2) 编译时变量 + try { /* #ifdef H5 */ return 'h5'; /* #endif */ } catch (e) { /* ignore */ } + try { /* #ifdef MP-WEIXIN */ return 'mp-weixin'; /* #endif */ } catch (e) { /* ignore */ } + try { /* #ifdef MP-HARMONY */ return 'mp-harmony'; /* #endif */ } catch (e) { /* ignore */ } + try { /* #ifdef MP-QUICKAPP */ return 'quickapp'; /* #endif */ } catch (e) { /* ignore */ } + + // 3) 运行时 + if (typeof process !== 'undefined' && process.env && process.env.UNI_PLATFORM) { + return process.env.UNI_PLATFORM; + } + if (typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof uni === 'undefined') { + return 'h5'; + } + if (typeof uni !== 'undefined' && typeof uni.getSystemInfoSync === 'function') { + try { + const info = uni.getSystemInfoSync(); + const p = (info && info.platform) || ''; + if (/harmony|ohos/i.test(p)) return 'mp-harmony'; + if (info.mp === true || /miniprogram/i.test(info.appName || '')) return 'mp-weixin'; + if (/quickapp|huawei/i.test(p)) return 'quickapp'; + return 'h5'; + } catch (e) { /* ignore */ } + } + return 'h5'; +} + +/** + * 是否需要 JS 侧主动 encodeURIComponent + * 4 平台都需要(true) + */ +export function shouldEncodeUrl() { + return true; +} + +/** + * 跨平台 safeEncode(decode roundtrip + encode 一次) + * + * 4 平台编码行为: + * | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? | + * |-----------|---------------|------------------|------------------| + * | h5 | 浏览器 | 否 | 必须 | + * | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) | + * | mp-harmony| 鸿蒙原生 | 否 | 必须 | + * | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 | + * + * 关键 bug 修复(2026-07-14): + * 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时, + * 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符, + * 导致后续 key(id/uniacid/page_params) 全部丢失。 + * + * @param {*} v + * @returns {string} + */ +export function _safeEncodeForPlatform(v) { + const s = _toParamValueString(v); + + // decode roundtrip: 兼容上游已 encode 过的入参 + let once = s; + try { + if (/%[0-9a-fA-F]{2}/.test(s)) { + once = decodeURIComponent(s); + } + } catch (e) { + once = s; + } + + // 4 平台都主动 encode 一次 + return encodeURIComponent(once); +} + +/** + * 跨平台跳转 API 调度 + * + * @param {Object} args + * @param {string} args.to + * @param {string} [args.mode='navigateTo'] + * @param {string} [args.platform] 不传则自动 getPlatform() + * @returns {{ url: string, mode: string, api: string }} + */ +export function _navigateTo({ to, mode, platform } = {}) { + const p = platform || getPlatform(); + const m = mode || 'navigateTo'; + + // H5: 用 window.location + if (p === 'h5') { + if (typeof window === 'undefined' || typeof window.location === 'undefined') { + console.warn('[navigate] H5 环境下没有 window.location'); + return { url: to, mode: m, api: 'noop' }; + } + try { + if (m === 'reLaunch' || m === 'redirectTo') { + window.location.replace(to); + } else { + window.location.href = to; + } + } catch (e) { + console.warn('[navigate] H5 跳转失败:', e); + } + return { url: to, mode: m, api: 'window.location' }; + } + + // 微信 / 鸿蒙 / 快应用: 走对应的 uni API + // (华为快应用实际是 H5 嵌入快应用 Webview, uni 全局可用) + if (typeof uni === 'undefined') { + console.warn('[navigate] 非 H5 环境下没有 uni 全局'); + return { url: to, mode: m, api: 'noop' }; + } + + let api = 'navigateTo'; + switch (m) { + case 'tabbar': + case 'switchTab': + // 鸿蒙元服务: uni.switchTab 静默失败, 用 reLaunch 兜底 + // (微信 / 快应用: 正常用 uni.switchTab) + if (p === 'mp-harmony') { + uni.reLaunch({ url: to }); + api = 'reLaunch'; + } else { + uni.switchTab({ url: to }); + api = 'switchTab'; + } + break; + case 'redirectTo': + uni.redirectTo({ url: to }); + api = 'redirectTo'; + break; + case 'reLaunch': + uni.reLaunch({ url: to }); + api = 'reLaunch'; + break; + case 'navigateTo': + default: + uni.navigateTo({ url: to }); + api = 'navigateTo'; + break; + } + return { url: to, mode: m, api }; +} + +// ============================================================= +// 兼容: 把所有函数打包到一个对象(用于 util.js 挂载到 default export) +// ============================================================= + +/** + * 把所有跳转相关函数打包到一个对象, util.js 把它挂载到 default export + * 上, 保持 this.$util.cleanParamUrls / this.$util.redirectTo 等 API 兼容 + * + * @param {Object} [opts] + * @param {string} [opts.platform] 强制指定平台(测试用) + * @returns {Object} + */ +export function createNavigateApi(opts = {}) { + if (opts.platform) { + // 测试用: 注入到 globalThis,getPlatform 会优先读 + if (typeof globalThis !== 'undefined') { + globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = opts.platform; + } + } + return { + getPlatform, + shouldEncodeUrl, + _safeEncodeForPlatform, + _navigateTo, + cleanParamUrls, + normalizeUrl, + appendQueryParams, + parsePageParams, + resolveLinkUrl, + decodeEntity, + }; +} diff --git a/common/js/util-tabbar.js b/common/js/util-tabbar.js new file mode 100644 index 0000000..97b347f --- /dev/null +++ b/common/js/util-tabbar.js @@ -0,0 +1,443 @@ +/** + * 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; +} diff --git a/common/js/util.js b/common/js/util.js index 1978c35..e4cfe12 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -8,62 +8,47 @@ import { } from 'common/js/wx-jssdk.js'; import { copyText } from './uniapp.utils.js'; -// -export function decodeEntity(str, amp) { - const html4Entities = { - nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'ψ', omega: 'ω' - } - amp = amp === undefined ? true : amp - let i = str.indexOf('&') - while (i !== -1) { - const j = str.indexOf(';', i + 3) - let code - if (j === -1) break - if (str[i + 1] === '#') { - code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j)) - if (!isNaN(code)) { - str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1) - } - } else { - code = str.substring(i + 1, j) - if (html4Entities[code] || (code === 'amp' && amp)) { - str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1) - } - } - i = str.indexOf('&', i + 1) - } - return str -} +// 跳转相关工具集(纯函数, 不依赖 util.js 自身, 避免循环依赖) +import { + _toParamValueString, + parsePageParams as _parsePageParams, + decodeEntity, + cleanParamUrls, + normalizeUrl as _normalizeUrlBase, + appendQueryParams as _appendQueryParamsBase, + _shouldEncodeUrl, + _safeEncodeForPlatform, + _navigateTo, +} from './util-navigate.js'; + +// TabBar / 子包路径相关工具集(纯函数, 依赖 util-navigate 的 _toParamValueString) +import { + resolvePageUrl as _resolvePageUrl, + isPageInRouteList as isPageInRouteListFn, + resolveLinkUrl as _resolveLinkUrlPure, + systemTabBarList as _makeSystemTabBarList, + adaptSubpackageUrl, + isTabBarRootPage as _isTabBarRootPagePure, + isCurrentPageTabBarRoot as _isCurrentPageTabBarRootPure, + checkTabBarActive as _checkTabBarActivePure, +} from './util-tabbar.js'; -/** - * 把任意值序列化成可作为 URL 参数的字符串。 - * - null / undefined → '' (由调用方负责过滤空值) - * - 对象 / 数组 → JSON.stringify (避免 String({}) 退化成 "[object Object]") - * - 其它类型(number / boolean / string)→ String(v),保持原行为 - * - * 与消费端约定:page_params 一律以 JSON 字符串形式落 URL, - * 消费侧用 parsePageParams 还原为对象。 - * - * @example - * _toParamValueString({a:1}) // '{"a":1}' - * _toParamValueString('hello') // 'hello' - * _toParamValueString(null) // '' - * _toParamValueString([1, 2]) // '[1,2]' - * - * @param {*} v - * @returns {string} - */ -function _toParamValueString(v) { - if (v === null || v === undefined) return '' - if (typeof v === 'object') return JSON.stringify(v) - return String(v) -} +// ============================================================= +// HTML 实体解码 / 参数序列化 / page_params 解析 +// (直接 re-export util-navigate 的同名函数, 保持 API 兼容) +// ============================================================= + +export { decodeEntity, cleanParamUrls, _toParamValueString }; /** * 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。 * 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。 * + * 注意: 与 util-navigate.js 的 parsePageParams 略有差异 —— 本版本对空串 '' 也返回 defaultValue, + * 保持原 util.js 的旧行为(老代码 `'page_params' in link` 但 page_params === '' 时, + * 旧版会落到 defaultValue, 新版会落到 defaultValue 同样的结果)。 + * * @example * parsePageParams('{"a":1}') // { a: 1 } * parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回) @@ -78,12 +63,7 @@ function _toParamValueString(v) { * @returns {Object} */ export function parsePageParams(raw, defaultValue = {}) { - if (raw == null || raw === '') return defaultValue - if (typeof raw === 'object') return raw - if (typeof raw === 'string') { - try { return JSON.parse(raw) } catch (e) { return defaultValue } - } - return defaultValue + return _parsePageParams(raw, defaultValue); } @@ -318,6 +298,12 @@ export const PAGE_TAB_MAP = { '/pages_tool/files/list': null }; + +// ============================================================= +// 基于 util-tabbar / util-navigate 的本文件 wrapper +// (这些函数都依赖本文件导出的 PAGE_KEY_MAP, 所以要包一层) +// ============================================================= + /** * 根据 page_key + params 解析得到实际跳转URL * @param {string} page_key 页面语义常量,如 PAGE_GOODS_LIST @@ -325,32 +311,7 @@ export const PAGE_TAB_MAP = { * @returns {string|null} 实际URL(已拼接参数)或 null(未匹配) */ export const resolvePageUrl = (page_key, params = {}) => { - if (!page_key || !PAGE_KEY_MAP[page_key]) { - return null; - } - let url = PAGE_KEY_MAP[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; + return _resolvePageUrl(page_key, params, PAGE_KEY_MAP); }; /** @@ -364,53 +325,7 @@ export const resolvePageUrl = (page_key, params = {}) => { * @returns {boolean} 是否命中 */ export const isPageInRouteList = (currentRoute, routeList) => { - 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; - - if (entry.endsWith('/*')) { - const prefix = entry.slice(0, -1); - if (route.startsWith(prefix)) return true; - continue; - } - - if (entry.endsWith('_*') && PAGE_KEY_MAP) { - const keyPrefix = entry.slice(0, -1); - const matchedPaths = []; - for (const k of Object.keys(PAGE_KEY_MAP)) { - if (k.startsWith(keyPrefix) && PAGE_KEY_MAP[k]) { - matchedPaths.push(PAGE_KEY_MAP[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; - } - - if (PAGE_KEY_MAP && PAGE_KEY_MAP[entry]) { - let mapped = PAGE_KEY_MAP[entry]; - mapped = mapped.startsWith('/') ? mapped : '/' + mapped; - if (route === mapped) return true; - continue; - } - - if (entry.startsWith('/')) { - if (route === entry) return true; - continue; - } - } - - return false; + return isPageInRouteListFn(currentRoute, routeList, PAGE_KEY_MAP); }; /** @@ -418,157 +333,35 @@ export const isPageInRouteList = (currentRoute, routeList) => { * 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params; * 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加 * 优先级: page_params > wap_url query (新机制优先) + * * @param {Object} link * @returns {string|null} */ export const resolveLinkUrl = (link) => { if (!link) return null; - // 0. 把 wap_url 中的 query 合并到 page_params - // - 拆分 wap_url 的 query, 与 link.page_params 合并 - // - page_params 优先 (新机制, 显式声明的参数覆盖 wap_url query) - // - 把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 二次编码 - if (link.wap_url) { - const decodedWap = decodeEntity(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,避免分散 try/catch - const pageParams = parsePageParams(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(新机制) + // 0. 先走 util-tabbar 的纯函数(合并 wap_url 的 query 到 page_params) + const after = _resolveLinkUrlPure(link, { + pageKeyMap: PAGE_KEY_MAP, + parsePageParams: parsePageParams, + decodeEntity: decodeEntity, + }); + // 1. 优先使用 page_key(新机制) if (link.page_key) { const params = parsePageParams(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; + // 2. 回退: 旧 wap_url / 外部 http 链接 + return after; }; -// 当前最新的tabBar.list (参见pages.json 中的tabBar.list 配置) -export const systemTabBarList = [ +// 当前最新的 tabBar.list (由 util-tabbar 的 makeSystemTabBarList 生成) +export const systemTabBarList = _makeSystemTabBarList( INDEX_PAGE_URL, CATEGORY_PAGE_URL, CONTACT_PAGE_URL, - MEMBER_PAGE_URL -]; - -/** - * 适配子包路径 - * @param {string} url - * @returns - */ -export const adaptSubpackageUrl = (url) => { - /** - * 特别注意: - * 由于老版本或者后台系统服务未同步更新可以作为tabBarList的最新分包路径。历史遗留问题,需要与当前最新分包机制版本保持一致。 - * 系统当前的支持tarbarList(包含动态tabBar),注意这是实际分包后的路径 - * 根据匹配映射关系,修改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/', - }; - - // 构建正则表达式(加 ^ 锚定前缀,避免路径中间出现 /pages/xxx/ 时误匹配) - 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 部分(避免影响 `https://` 这种 scheme) - // 例如把 `/pages//webview///webview?x=1` 修正成 `/pages//webview//webview?x=1` - const collapsed = url.replace(/([^:])\/{3,}/g, '$1//'); - return replacePrefix(collapsed); -} - -// 归一化任意路径:确保以 '/' 开头,去除 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; -} + MEMBER_PAGE_URL, +); /** * 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等) @@ -577,15 +370,7 @@ function _findMatchingTabUrl(currentPagePath, tabBarList, packageEntryTab) { * @returns {boolean} 如果是 TabBar 根页面返回 true;路径无效/未配置 tabBarList 时返回 false */ export const 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; - }); + return _isTabBarRootPagePure(pagePath, tabBarList); }; /** @@ -595,16 +380,7 @@ export const isTabBarRootPage = (pagePath, tabBarList) => { * @returns {boolean} 当前页是 TabBar 项时返回 true,否则 false(getCurrentPages 不可用时也返回 false) */ export const isCurrentPageTabBarRoot = () => { - try { - const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] - if (!pages || !pages.length) return false - const currentRoute = '/' + String(pages[pages.length - 1].route || '').replace(/^\//, '') - if (!currentRoute) return false - const tabBarList = store && store.state ? store.state.tabBarList : null - return isTabBarRootPage(currentRoute, tabBarList) - } catch (e) { - return false - } + return _isCurrentPageTabBarRootPure({ store }); }; /** @@ -617,55 +393,23 @@ export const isCurrentPageTabBarRoot = () => { * @returns {boolean} 是否激活 */ export const checkTabBarActive = (linkUrl, currentPageRoute, tabBarList, packageEntryTab, currentPageQuery = {}) => { - // 1) 归一化 linkUrl:统一前导 '/' + 子包路径适配 + 去除 query - const normalizedLink = _normalizePath(linkUrl); - const normalizedCurrent = _normalizePath(currentPageRoute); + return _checkTabBarActivePure( + linkUrl, + currentPageRoute, + tabBarList, + packageEntryTab, + currentPageQuery, + { + noTabPages: NO_TAB_PAGES, + pageTabMap: PAGE_TAB_MAP, + }, + ); +}; - if (!normalizedLink) return false; - // 2) 精确匹配:当前页面就是 linkUrl 指向的页面 - if (normalizedLink === normalizedCurrent) return true; - - // 3) 独立流程页:不点亮任何 Tab - if (NO_TAB_PAGES.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 = PAGE_TAB_MAP[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; -} +// ============================================================= +// default export - 保持 this.$util.* 旧 API 完全兼容 +// ============================================================= export default { /** 导出页面URL常量 */ @@ -679,6 +423,12 @@ export default { /** page_key 到路径的映射表(统一管理所有页面) */ PAGE_KEY_MAP, + /** TabBar 业务配置 */ + TAB_BAR_KEYS, + NO_TAB_PAGES, + PAGE_TAB_MAP, + systemTabBarList, + /** 根据 page_key + params 解析实际URL */ resolvePageUrl, @@ -691,6 +441,9 @@ export default { /** 把 page_params 还原成对象(JSON.parse 的安全封装) */ parsePageParams, + /** HTML 实体解码 */ + decodeEntity, + /** * 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段: * - 去除反引号 / 各种空白 / 零宽字符 @@ -701,38 +454,8 @@ export default { * - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤) * * 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖 - * - * @param {*} value 任意值 - * @returns {*} 与输入同结构的清理后值 */ - cleanParamUrls(value) { - // 内部递归函数,通过闭包引用自身,避免 this 绑定问题 - const _clean = (v) => { - if (v === null || v === undefined) return v; - if (typeof v === 'string') { - if (/:\/\//i.test(v)) { - return v - .split('').filter(ch => - ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r', - ).join('') - .replace(/^(https?:\/\/)\/+/i, '$1'); - } - return v; - } - if (Array.isArray(v)) { - return v.map(item => _clean(item)); - } - if (typeof v === 'object') { - const out = {}; - for (const k of Object.keys(v)) { - out[k] = _clean(v[k]); - } - return out; - } - return v; - }; - return _clean(value); - }, + cleanParamUrls, /** 判断给定路径是否是 TabBar 根页面(用于动态控制返回按钮显示) */ isTabBarRootPage, @@ -740,6 +463,28 @@ export default { /** 判断当前页面是否是 TabBar 根页面(供业务组件在弹窗前判断是否需要隐藏 TabBar) */ isCurrentPageTabBarRoot, + /** 检查 tabBar 是否激活(动态 tabBar 高亮判断) */ + checkTabBarActive, + + /** 子包路径前缀映射(/pages/order/ → /pages_order/) */ + adaptSubpackageUrl, + + /** + * URL 规范化(严格规范化已有部分,绝不自动补 https / host) + * @see util-navigate.js#normalizeUrl + */ + normalizeUrl(urlString) { + return _normalizeUrlBase(urlString); + }, + + /** + * 给 URL 追加 query 参数(已存在的 key 会被覆盖) + * @see util-navigate.js#appendQueryParams + */ + appendQueryParams(url, params) { + return _appendQueryParamsBase(url, params); + }, + /** * 页面跳转 * @param {string} to 跳转链接 /pages/idnex/index @@ -777,41 +522,15 @@ export default { merged.page_params = page_params; } - // 3. 参数编码策略,按平台区分: + // 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform) // - 小程序/App: uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码, // 若在 JS 侧再 encodeURIComponent 一次,会被平台再编码一层,目标页 onLoad(options) // 只 decode 一次后仍是编码形态(如 %25E6%25B2%2599%25E5%258F%2591 -> %E6%B2%99%E5%8F%91) // => 这里必须保持原始值,完全交给平台去编码 // - H5: 浏览器不会自动编码 url,若参数含 & ? = 等特殊字符会被 queryString 错位解析 // => 这里仍需手动 encodeURIComponent,decode->encode roundtrip 兼容调用方已编码的入参 - const safeEncode = (v) => { - // 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]" - const s = _toParamValueString(v); - // #ifdef H5 - try { - return encodeURIComponent(decodeURIComponent(s)); - } catch (e) { - return encodeURIComponent(s); - } - // #endif - // #ifndef H5 - // 非 H5:uni.navigateTo / router.pushUrl 内部会再对 url 整体编码一次。 - // 如果调用方已经手动 encode 过 page_params (或上游已经 encode 过的 url 字符串), - // 这里再走一遍就会出现双重编码(形如 %25xx)。 - // 兜底:仅当输入里包含 %xx 这种"看起来是已编码"的模式时,先 decode 一次,让 uni 再 encode 时得到单层编码。 - // 原始字符串(没有 %xx)不受影响,避免误把含 % 的原文吃掉。 - if (/%[0-9a-fA-F]{2}/.test(s)) { - try { - return decodeURIComponent(s); - } catch (e) { - return s; - } - } - return s; - // #endif - }; const queryStr = Object.keys(merged) - .map(k => safeEncode(k) + '=' + safeEncode(merged[k])) + .map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k])) .join('&'); let url = path + (queryStr ? '?' + queryStr : ''); @@ -877,14 +596,14 @@ export default { const cleanUrl = url.split('?')[0]; const isDiyTabRoot = diyTabBar.list.some(item => { if (!item || !item.link || !item.link.wap_url) return false; - const normalizedTab = _normalizePath(item.link.wap_url); - const normalizedUrl = _normalizePath(cleanUrl); + const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0]; + const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0]; return normalizedTab === normalizedUrl; }); if (isDiyTabRoot) { const adaptedUrl = adaptSubpackageUrl(cleanUrl); const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl); - // 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净 + // 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净 let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch; // 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch @@ -1363,7 +1082,7 @@ export default { } else { fallbackFunc && fallbackFunc(); } - // #endif + // #endif }, /** @@ -2087,212 +1806,4 @@ export default { return newObj; }, - /** - * 适用于 UniApp 的全平台 URL 标准化函数 - * @param {string} urlString - 需要标准化的原始 URL 字符串 - * @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 '' - * - * 严格策略(只规范化已有部分,绝不自动补充): - * 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底) - * 2) 不补 https:// —— 如果原串没有协议,保持没有 - * 3) 不补 host —— 如果原串没有 host,保持没有 - * 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序 - * 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 '' - */ - normalizeUrl(urlString) { - if (!urlString || typeof urlString !== 'string') return ''; - - // 1) 预清理: 反引号 + 所有 ASCII 空白 + 中文全角空白 + BOM + 零宽字符 - let url = urlString - .split('').filter(function (ch) { - const code = ch.charCodeAt(0); - if (code <= 0x20) return false; // ASCII 空白 - if (ch === '`') return false; // 反引号 - if (code === 0x3000) return false; // 全角空格 - if (code === 0xFEFF || code === 0x200B || - code === 0x200C || code === 0x200D || - code === 0x2060) return false; // BOM / 零宽 - return true; - }).join(''); - - // 解析协议(可选,允许缺失) - const protocolMatch = url.match(/^(https?):\/\//i); - let protocol = ''; - let rest = url; - if (protocolMatch) { - protocol = protocolMatch[1].toLowerCase() + '://'; - rest = url.substring(protocolMatch[0].length); - // scheme 后不能紧跟 '/',即不允许 https:////host 这种畸形 - if (rest.charAt(0) === '/') { - console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString); - return ''; - } - } else { - // 没有协议 —— 不自动补,只取 path 部分 - rest = url; - } - - // 解析 hash(可选,允许缺失) - const hashIndex = rest.indexOf('#'); - let hash = ''; - let mainPart = rest; - if (hashIndex !== -1) { - hash = rest.substring(hashIndex); - mainPart = rest.substring(0, hashIndex); - } - - // 解析 query(可选,允许缺失) - const queryIndex = mainPart.indexOf('?'); - let query = ''; - let pathPart = mainPart; - if (queryIndex !== -1) { - query = mainPart.substring(queryIndex); // 包含 '?' - pathPart = mainPart.substring(0, queryIndex); - } - - // 解析 host(可选,允许缺失) - let hostname = ''; - let port = ''; - let pathname = pathPart; - if (protocol) { - // 有协议,前一段视为 host(可能为 'host'、'host:port'、'' 三种) - const firstSlash = pathPart.indexOf('/'); - const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash); - pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash); // 保留前导 '/' - if (hostPort) { - const colonIdx = hostPort.lastIndexOf(':'); - if (colonIdx !== -1) { - hostname = hostPort.substring(0, colonIdx); - port = hostPort.substring(colonIdx + 1); - } else { - hostname = hostPort; - } - } - } - // 无协议时,pathPart 整体视为 pathname,host 保持空 - - // 规范化 host - let lowerHostname = ''; - if (hostname) { - lowerHostname = hostname.toLowerCase(); - // 去默认端口 - if ((protocol === 'http://' && port === '80') || - (protocol === 'https://' && port === '443')) { - port = ''; - } - // host 内不允许出现 '/' 或空白(若出现,视为脏数据) - if (/[\/\s]/.test(lowerHostname)) { - console.warn('[normalizeUrl] host 含非法字符:', urlString); - return ''; - } - } - - // 规范化 pathname: 解析 . 和 ..,合并连续 / - let normalizedPath = pathname; - if (normalizedPath) { - const segments = normalizedPath.split('/'); - const resolved = []; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (seg === '' || seg === '.') { - // 保留前导空段(对应开头的 /),其余空段和 '.' 跳过 - if (i === 0) resolved.push(''); - continue; - } - if (seg === '..') { - // 不能跨越最顶层 - if (resolved.length > 1) resolved.pop(); - continue; - } - resolved.push(seg); - } - // 合并连续 '/' 已经在 split/join 中自然完成 - normalizedPath = resolved.join('/') || '/'; - } - - // 规范化 query: 按 key 字母序排序 - let normalizedQuery = ''; - if (query) { - const qs = query.substring(1); // 去 '?' - if (qs) { - const pairs = qs.split('&').map(function (pair) { - const eqIdx = pair.indexOf('='); - return eqIdx === -1 - ? { k: pair, v: '' } - : { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) }; - }); - pairs.sort(function (a, b) { return a.k.localeCompare(b.k); }); - normalizedQuery = '?' + pairs.map(function (p) { return p.k + '=' + p.v; }).join('&'); - } - } - - // 拼回最终 URL(只包含真实存在的部分) - const portStr = port ? ':' + port : ''; - const hostPart = lowerHostname ? lowerHostname + portStr : ''; - const schemeAndHost = protocol + hostPart; // protocol 形如 'https://',hostPart 可能为空 - return schemeAndHost + normalizedPath + normalizedQuery + hash; - }, - - /** - * UniApp 全平台通用的 URL 追加查询参数函数(自动处理中文编码) - * @param {string} url - 原始 URL 字符串 - * @param {object} params - 需要追加的参数对象,例如 { name: '张三', city: '苏州' } - * @returns {string} 追加参数后的完整 URL - */ - appendQueryParams(url, params) { - if (!url || typeof url !== 'string') return ''; - - // ==================== H5 端处理 (利用原生 URL API) ==================== - // #ifdef H5 - try { - let targetUrl = url.trim(); - if (!/^https?:\/\//i.test(targetUrl)) { - targetUrl = 'https://' + targetUrl; - } - const urlObj = new URL(targetUrl); - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - // 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]" - urlObj.searchParams.append(key, _toParamValueString(value)); - } - } - return urlObj.toString(); - } catch (e) { - console.error('H5端 URL 参数追加失败:', e); - return url; - } - // #endif - - // ==================== 非 H5 端处理 (小程序 / App) ==================== - // #ifndef H5 - try { - let targetUrl = url.trim(); - // 分离 hash (#部分),避免干扰参数拼接 - const hashIndex = targetUrl.indexOf('#'); - let hash = ''; - if (hashIndex !== -1) { - hash = targetUrl.substring(hashIndex); - targetUrl = targetUrl.substring(0, hashIndex); - } - - // 判断当前 URL 是否已经包含查询参数,决定使用 ? 还是 & - const separator = targetUrl.includes('?') ? '&' : '?'; - - // 遍历参数对象,拼接参数 - // 小程序/App 端:uni.navigateTo/router.pushUrl 内部会自动对 url 编码, - // 这里不能再 encodeURIComponent,否则会被平台再编码一层导致双重编码 - const queryString = Object.entries(params) - .filter(([_, value]) => value !== undefined && value !== null) - // 对象/数组走 JSON.stringify,避免 String({}) 退化成 "[object Object]" - .map(([key, value]) => `${key}=${_toParamValueString(value)}`) - .join('&'); - - // 重新拼接成完整的 URL - return `${targetUrl}${separator}${queryString}${hash}`; - } catch (e) { - console.error('非H5端 URL 参数追加失败:', e); - return url; - } - // #endif - } } \ No newline at end of file