新增租户uniacid一致性校验逻辑,修复装修外链携带过期租户ID导致的跳转错误问题: 1. 新增pickCurrentUniacid和hasStaleUniacidInUrl工具函数 2. 在链接处理逻辑中校验wap_url内的uniacid与当前系统租户ID是否一致 3. 脏数据场景下强制走外链模式而非内部跳转 4. 补充完整的单元测试覆盖
805 lines
28 KiB
JavaScript
805 lines
28 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|
||
}
|