Files
lucky_shop/common/js/util-navigate.js
ZF sun cbf91fa905 feat(util,navigate): add uniacid stale check for wap url
新增租户uniacid一致性校验逻辑,修复装修外链携带过期租户ID导致的跳转错误问题:
1. 新增pickCurrentUniacid和hasStaleUniacidInUrl工具函数
2. 在链接处理逻辑中校验wap_url内的uniacid与当前系统租户ID是否一致
3. 脏数据场景下强制走外链模式而非内部跳转
4. 补充完整的单元测试覆盖
2026-07-17 11:37:53 +08:00

805 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
/**
* 跨平台 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,
extractInternalAppPath,
parsePageParams,
resolveLinkUrl,
decodeEntity,
};
}