/** * 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; } // ============================================================= // extractInternalAppPath // ============================================================= /** * 从一个 URL 中提取"本 App 内部页面路径"。 * * 背景 / 修复的 bug: * 后台装修数据里,内部页面常被存成【绝对 URL】,例如 * https://host/hwappx/common/pages_tool/form/formdata?id=130 * diyRedirectTo 用 `wap_url.indexOf('http') != -1` 判断"外部链接",于是: * - H5: 走 window.location.href → 整页刷新;点返回时整个 App 重启、启动屏重播、状态丢失 * - MP: 走 web-view → 本应原生跳转的内部页被塞进 web-view * 本函数把这类"指向本 App 内部页面"的绝对 URL 还原成相对路径, * 让 diyRedirectTo 改走内部页面分支(uni.navigateTo / SPA 跳转)。 * * 判定规则(双闸门,避免误伤真正的外部站点): * 闸门 1 —— host 校验(相对路径直接放行): * 绝对 URL 的 host 必须与本 App 自己的域名【完全一致】(忽略端口, 大小写不敏感); * 否则返回 null(视为真外链, 维持原逻辑)。 * ★ 完全一致是最严格的口径: 不同子域(如 xcx3 vs xcx6)、不同域名一律当外链, * 彻底杜绝误伤 Shopify(xxx.myshopify.com/pages/about)、Facebook 等外站。 * 闸门 2 —— 路径校验: * pathname 中出现以 App 页面根开头的段(pages / pages_xxx), * 则从该段起截取为内部路径(以 '/' 开头),连同 query/hash 一起返回; * 未命中返回 null。 * * @example * const H = ['xcx6.aigc-quickapp.com'] * extractInternalAppPath('https://xcx6.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?id=1', H) * // '/pages_tool/form/formdata?id=1' (域名完全一致 → 还原) * extractInternalAppPath('https://xcx3.aigc-quickapp.com/hwappx/common/pages/index/index', H) * // null (子域不同 → 维持外链) * extractInternalAppPath('https://xxx.myshopify.com/pages/about-us', H) * // null (外域名 → 维持外链,不误伤) * extractInternalAppPath('/pages_goods/detail?id=9', H) * // '/pages_goods/detail?id=9' (相对路径 → 内部) * * @param {string} url 绝对或相对 URL * @param {string|string[]|true} [allowedHosts] 本 App 自己的域名(可传完整 URL 或纯 host,会自动提取); * 传 `true` 表示【强制内部】—— 跳过 host 校验, 只要路径含 /pages 段就还原(用于 open_mode='internal')。 * @returns {string|null} 形如 '/pages_tool/form/formdata?id=1' 的内部路径,或 null */ export function extractInternalAppPath(url, allowedHosts) { if (!url || typeof url !== 'string') return null; let rest = url; // 闸门 1: host 校验(完全一致);allowedHosts === true 时跳过(强制内部) const hostMatch = rest.match(/^https?:\/\/([^/]*)/i); if (hostMatch) { if (allowedHosts !== true) { const host = (hostMatch[1] || '').toLowerCase().replace(/:\d+$/, ''); const allow = _normalizeHostList(allowedHosts); if (!host || allow.indexOf(host) === -1) return null; // 域名不完全一致 → 不改 } rest = rest.substring(hostMatch[0].length); } // 闸门 2: 路径校验(找第一个"页面根段" /pages 或 /pages_xxx) const pageMatch = rest.match(/\/pages(?:_[a-z0-9]+)?\//i); if (!pageMatch) return null; const internal = rest.substring(pageMatch.index); return internal || null; } /** * 把 allowedHosts 归一化成纯 host 数组(小写、去协议、去路径、去端口)。 * 入参每一项可以是完整 URL(https://host/xx/)或纯 host(host:port)。 * @param {string|string[]} hosts * @returns {string[]} */ function _normalizeHostList(hosts) { if (!hosts) return []; const arr = Array.isArray(hosts) ? hosts : [hosts]; const out = []; for (const h of arr) { if (!h || typeof h !== 'string') continue; let s = h.trim().toLowerCase(); const m = s.match(/^https?:\/\/([^/]*)/i); if (m) s = m[1] || ''; else s = s.replace(/\/.*$/, ''); // 去掉可能带的 path s = s.replace(/:\d+$/, ''); // 去端口 if (s) out.push(s); } return out; } /** * 解析 link 的导航模式(统一开关)。用于 diyRedirectTo 决定内部页面绝对 URL 如何跳转。 * * 取值来源(顶层字段 或 page_params 里, 大小写不敏感, 兼容驼峰 openMode): * - open_mode = 'internal' | 'spa' → 'internal' 强制走内部 SPA(忽略域名是否一致) * - open_mode = 'online' | 'web' → 'online' 强制走线上(H5 整页刷新 / MP web-view) * - 未设置 / 其他 → 'auto' 自动: 域名完全一致才内部, 否则线上 * * @param {Object} link * @returns {'internal'|'online'|'auto'} */ export function resolveNavMode(link) { if (!link || typeof link !== 'object') return 'auto'; const pp = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {}; const raw = link.open_mode != null ? link.open_mode : (link.openMode != null ? link.openMode : (pp.open_mode != null ? pp.open_mode : pp.openMode)); const m = (typeof raw === 'string') ? raw.trim().toLowerCase() : ''; if (m === 'internal' || m === 'spa') return 'internal'; if (m === 'online' || m === 'web') return 'online'; return 'auto'; } // ============================================================= // hasStaleUniacidInUrl // ============================================================= // pickCurrentUniacid // ============================================================= /** * 从两个候选 uniacid 源中选出"系统当前 uniacid"。 * * 用法(当前 diyRedirectTo 中的实际调用, 必须与 http.js 行 168 保持一致): * pickCurrentUniacid( * uni.getStorageSync('uniacid'), // 持久化, 登录/初始化写入 * Config.uniacid, // 编译期兜底 * ) * * 优先级: 第一个参数非 0 → 选第一个; 否则选第二个非 0 值; 都没有 → 0。 * 0 视为"无 uniacid": 业务上 uniacid 不会是 0, parseInt 后为 0 也算无效。 * * ★ 注意: 故意【不读 store.state.uniacid】。当前代码库里 store.state 上从未设置 * uniacid 字段(http.js 只用 storage/Config), 若引入 store 分支会出现 * "守卫用 A 真值, http 请求用 B 真值"的不一致。 * * @param {*} primaryValue 第一候选(运行时持久化值) * @param {*} secondaryValue 第二候选(编译期兜底) * @returns {number} 选出的 uniacid; 都无效时返回 0 */ export function pickCurrentUniacid(primaryValue, secondaryValue) { const a = parseInt(primaryValue, 10); if (a) return a; // 非 0 真值 const b = parseInt(secondaryValue, 10); if (b) return b; return 0; } // ============================================================= // hasStaleUniacidInUrl // ============================================================= /** * 检测 link.wap_url 中【URL 原生】的 uniacid query 与系统当前 uniacid 是否一致。 * * 用途: uniacid 是平台租户 ID, 会随租户迁移 / 平台调整而变化。 * - 如果数据里的 wap_url 自带的 uniacid 与当前系统不一致, * 说明这份装修数据可能是历史遗留或别的租户的(脏数据), * 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。 * - 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。 * * 关键: 必须【在 resolveLinkUrl 抽走 query 之前】判断, 否则 URL 里的 uniacid 已经 * 被搬到 page_params 里, 分不清"URL 原生"还是"外加的"。 * * 比较规则: 两侧 parseInt 后比较, 兼容 '2482' vs 2482、URL 编码、空值。 * * @param {string} wap_url link.wap_url (resolveLinkUrl 之前的原始值) * @param {string|number|null} currentUniacid 系统当前的 uniacid * (0 / null / '' / undefined 一律视为"无当前 uniacid", 返回 false — 让上游决定怎么做) * @returns {boolean} true = URL 原生 uniacid 与当前不一致(脏数据, 该强制走线上) */ export function hasStaleUniacidInUrl(wap_url, currentUniacid) { if (!wap_url || typeof wap_url !== 'string') return false; if (currentUniacid == null || currentUniacid === '' || currentUniacid === 0) return false; const m = wap_url.match(/[?&]uniacid=([^&#]*)/i); if (!m) return false; let urlUniacid; try { urlUniacid = decodeURIComponent(m[1]); } catch (e) { urlUniacid = m[1]; } const cur = String(currentUniacid); const a = parseInt(urlUniacid, 10); const b = parseInt(cur, 10); // 两侧都能 parse 成数字时按数字比; 否则按字符串比 if (!Number.isNaN(a) && !Number.isNaN(b)) return a !== b; return urlUniacid !== cur; } // ============================================================= // 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) 编译时变量 // #ifdef MP-WEIXIN return 'mp-weixin' // #endif // #ifdef MP-HARMONY return 'mp-harmony' // #endif // 3) 运行时 if (navigator?.userAgent?.includes('mode-quickapp')) { // H5网页,如何判断自己在华为快应用的Webview中 return 'quickapp' } // #ifdef H5 try { var ua = navigator?.userAgent; if (ua) { // 华为快应用内嵌 WebView 的 UA 关键字:HarmonyQuickApp / HuaweiQuickApp / HQABridge / QuickApp const isQuickapp = /HarmonyQuickApp|HuaweiQuickApp|HQABridge|QuickApp/i.test(ua); if (isQuickapp) { return 'quickapp' } } } catch (e) {} // #endif 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'; } /** * 判断是否是在华为快应用的WebView中 * @returns */ export function isHarmonyQuickAppWebview() { return getPlatform() === 'quickapp' } /** * 是否需要 JS 侧主动 encodeURIComponent * 4 平台都需要(true) */ export function shouldEncodeUrl() { return true; } /** * 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样 * * 解决问题: * - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值 * 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏), * 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。 * - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。 * 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。 * - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。 * * @param {string} s 已 decode roundtrip 后的原文 * @returns {string} */ function _encodeUrlDelimiters(s) { // 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样 return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c)); } 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; } const platform = getPlatform(); // 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。 // - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码) // - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断) if (platform === 'mp-weixin') { return _encodeUrlDelimiters(once); } // 鸿蒙元服务: 整体 encode url(包括中文和已编码的 %xx)。 // JS 侧完全不 encode,直接返回 decode roundtrip 后的原文,让鸿蒙原生做唯一一次 encode。 if (platform === 'mp-harmony') { return once; } // H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent) 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, extractInternalAppPath, parsePageParams, resolveLinkUrl, decodeEntity, }; }