1. add cleanParamUrls recursive URL sanitization util 2. add URL cleanup to diyRedirectTo as final defense 3. refactor redirectTo to avoid duplicate cleanup 4. add comprehensive unit tests for all new functionality
2298 lines
74 KiB
JavaScript
2298 lines
74 KiB
JavaScript
import Config from './config.js'
|
||
import store from '@/store/index.js'
|
||
import Http from './http.js'
|
||
import Platform from './platform.js'
|
||
import Lang from './lang.js'
|
||
import {
|
||
Weixin
|
||
} 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
|
||
}
|
||
|
||
|
||
/**
|
||
* 把任意值序列化成可作为 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)
|
||
}
|
||
|
||
/**
|
||
* 把 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 page_params 原始值(可能是 object / string / null / undefined)
|
||
* @param {Object} [defaultValue={}] 解析失败时的兜底值
|
||
* @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
|
||
}
|
||
|
||
|
||
// 关于页面路径,使用常量导出
|
||
export const INDEX_PAGE_URL = '/pages/index/index';
|
||
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
||
export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
|
||
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
||
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
||
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
||
|
||
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||
export const PAGE_KEY_MAP = {
|
||
// === 主页 & 商品 ===
|
||
PAGE_INDEX: '/pages/index/index',
|
||
PAGE_GOODS_LIST: '/pages_goods/list',
|
||
PAGE_GOODS_CATEGORY: '/pages_goods/category',
|
||
PAGE_GOODS_DETAIL: '/pages_goods/detail',
|
||
PAGE_GOODS_CART: '/pages_goods/cart',
|
||
PAGE_GOODS_COUPON: '/pages_tool/goods/coupon',
|
||
PAGE_GOODS_BRAND: '/pages_tool/goods/brand',
|
||
|
||
// === 订单 ===
|
||
PAGE_ORDER_LIST: '/pages/order/list',
|
||
PAGE_ORDER_DETAIL: '/pages/order/detail',
|
||
PAGE_ORDER_CREATE: '/pages/order/create',
|
||
PAGE_ORDER_REFUND: '/pages/order/refund',
|
||
PAGE_ORDER_ACTIVIST: '/pages/order/activist',
|
||
|
||
// === 会员中心 ===
|
||
PAGE_MEMBER_INDEX: '/pages_tool/member/index',
|
||
PAGE_MEMBER_INFO: '/pages_tool/member/info',
|
||
PAGE_MEMBER_ACCOUNT: '/pages_tool/member/account',
|
||
PAGE_MEMBER_COLLECTION: '/pages_tool/member/collection',
|
||
PAGE_MEMBER_FOOTPRINT: '/pages_tool/member/footprint',
|
||
PAGE_MEMBER_COUPON: '/pages_tool/member/coupon',
|
||
PAGE_MEMBER_BALANCE: '/pages_tool/member/balance_detail',
|
||
PAGE_MEMBER_ADDRESS: '/pages_tool/member/address',
|
||
PAGE_MEMBER_LEVEL: '/pages_tool/member/level',
|
||
PAGE_MEMBER_SIGNIN: '/pages_tool/member/signin',
|
||
PAGE_MEMBER_CARD: '/pages_tool/member/card',
|
||
PAGE_MEMBER_CONTACT: '/pages_tool/member/contact',
|
||
|
||
// === 公告/文章/帮助 ===
|
||
PAGE_NOTICE_LIST: '/pages_tool/notice/list',
|
||
PAGE_ARTICLE_LIST: '/pages_tool/article/list',
|
||
PAGE_ARTICLE_DETAIL: '/pages_tool/article/detail',
|
||
PAGE_HELP_LIST: '/pages_tool/help/list',
|
||
|
||
// === 积分商城 ===
|
||
PAGE_POINT_LIST: '/pages_promotion/point/list',
|
||
PAGE_POINT_ORDER_LIST: '/pages_promotion/point/order_list',
|
||
|
||
// === 分销 ===
|
||
PAGE_FENXIAO_INDEX: '/pages_promotion/fenxiao/index',
|
||
PAGE_FENXIAO_WITHDRAW: '/pages_promotion/fenxiao/withdraw_list',
|
||
PAGE_FENXIAO_ORDER: '/pages_promotion/fenxiao/order',
|
||
PAGE_FENXIAO_TEAM: '/pages_promotion/fenxiao/team',
|
||
PAGE_FENXIAO_PROMOTE: '/pages_promotion/fenxiao/promote_code',
|
||
|
||
// === 商户 ===
|
||
PAGE_MERCH_CATEGORY: '/pages_promotion/merch/merchcategory',
|
||
|
||
// === 案例展示 ===
|
||
PAGE_CASES_INDEX: '/pages_tool/cases/index',
|
||
|
||
// === 电子名片 ===
|
||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||
|
||
// === VR ===
|
||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||
|
||
// === 介质材料/结构选型 ===
|
||
PAGE_SEAL_MEDIUM: '/pages_tool/seal/medium/search',
|
||
PAGE_SEAL_STRUCTURE: '/pages_tool/seal/structure',
|
||
|
||
// === 充值/核销 ===
|
||
PAGE_RECHARGE_LIST: '/pages_tool/recharge/list',
|
||
PAGE_VERIFICATION_INDEX: '/pages_tool/verification/index',
|
||
|
||
// === 登录/联系 ===
|
||
PAGE_LOGIN: '/pages_tool/login/login',
|
||
PAGE_REGISTER: '/pages_tool/login/register',
|
||
PAGE_CONTACT: '/pages_tool/contact/contact',
|
||
|
||
// === 文件/微页面 ===
|
||
PAGE_FILES_LIST: '/pages_tool/files/list',
|
||
PAGE_FILES_DETAIL: '/pages_tool/files/detail',
|
||
PAGE_DIY_VIEW: '/pages_tool/index/diy',
|
||
|
||
// === 其他 ===
|
||
PAGE_AI_CHAT: '/pages_tool/ai-chat/index',
|
||
PAGE_WEBVIEW: '/pages_tool/webview/webview'
|
||
};
|
||
|
||
// ================== TabBar 高亮映射配置 ==================
|
||
|
||
// TabBar 标识(使用各 Tab 根页面的 wap_url)
|
||
export const TAB_BAR_KEYS = {
|
||
HOME: '/pages/index/index',
|
||
CATEGORY: '/pages_goods/category',
|
||
MERCHANT: '/pages_promotion/merch/merchcategory',
|
||
CART: '/pages_goods/cart',
|
||
MEMBER: '/pages_tool/member/index'
|
||
};
|
||
|
||
// 明确不归属任何 TabBar 的独立页面:进入这些页面时 TabBar 全不亮
|
||
export const NO_TAB_PAGES = new Set([
|
||
// 登录/注册/找回密码
|
||
'/pages_tool/login/login',
|
||
'/pages_tool/login/register',
|
||
'/pages_tool/login/find',
|
||
|
||
// 支付/收银流程
|
||
'/pages_tool/pay/index',
|
||
'/pages_tool/pay/wx_pay',
|
||
'/pages_tool/pay/cashier',
|
||
'/pages_tool/pay/result',
|
||
|
||
// 订单独立流程页(详情/退款/物流/评价)
|
||
'/pages_order/payment',
|
||
'/pages_order/detail',
|
||
'/pages_order/detail_local_delivery',
|
||
'/pages_order/detail_pickup',
|
||
'/pages_order/detail_point',
|
||
'/pages_order/refund',
|
||
'/pages_order/refund_detail',
|
||
'/pages_order/refund_goods_select',
|
||
'/pages_order/refund_type_select',
|
||
'/pages_order/refund_batch',
|
||
'/pages_order/logistics',
|
||
'/pages_order/activist',
|
||
'/pages_order/order/evaluate',
|
||
'/pages_order/detail_virtual',
|
||
|
||
// 表单
|
||
'/pages_tool/form/form',
|
||
'/pages_tool/form/formdata',
|
||
|
||
// 外部/特殊页面
|
||
'/pages_tool/webview/webview',
|
||
'/pages_tool/ai-chat/index',
|
||
'/pages_tool/vr/index',
|
||
'/pages_tool/cases/index',
|
||
'/pages_tool/contact/contact',
|
||
'/pages_tool/weapp/order_shipping',
|
||
'/pages_tool/file-preview/file-preview',
|
||
|
||
// 帮助/公告/文章详情
|
||
'/pages_tool/help/detail',
|
||
'/pages_tool/notice/detail',
|
||
'/pages_tool/article/detail',
|
||
|
||
// 介质材料
|
||
'/pages_tool/seal/structure',
|
||
'/pages_tool/seal/medium/detail'
|
||
]);
|
||
|
||
// 页面级 TabBar 精确归属映射(key: 页面路径, value: TabBar 根路径)
|
||
// 未在此映射中的页面,优先走 NO_TAB_PAGES 判断,其次走同分包回退逻辑
|
||
export const PAGE_TAB_MAP = {
|
||
// 首页
|
||
'/pages/index/index': TAB_BAR_KEYS.HOME,
|
||
'/pages/empty/index': TAB_BAR_KEYS.HOME,
|
||
|
||
// 分类
|
||
'/pages_goods/category': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_goods/list': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_goods/detail': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/search': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/brand': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/coupon': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/coupon_receive': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/evaluate': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_tool/goods/not_exist': TAB_BAR_KEYS.CATEGORY,
|
||
|
||
// 入驻企业
|
||
'/pages_promotion/merch/merchcategory': TAB_BAR_KEYS.MERCHANT,
|
||
'/pages_promotion/merch/detail': TAB_BAR_KEYS.MERCHANT,
|
||
|
||
// 购物车
|
||
'/pages_goods/cart': TAB_BAR_KEYS.CART,
|
||
|
||
// 我的(会员中心及子页面)
|
||
'/pages_tool/member/index': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/info': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/info_edit': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/address': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/address_edit': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/account': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/account_edit': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/balance': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/balance_detail': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/point': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/point_detail': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/coupon': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/level': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/level_growth_rules': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/card': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/card_buy': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/card_log': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/card_agreement': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/invite_friends': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/collection': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/footprint': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/contact': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/modify_face': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/pay_password': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/apply_withdrawal': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/withdrawal': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/withdrawal_detail': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/cancellation': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/cancelstatus': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/cancelsuccess': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/cancelrefuse': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/assets': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/signin': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/member/bind_mobile': TAB_BAR_KEYS.MEMBER,
|
||
|
||
// 我的频道下的其他功能页
|
||
'/pages_order/list': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/recharge/list': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/recharge/order_list': TAB_BAR_KEYS.MEMBER,
|
||
|
||
// 帮助/公告/文章列表属于首页频道
|
||
'/pages_tool/help/list': TAB_BAR_KEYS.HOME,
|
||
'/pages_tool/notice/list': TAB_BAR_KEYS.HOME,
|
||
'/pages_tool/article/list': TAB_BAR_KEYS.HOME,
|
||
|
||
// 文件列表(视业务可归首页,暂不归任何 Tab)
|
||
'/pages_tool/files/list': null
|
||
};
|
||
|
||
/**
|
||
* 根据 page_key + params 解析得到实际跳转URL
|
||
* @param {string} page_key 页面语义常量,如 PAGE_GOODS_LIST
|
||
* @param {Object} params 页面参数(JSON对象)
|
||
* @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;
|
||
};
|
||
|
||
/**
|
||
* 判断当前路由是否命中 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 数组
|
||
* @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;
|
||
};
|
||
|
||
/**
|
||
* 解析 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 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(新机制)
|
||
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;
|
||
};
|
||
|
||
// 当前最新的tabBar.list (参见pages.json 中的tabBar.list 配置)
|
||
export const systemTabBarList = [
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
|
||
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
|
||
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
|
||
* @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;
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 判断**当前**页面是否是 TabBar 根页面
|
||
* 封装了 getCurrentPages() + isTabBarRootPage 的组合,供业务组件在弹窗/操作前判断
|
||
* 例: 登录弹窗在会员中心页打开时,应当跳过 hideTabBar 的调用
|
||
* @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
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 检查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 来源记忆
|
||
* @returns {boolean} 是否激活
|
||
*/
|
||
export const checkTabBarActive = (linkUrl, currentPageRoute, tabBarList, packageEntryTab, currentPageQuery = {}) => {
|
||
// 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 (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;
|
||
}
|
||
|
||
export default {
|
||
/** 导出页面URL常量 */
|
||
MEMBER_PAGE_URL,
|
||
CATEGORY_PAGE_URL,
|
||
CONTACT_PAGE_URL,
|
||
INDEX_PAGE_URL,
|
||
LOGIN_PAGE_URL,
|
||
AI_CHAT_PAGE_URL,
|
||
|
||
/** page_key 到路径的映射表(统一管理所有页面) */
|
||
PAGE_KEY_MAP,
|
||
|
||
/** 根据 page_key + params 解析实际URL */
|
||
resolvePageUrl,
|
||
|
||
/** 判断当前路由是否命中 page_routes 列表(支持 PAGE_KEY / 实际路径 / 通配符) */
|
||
isPageInRouteList,
|
||
|
||
/** 解析 link 对象得到最终可跳转的 URL(优先级: page_key → wap_url) */
|
||
resolveLinkUrl,
|
||
|
||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||
parsePageParams,
|
||
|
||
/**
|
||
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
|
||
* - 去除反引号 / 各种空白 / 零宽字符
|
||
* - 修复 scheme 后多余斜杠(https:////host → https://host)
|
||
*
|
||
* "看起来像 URL" 的判定: 含 `://`(大小写不敏感)
|
||
* - 命中: 走 cleanUrlString
|
||
* - 不命中: 原样保留(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);
|
||
},
|
||
|
||
/** 判断给定路径是否是 TabBar 根页面(用于动态控制返回按钮显示) */
|
||
isTabBarRootPage,
|
||
|
||
/** 判断当前页面是否是 TabBar 根页面(供业务组件在弹窗前判断是否需要隐藏 TabBar) */
|
||
isCurrentPageTabBarRoot,
|
||
|
||
/**
|
||
* 页面跳转
|
||
* @param {string} to 跳转链接 /pages/idnex/index
|
||
* @param {Object} param 参数 {key : value, ...}, 默认{}
|
||
* @param {string} mode 模式 navigateTo、redirectTo、reLaunch、tabbar, 默认navigateTo
|
||
*/
|
||
redirectTo(to, param = {}, mode = 'navigateTo') {
|
||
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
||
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
||
//
|
||
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
||
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
||
|
||
const queryIdx = to.indexOf('?');
|
||
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
|
||
const existingParams = {};
|
||
|
||
if (queryIdx !== -1) {
|
||
to.substring(queryIdx + 1).split('&').forEach(kv => {
|
||
if (!kv) return;
|
||
// 用 indexOf('=') 取首个等号,兼容 value 里带 '=' 的场景
|
||
const eqIdx = kv.indexOf('=');
|
||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||
if (k) existingParams[k] = v;
|
||
});
|
||
}
|
||
|
||
// 2. 合并 param:同 key 时新值覆盖旧值,杜绝 ?name=A&name=B 这种重复
|
||
// page_params 永远放在 URL 最末:从 param 中抽离后追加到 merged 末尾
|
||
// param 已在函数顶部 0 号步骤做了 URL 清理,这里直接用
|
||
const { page_params, ...restParam } = param;
|
||
const merged = Object.assign({}, existingParams, restParam);
|
||
if (page_params !== undefined) {
|
||
merged.page_params = page_params;
|
||
}
|
||
|
||
// 3. 参数编码策略,按平台区分:
|
||
// - 小程序/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]))
|
||
.join('&');
|
||
|
||
let url = path + (queryStr ? '?' + queryStr : '');
|
||
console.log(`[redirectTo]0 url= ${url}`, {
|
||
to,
|
||
param
|
||
});
|
||
|
||
// 替换url中的前缀
|
||
url = adaptSubpackageUrl(url);
|
||
|
||
console.log('[redirectTo]1 to=', to, 'param=', param, '-> url=', url);
|
||
|
||
// 针对tabBar.list中的路径,直接切换tabBar
|
||
for (let i = 0; i < systemTabBarList.length; i++) {
|
||
const tabBarUrl = systemTabBarList[i];
|
||
if (url.indexOf(tabBarUrl) == 0) {
|
||
// 首页使用switchTab(pages.json中注册的tabBar页面),其他tab页面使用reLaunch
|
||
// reLaunch关闭所有页面再打开目标页,确保页面栈与浏览器历史一致,避免返回按钮异常
|
||
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
|
||
if (tabBarUrl == INDEX_PAGE_URL) {
|
||
// #ifdef MP-HARMONY
|
||
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
|
||
// 用不携带 query 的干净路径, 避免 HarmonyOS 对带 query 的 tabBar 跳转有兼容性问题
|
||
const cleanUrl = url.split('?')[0];
|
||
console.log('[navigate][HARMONY] tabBar INDEX navigate, url=', cleanUrl);
|
||
let navigated = false;
|
||
uni.switchTab({
|
||
url: cleanUrl,
|
||
success: () => { navigated = true; console.log('[navigate][HARMONY] switchTab success:', cleanUrl); },
|
||
fail: (err) => {
|
||
navigated = true;
|
||
console.warn('[navigate][HARMONY] switchTab fail, fallback reLaunch:', cleanUrl, err);
|
||
uni.reLaunch({ url: cleanUrl });
|
||
}
|
||
});
|
||
// 兜底: 500ms 后若仍无回调或未完成, 强制 reLaunch
|
||
// (鸿蒙下 switchTab 经常静默失败, success/fail 都不调用)
|
||
setTimeout(() => {
|
||
if (!navigated) {
|
||
console.warn('[navigate][HARMONY] switchTab silent fail, forcing reLaunch:', cleanUrl);
|
||
uni.reLaunch({ url: cleanUrl });
|
||
}
|
||
}, 500);
|
||
// #endif
|
||
|
||
// #ifndef MP-HARMONY
|
||
uni.switchTab({ url });
|
||
// #endif
|
||
} else {
|
||
uni.reLaunch({ url });
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 动态 diy tabBar:如果目标是 diy_bottom_nav 中的 tab 根页
|
||
// 注意:uni.switchTab 仅对 pages.json 中 tabBar.list 注册的页面有效,
|
||
// 其他 DIY 配置页面调用 switchTab 会静默失败(点击无响应)。
|
||
// 因此使用 uni.redirectTo 替换当前页:不进栈 → 无返回按钮 → 对任意页面都有效。
|
||
const diyTabBar = store.state.tabBarList;
|
||
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
||
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);
|
||
return normalizedTab === normalizedUrl;
|
||
});
|
||
if (isDiyTabRoot) {
|
||
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
|
||
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
|
||
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
|
||
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
|
||
|
||
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
|
||
// #ifdef MP-HARMONY
|
||
navApi = uni.reLaunch;
|
||
// #endif
|
||
|
||
navApi({
|
||
url,
|
||
fail: (err) => {
|
||
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
console.log(`====>mode=${mode}, url=${url}`);
|
||
|
||
// 如果url不包含tabBarList中的路径,根据mode判断跳转方式
|
||
switch (mode) {
|
||
case 'tabbar':
|
||
// 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面。重置页面栈,仅保留 tabBar 页面
|
||
// 鸿蒙元服务环境下 uni.switchTab 静默失败, 改用 reLaunch
|
||
// #ifdef MP-HARMONY
|
||
uni.reLaunch({
|
||
url
|
||
});
|
||
// #endif
|
||
|
||
// #ifndef MP-HARMONY
|
||
uni.switchTab({
|
||
url
|
||
});
|
||
// #endif
|
||
break;
|
||
case 'redirectTo':
|
||
// 关闭当前页面,跳转到应用内的某个页面。替换栈顶页面,长度不变(上限10层)
|
||
uni.redirectTo({
|
||
url
|
||
});
|
||
break;
|
||
case 'reLaunch':
|
||
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
||
uni.reLaunch({
|
||
url
|
||
});
|
||
break;
|
||
case 'navigateTo':
|
||
default:
|
||
// 保留当前页面,跳转到应用内的某个页面,新增页面到栈顶,长度+1(上限10层)
|
||
uni.navigateTo({
|
||
url
|
||
});
|
||
}
|
||
},
|
||
/**
|
||
* 图片路径转换
|
||
* @param {String} img_path 图片地址
|
||
* @param {Object} params 参数,针对商品、相册里面的图片区分大中小,size: big、mid、small
|
||
*/
|
||
img(img_path, params) {
|
||
var path = "";
|
||
if (img_path != undefined && img_path != "") {
|
||
if (img_path.split(',').length > 1) {
|
||
img_path = img_path.split(',')[0];
|
||
}
|
||
if (params && img_path != this.getDefaultImage().goods) {
|
||
// 过滤默认图
|
||
let arr = img_path.split(".");
|
||
let suffix = arr[arr.length - 1];
|
||
arr.pop();
|
||
arr[arr.length - 1] = arr[arr.length - 1] + "_" + params.size.toUpperCase();
|
||
arr.push(suffix);
|
||
// if(img_path.indexOf('attachment') == -1){
|
||
// img_path = arr.join(".");
|
||
// }
|
||
|
||
}
|
||
if (img_path.indexOf("http://") == -1 && img_path.indexOf("https://") == -1) {
|
||
path = Config.imgDomain + "/" + img_path;
|
||
} else {
|
||
// console.log(img_path)
|
||
path = img_path;
|
||
}
|
||
// 修复:去除 URL 中的双斜杠,避免微信 downloadFile 域名匹配失败
|
||
path = path.replace(/([^:]\/)\/+/g, "$1");
|
||
}
|
||
|
||
// path += '?t=' + parseInt(new Date().getTime() / 1000);
|
||
return path;
|
||
},
|
||
/**
|
||
* 时间戳转日期格式
|
||
* @param {Object} timeStamp
|
||
*/
|
||
timeStampTurnTime(timeStamp, type = "") {
|
||
if (timeStamp != undefined && timeStamp != "" && timeStamp > 0) {
|
||
var date = new Date();
|
||
date.setTime(timeStamp * 1000);
|
||
var y = date.getFullYear();
|
||
var m = date.getMonth() + 1;
|
||
m = m < 10 ? ('0' + m) : m;
|
||
var d = date.getDate();
|
||
d = d < 10 ? ('0' + d) : d;
|
||
var h = date.getHours();
|
||
h = h < 10 ? ('0' + h) : h;
|
||
var minute = date.getMinutes();
|
||
var second = date.getSeconds();
|
||
minute = minute < 10 ? ('0' + minute) : minute;
|
||
second = second < 10 ? ('0' + second) : second;
|
||
if (type) {
|
||
if (type == 'yearMonthDay') {
|
||
return y + Lang.lang('util.dateYear') + m + Lang.lang('util.dateMonth') + d + Lang.lang('util.dateDay');
|
||
}
|
||
return y + '-' + m + '-' + d;
|
||
} else {
|
||
return y + '-' + m + '-' + d + ' ' + h + ':' + minute + ':' + second;
|
||
}
|
||
} else {
|
||
return "";
|
||
}
|
||
},
|
||
/**
|
||
* 日期格式转时间戳
|
||
* @param {Object} string
|
||
*/
|
||
timeTurnTimeStamp(string) {
|
||
var f = string.split(' ', 2);
|
||
var d = (f[0] ? f[0] : '').split('-', 3);
|
||
var t = (f[1] ? f[1] : '').split(':', 3);
|
||
return (new Date(
|
||
parseInt(d[0], 10) || null,
|
||
(parseInt(d[1], 10) || 1) - 1,
|
||
parseInt(d[2], 10) || null,
|
||
parseInt(t[0], 10) || null,
|
||
parseInt(t[1], 10) || null,
|
||
parseInt(t[2], 10) || null
|
||
)).getTime() / 1000;
|
||
},
|
||
/**
|
||
* 倒计时
|
||
* @param {Object} seconds 秒
|
||
*/
|
||
countDown(seconds) {
|
||
let [day, hour, minute, second] = [0, 0, 0, 0]
|
||
if (seconds > 0) {
|
||
day = Math.floor(seconds / (60 * 60 * 24))
|
||
hour = Math.floor(seconds / (60 * 60)) - (day * 24)
|
||
minute = Math.floor(seconds / 60) - (day * 24 * 60) - (hour * 60)
|
||
second = Math.floor(seconds) - (day * 24 * 60 * 60) - (hour * 60 * 60) - (minute * 60)
|
||
}
|
||
if (day < 10) {
|
||
day = '0' + day
|
||
}
|
||
if (hour < 10) {
|
||
hour = '0' + hour
|
||
}
|
||
if (minute < 10) {
|
||
minute = '0' + minute
|
||
}
|
||
if (second < 10) {
|
||
second = '0' + second
|
||
}
|
||
return {
|
||
d: day,
|
||
h: hour,
|
||
i: minute,
|
||
s: second
|
||
};
|
||
},
|
||
/**
|
||
* 数值去重
|
||
* @param {Array} arr 数组
|
||
* @param {string} field 字段
|
||
*/
|
||
unique(arr, field) {
|
||
const res = new Map();
|
||
return arr.filter((a) => !res.has(a[field]) && res.set(a[field], 1));
|
||
},
|
||
/**
|
||
* 判断值是否在数组中
|
||
* @param {Object} elem
|
||
* @param {Object} arr
|
||
*/
|
||
inArray: function (elem, arr) {
|
||
return arr == null ? -1 : arr.indexOf(elem);
|
||
},
|
||
/**
|
||
* 获取某天日期
|
||
* @param {Object} day
|
||
*/
|
||
getDay: function (day) {
|
||
var today = new Date();
|
||
var targetday_milliseconds = today.getTime() + 1000 * 60 * 60 * 24 * day;
|
||
today.setTime(targetday_milliseconds);
|
||
|
||
const doHandleMonth = function (month) {
|
||
var m = month;
|
||
if (month.toString().length == 1) {
|
||
m = "0" + month;
|
||
}
|
||
return m
|
||
}
|
||
|
||
var tYear = today.getFullYear();
|
||
var tMonth = today.getMonth();
|
||
var tDate = today.getDate();
|
||
var tWeek = today.getDay();
|
||
var time = parseInt(today.getTime() / 1000);
|
||
tMonth = doHandleMonth(tMonth + 1);
|
||
tDate = doHandleMonth(tDate);
|
||
|
||
const week = [
|
||
Lang.lang('util.weekSunday'),
|
||
Lang.lang('util.weekMonday'),
|
||
Lang.lang('util.weekTuesday'),
|
||
Lang.lang('util.weekWednesday'),
|
||
Lang.lang('util.weekThursday'),
|
||
Lang.lang('util.weekFriday'),
|
||
Lang.lang('util.weekSaturday')
|
||
]
|
||
return {
|
||
't': time,
|
||
'y': tYear,
|
||
'm': tMonth,
|
||
'd': tDate,
|
||
'w': week[tWeek]
|
||
};
|
||
},
|
||
/**
|
||
* 图片选择加上传
|
||
* @param num
|
||
* @param params
|
||
* @param callback
|
||
* @param url
|
||
*/
|
||
upload: function (num, params, callback, url) {
|
||
const { appType, appTypeName } = Platform.getPlatformInfo();
|
||
var data = {
|
||
token: store.state.token,
|
||
app_type: appType,
|
||
app_type_name: appTypeName
|
||
}
|
||
data = Object.assign(data, params);
|
||
|
||
var imgs_num = num;
|
||
var _self = this;
|
||
|
||
uni.chooseImage({
|
||
count: imgs_num,
|
||
sizeType: ['compressed'], //可以指定是原图还是压缩图,默认二者都有
|
||
sourceType: ['album', 'camera'], //从相册或者拍照
|
||
success: async function (res) {
|
||
const tempFilePaths = res.tempFilePaths;
|
||
var _data = data;
|
||
var imgs = [];
|
||
uni.showLoading({
|
||
title: Lang.lang('util.uploading')
|
||
})
|
||
for (var i = 0; i < tempFilePaths.length; i++) {
|
||
var path = await _self.upload_file_server(tempFilePaths[i], _data, params.path,
|
||
url);
|
||
imgs.push(path);
|
||
if (imgs.length == tempFilePaths.length) {
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: Lang.lang('util.uploadSuccess'),
|
||
icon: 'none'
|
||
})
|
||
typeof callback == 'function' && callback(imgs);
|
||
}
|
||
}
|
||
},
|
||
fail: err => {
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: Lang.lang('util.uploadFailed'),
|
||
icon: 'none'
|
||
})
|
||
}
|
||
});
|
||
},
|
||
//上传
|
||
upload_file_server(tempFilePath, data, path, url = "", callback) {
|
||
if (url) {
|
||
var uploadUrl = Config.baseUrl + url
|
||
} else {
|
||
var uploadUrl = Config.baseUrl + '/api/upload/' + path
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
uni.uploadFile({
|
||
url: uploadUrl,
|
||
filePath: tempFilePath,
|
||
name: 'file',
|
||
fileType: data.fileType || 'image',
|
||
formData: data,
|
||
success: function (res) {
|
||
var path_str = JSON.parse(res.data);
|
||
if (path_str.code >= 0) {
|
||
resolve(path_str.data.pic_path);
|
||
typeof callback == 'function' && callback(path_str.data.pic_path);
|
||
} else {
|
||
reject("error");
|
||
}
|
||
}
|
||
});
|
||
|
||
});
|
||
|
||
},
|
||
/**
|
||
* 复制
|
||
* @param {Object} value
|
||
* @param {Object} callback
|
||
*/
|
||
copy(value, callback) {
|
||
// #ifdef H5
|
||
var oInput = document.createElement('input'); //创建一个隐藏input(重要!)
|
||
oInput.value = value; //赋值
|
||
oInput.setAttribute("readonly", "readonly");
|
||
document.body.appendChild(oInput);
|
||
oInput.select(); // 选择对象
|
||
document.execCommand("Copy"); // 执行浏览器复制命令
|
||
oInput.className = 'oInput';
|
||
oInput.style.display = 'none';
|
||
uni.hideKeyboard();
|
||
this.showToast({
|
||
title: Lang.lang('util.copySuccess')
|
||
});
|
||
|
||
typeof callback == 'function' && callback();
|
||
// #endif
|
||
|
||
// #ifdef MP || APP-PLUS
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
typeof callback == 'function' && callback();
|
||
}
|
||
});
|
||
// #endif
|
||
},
|
||
/**
|
||
* 是否是微信浏览器
|
||
* @deprecated 请使用 Platform.isWeiXin()
|
||
*/
|
||
isWeiXin() {
|
||
return Platform.isWeiXin();
|
||
},
|
||
/**
|
||
* 判断是否在微信小程序的web-view中打开H5
|
||
* @returns {boolean} 是否在微信小程序的web-view中
|
||
*
|
||
* 鸿蒙元服务(JSVM)没有 navigator 全局,改用 typeof 守卫兜底,让它直接返回 false
|
||
*/
|
||
isWeChatMiniProgram() {
|
||
if (typeof navigator === 'undefined' || !navigator.userAgent) return false;
|
||
const userAgent = navigator.userAgent.toLowerCase();
|
||
|
||
// 微信小程序的web-view User-Agent会包含以下特征之一
|
||
const miniProgramIndicators = [
|
||
'miniprogram', // 普通微信小程序
|
||
'wxwork', // 企业微信小程序
|
||
'micromessenger/[0-9]+\.[0-9]+\.[0-9]+ nettype/wifi wxwebviewtype/1'
|
||
];
|
||
|
||
return miniProgramIndicators.some(indicator => {
|
||
if (typeof indicator === 'string' && indicator.includes('/')) {
|
||
// 正则表达式匹配
|
||
const regex = new RegExp(indicator, 'i');
|
||
return regex.test(userAgent);
|
||
}
|
||
return userAgent.includes(indicator);
|
||
});
|
||
},
|
||
/**
|
||
* 判断是否在微信浏览器中打开H5(非小程序的web-view)
|
||
* @returns {boolean} 是否在微信浏览器中打开的H5
|
||
*/
|
||
isWeChatBrowser() {
|
||
return isWeiXin() && !isWeChatMiniProgram();
|
||
},
|
||
/**
|
||
* 显示消息提示框
|
||
* @param {Object} params 参数
|
||
*/
|
||
showToast(params = {}) {
|
||
params.title = params.title || "";
|
||
params.icon = params.icon || "none";
|
||
// params.position = params.position || 'bottom';
|
||
params.duration = params.duration || 1500;
|
||
uni.showToast(params);
|
||
if (params.success) params.success();
|
||
},
|
||
/**
|
||
* 检测苹果X以上的手机
|
||
*/
|
||
isIPhoneX() {
|
||
let res = this.getDeviceInfo();
|
||
if (res.model.search('iPhone X') != -1) {
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
//判断安卓还是iOS
|
||
isAndroid() {
|
||
let platform = this.getDeviceInfo().platform;
|
||
if (platform == 'ios') {
|
||
return false;
|
||
} else if (platform == 'android') {
|
||
return true;
|
||
}
|
||
},
|
||
/**
|
||
* 获取设备信息(包含降级处理)
|
||
*/
|
||
getDeviceInfo() {
|
||
try {
|
||
return uni.getDeviceInfo();
|
||
} catch (e) {
|
||
return uni.getSystemInfoSync();
|
||
}
|
||
},
|
||
/**
|
||
* 获取窗口信息(包含降级处理)
|
||
*/
|
||
getWindowInfo() {
|
||
try {
|
||
return uni.getWindowInfo();
|
||
} catch (e) {
|
||
return uni.getSystemInfoSync();
|
||
}
|
||
},
|
||
/**
|
||
* 深度拷贝对象
|
||
* @param {Object} obj
|
||
*/
|
||
deepClone(obj) {
|
||
const isObject = function (obj) {
|
||
return typeof obj == 'object';
|
||
}
|
||
|
||
if (!isObject(obj)) {
|
||
throw new Error(Lang.lang('util.deepCloneNotObject'))
|
||
}
|
||
//判断传进来的是对象还是数组
|
||
let isArray = Array.isArray(obj)
|
||
let cloneObj = isArray ? [] : {}
|
||
//通过for...in来拷贝
|
||
for (let key in obj) {
|
||
cloneObj[key] = isObject(obj[key]) ? this.deepClone(obj[key]) : obj[key]
|
||
}
|
||
return cloneObj
|
||
},
|
||
|
||
|
||
|
||
/**
|
||
* 打开微信企业客服
|
||
* @param {Function} fallbackFunc 降级处理函数
|
||
*/
|
||
openWxWorkServiceChat(fallbackFunc) {
|
||
// #ifdef MP-WEIXIN
|
||
let wxworkConfig = store.state?.wxworkConfig
|
||
if (wxworkConfig?.enable && wxworkConfig?.contact_url) {
|
||
// 直接使用活码链接跳转
|
||
wx.navigateToMiniProgram({
|
||
appId: 'wxeb490c6f9b154ef9', // 是企业微信官方小程序的AppID(固定值, 由腾讯企业微信团队维护, 不需要修改,用于展示"添加企业微信联系人"的官方页面)
|
||
path: `pages/contacts/externalContactDetail?url=${encodeURIComponent(wxworkConfig?.contact_url)}`,
|
||
success: () => {
|
||
},
|
||
fail: (err) => {
|
||
}
|
||
});
|
||
} else {
|
||
fallbackFunc && fallbackFunc();
|
||
}
|
||
// #endif
|
||
},
|
||
|
||
/**
|
||
* 自定义模板的跳转链接
|
||
* @param {Object} link
|
||
*/
|
||
diyRedirectTo(link) {
|
||
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
||
//if (link == null || Object.keys(link).length == 1) return;
|
||
|
||
// 0. 兜底清理 link 里所有"看起来像 URL"的字符串字段
|
||
// 递归遍历,任意 key / 任意嵌套层级都被命中
|
||
// (去除反引号 / 各种空白 / 零宽字符,修复 scheme 后多余斜杠)
|
||
// 这是最后一道防线,防止后台脏数据(如 wap_url 字段被粘贴时带 markdown 反引号)
|
||
// 把脏 URL 一直传到 web-view,导致微信 web-view 解析失败
|
||
link = this.cleanParamUrls(link) || {};
|
||
|
||
if (link.wap_url) {
|
||
link.wap_url = decodeEntity(link.wap_url);
|
||
}
|
||
|
||
// 优先用 page_key 解析实际URL(新机制,向后兼容)
|
||
const resolvedUrl = resolveLinkUrl(link);
|
||
if (link.page_key && resolvedUrl) {
|
||
link.wap_url = resolvedUrl;
|
||
}
|
||
|
||
// 外部链接
|
||
if (link.wap_url && link.wap_url.indexOf('http') != -1) {
|
||
// 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params)
|
||
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||
// page_params 放在最后,确保其在最终 URL 中位于 title/enable_set_title 之后
|
||
const params = Object.assign(
|
||
{},
|
||
{
|
||
title: link?.title ?? '',
|
||
enable_set_title: link?.enable_set_title ? 1 : 0
|
||
},
|
||
pageParams
|
||
);
|
||
let url = this.normalizeUrl(decodeEntity(link.wap_url));
|
||
url = this.appendQueryParams(url, params);
|
||
|
||
// #ifdef H5
|
||
// console.log(`url ---->`, url, link)
|
||
window.location.href = url;
|
||
// #endif
|
||
|
||
// #ifdef MP
|
||
console.log(`外部链接,跳转到webview`, url);
|
||
this.redirectTo(PAGE_KEY_MAP.PAGE_WEBVIEW, {
|
||
...link,
|
||
src: url
|
||
});
|
||
// #endif
|
||
|
||
} else if (link.appid) {
|
||
// 跳转其他小程序
|
||
// page_params 转 query string,拼到 path 后面(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params)
|
||
let path = link.page || '';
|
||
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||
const paramKeys = Object.keys(pageParams);
|
||
if (paramKeys.length > 0) {
|
||
const qs = paramKeys
|
||
.filter(k => pageParams[k] !== undefined && pageParams[k] !== null && pageParams[k] !== '')
|
||
.map(k => `${k}=${pageParams[k]}`)
|
||
.join('&');
|
||
if (qs) {
|
||
path += (path.indexOf('?') !== -1 ? '&' : '?') + qs;
|
||
}
|
||
}
|
||
uni.navigateToMiniProgram({
|
||
appId: link.appid,
|
||
path
|
||
})
|
||
|
||
} else if (link.name == 'MOBILE' && !link.wap_url) {
|
||
// 拨打电话
|
||
uni.makePhoneCall({
|
||
phoneNumber: link.mobile,
|
||
success: (res) => {
|
||
},
|
||
fail: (res) => {
|
||
}
|
||
});
|
||
|
||
} else if (link.name == 'MEMBER_CONTACT') {
|
||
// 客服
|
||
let servicerConfig = store.state.servicerConfig
|
||
let config = {
|
||
type: 'none'
|
||
};
|
||
|
||
// #ifdef H5
|
||
config = servicerConfig.h5;
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
config = servicerConfig.weapp;
|
||
// #endif
|
||
|
||
switch (config.type) {
|
||
case 'wxwork':
|
||
// 企业微信客服
|
||
|
||
// #ifdef H5
|
||
window.location.href = config.wxwork_url;
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
this.openWxWorkServiceChat(() => {
|
||
wx.openCustomerServiceChat({
|
||
extInfo: {
|
||
url: config.wxwork_url
|
||
},
|
||
corpId: config.corpid,
|
||
showMessageCard: true,
|
||
sendMessageTitle: 'this.sendMessageTitle',
|
||
sendMessagePath: 'this.sendMessagePath',
|
||
sendMessageImg: 'this.sendMessageImg'
|
||
});
|
||
});
|
||
|
||
// #endif
|
||
break;
|
||
case 'third':
|
||
// 第三方客服
|
||
window.location.href = config.third_url;
|
||
break;
|
||
case 'niushop':
|
||
// Niushop客服
|
||
this.redirectTo('/pages_tool/chat/room', { ...link });
|
||
break;
|
||
case 'weapp':
|
||
// 微信小程序,由于需要手动点击按钮触发,所以要跳转到中间页
|
||
this.redirectTo(link.wap_url, { ...link });
|
||
break;
|
||
default:
|
||
// 拨打客服电话
|
||
let siteInfo = store.state.siteInfo;
|
||
if (siteInfo && siteInfo.site_tel) {
|
||
uni.makePhoneCall({
|
||
phoneNumber: siteInfo.site_tel
|
||
});
|
||
} else {
|
||
this.showToast({
|
||
title: Lang.lang('customerService.noServiceOffline'),
|
||
})
|
||
}
|
||
}
|
||
|
||
} else if (link.wap_url) { // 微页面/内部页面
|
||
// resolveLinkUrl 已把 wap_url 中的 query 合并到 link.page_params
|
||
// 这里过滤掉 link 元数据字段, 避免 wap_url/name/parent/title 等被错误附加到目标 URL
|
||
// title/enable_set_title/page_params 仍需透传到目标 URL,page_params 放最后
|
||
const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link;
|
||
this.redirectTo(link.wap_url, {
|
||
...navParams,
|
||
...(link.title != null && { title: link.title }),
|
||
...(link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 }),
|
||
...(link.page_params || {})
|
||
});
|
||
}
|
||
},
|
||
/**
|
||
* 获取默认图
|
||
*/
|
||
getDefaultImage() {
|
||
let defaultImg = store.state.defaultImg;
|
||
defaultImg.goods = this.img(defaultImg.goods);
|
||
defaultImg.head = this.img(defaultImg.head);
|
||
defaultImg.store = this.img(defaultImg.store);
|
||
defaultImg.article = this.img(defaultImg.article);
|
||
defaultImg.kefu = this.img(defaultImg.kefu);
|
||
defaultImg.phone = this.img(defaultImg.phone);
|
||
return defaultImg;
|
||
},
|
||
/**
|
||
* 判断手机是否为iphoneX系列
|
||
*/
|
||
uniappIsIPhoneX() {
|
||
let isIphoneX = false;
|
||
let systemInfo = this.getDeviceInfo();
|
||
// #ifdef MP
|
||
if (systemInfo.model.search('iPhone X') != -1 || systemInfo.model.search('iPhone 11') != -1 || systemInfo.model.search('iPhone 12') != -1 || systemInfo.model.search('iPhone 13') != -1) {
|
||
isIphoneX = true;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
var u = navigator.userAgent;
|
||
var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端
|
||
if (isIOS) {
|
||
if (systemInfo.screenWidth == 375 && systemInfo.screenHeight == 812 && systemInfo.pixelRatio == 3) {
|
||
isIphoneX = true;
|
||
} else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 3) {
|
||
isIphoneX = true;
|
||
} else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 2) {
|
||
isIphoneX = true;
|
||
}
|
||
}
|
||
// #endif
|
||
return isIphoneX;
|
||
},
|
||
/**
|
||
* 判断手机是否为iphone11系列
|
||
*/
|
||
uniappIsIPhone11() {
|
||
let isIphone11 = false;
|
||
let systemInfo = this.getDeviceInfo();
|
||
// #ifdef MP
|
||
if (systemInfo.model.search('iPhone 11') != -1) {
|
||
isIphone11 = true;
|
||
}
|
||
// #endif
|
||
return isIphone11;
|
||
},
|
||
// #ifdef H5
|
||
//判断该浏览器是否为safaria浏览器
|
||
isSafari() {
|
||
let res = this.getDeviceInfo();
|
||
var ua = navigator.userAgent.toLowerCase();
|
||
if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 &&
|
||
ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 &&
|
||
ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) {
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
},
|
||
// #endif
|
||
numberFixed(e, f) {
|
||
if (!f) {
|
||
f = 0;
|
||
}
|
||
return Number(e).toFixed(f);
|
||
},
|
||
/**
|
||
* 获取url参数
|
||
*/
|
||
getUrlCode(callback) {
|
||
var url = location.search;
|
||
var theRequest = new Object();
|
||
if (url.indexOf('?') != -1) {
|
||
var str = url.substr(1);
|
||
var strs = str.split('&');
|
||
for (var i = 0; i < strs.length; i++) {
|
||
theRequest[strs[i].split('=')[0]] = strs[i].split('=')[1];
|
||
}
|
||
}
|
||
typeof callback == 'function' && callback(theRequest);
|
||
},
|
||
/**
|
||
* 获取当前页面路由
|
||
*/
|
||
getCurrRoute() {
|
||
let routes = getCurrentPages(); // 获取当前打开过的页面路由数组
|
||
return routes.length ? routes[routes.length - 1].route : '';
|
||
},
|
||
goBack(backUrl = INDEX_PAGE_URL) {
|
||
if (getCurrentPages().length == 1) {
|
||
this.redirectTo(backUrl);
|
||
} else {
|
||
uni.navigateBack();
|
||
}
|
||
},
|
||
/**
|
||
* 隐藏“返回首页/小房子”按钮
|
||
* 这个函数,用到页面show, onshow 的生命周期时
|
||
*/
|
||
hideHomeButton() {
|
||
// #ifdef MP-WEIXIN
|
||
wx.hideHomeButton();
|
||
// #endif
|
||
},
|
||
/**
|
||
*
|
||
* @param val 转化时间字符串 (转化时分秒)
|
||
* @returns {string}
|
||
*/
|
||
getTimeStr(val) {
|
||
var h = parseInt(val / 3600).toString();
|
||
var m = parseInt((val % 3600) / 60).toString();
|
||
if (m.length == 1) {
|
||
m = '0' + m;
|
||
}
|
||
if (h.length == 1) {
|
||
h = '0' + h;
|
||
}
|
||
return h + ':' + m;
|
||
},
|
||
/**
|
||
* 获取定位信息
|
||
*/
|
||
getLocation(param = {}) {
|
||
/*uni.getLocation({
|
||
type: param.type ?? 'gcj02',
|
||
success: res => {
|
||
store.commit('setLocation', res);
|
||
typeof param.success == 'function' && param.success(res);
|
||
},
|
||
fail: res => {
|
||
typeof param.fail == 'function' && param.fail(res);
|
||
},
|
||
complete: res => {
|
||
typeof param.complete == 'function' && param.complete(res);
|
||
}
|
||
});*/
|
||
},
|
||
// 计算两个经纬度之间的距离
|
||
getDistance(lat1, lng1, lat2, lng2) {
|
||
var radLat1 = lat1 * Math.PI / 180.0;
|
||
var radLat2 = lat2 * Math.PI / 180.0;
|
||
var a = radLat1 - radLat2;
|
||
var b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0;
|
||
var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) +
|
||
Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
|
||
s = s * 6378.137; // EARTH_RADIUS;
|
||
s = Math.round(s * 10000) / 10000;
|
||
return s;
|
||
},
|
||
//记录分享人
|
||
onSourceMember(source_member) {
|
||
Http.sendRequest({
|
||
url: '/api/Member/alterShareRelation',
|
||
data: {
|
||
share_member: source_member,
|
||
},
|
||
success: res => {
|
||
if (res.code >= 0) {
|
||
uni.removeStorage({
|
||
key: 'source_member',
|
||
success: res => {
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 微信订阅消息
|
||
*/
|
||
subscribeMessage(keywords) {
|
||
let url = ""
|
||
// #ifdef MP-WEIXIN
|
||
url = '/weapp/api/weapp/messagetmplids';
|
||
// #endif
|
||
// #ifdef MP-ALIPAY
|
||
url = '/aliapp/api/aliapp/messagetmplids';
|
||
// #endif
|
||
Http.sendRequest({
|
||
url,
|
||
data: {
|
||
keywords: keywords
|
||
},
|
||
success: res => {
|
||
if (res.data.length) {
|
||
// #ifdef MP-WEIXIN
|
||
uni.requestSubscribeMessage({
|
||
tmplIds: res.data,
|
||
success: (res) => {
|
||
},
|
||
fail: (res) => {
|
||
}
|
||
})
|
||
// #endif
|
||
|
||
// #ifdef MP-ALIPAY
|
||
my.requestSubscribeMessage({
|
||
entityIds: res.data,
|
||
success: res => {
|
||
},
|
||
fail: res => {
|
||
}
|
||
});
|
||
// #endif
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 获取小程序分享内容数据
|
||
*/
|
||
getMpShare(path) {
|
||
//如果没有特别指定 则获取当前页面的路由
|
||
if (!path) {
|
||
let route = this.getCurrentRoute();
|
||
path = route.path;
|
||
if (path == '/pages_tool/member/index') {
|
||
return new Promise((resolve, reject) => {
|
||
resolve({})
|
||
});
|
||
}
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
Http.sendRequest({
|
||
url: "/weapp/api/weapp/share",
|
||
data: {
|
||
path: path
|
||
},
|
||
success: res => {
|
||
if (res.code >= 0) {
|
||
let shareConfig = res.data.data;
|
||
if (shareConfig) {
|
||
//分享给好友
|
||
let appMessageData = {
|
||
title: shareConfig.title,
|
||
path: shareConfig.path,
|
||
imageUrl: shareConfig.imageUrl,
|
||
success: res => {
|
||
},
|
||
fail: res => {
|
||
}
|
||
}
|
||
//分享到朋友圈
|
||
let query = '';
|
||
if (shareConfig.path.indexOf('?') > 0) {
|
||
query = shareConfig.path.split('?')[1];
|
||
}
|
||
let timeLineData = {
|
||
title: shareConfig.title,
|
||
query: shareConfig.path,
|
||
imageUrl: shareConfig.imageUrl,
|
||
}
|
||
resolve({
|
||
appMessage: appMessageData,
|
||
timeLine: timeLineData,
|
||
})
|
||
} else {
|
||
reject(res.data);
|
||
}
|
||
} else {
|
||
reject(res.data)
|
||
}
|
||
}
|
||
});
|
||
})
|
||
},
|
||
/**
|
||
* 设置公众号分享
|
||
* @param shareData
|
||
* @param callback
|
||
*/
|
||
setPublicShare(shareData, callback) {
|
||
if (!this.isWeiXin()) return;
|
||
Http.sendRequest({
|
||
url: '/wechat/api/wechat/jssdkconfig',
|
||
data: {
|
||
url: uni.getSystemInfoSync().platform == 'ios' ? uni.getStorageSync('initUrl') : window.location
|
||
.href
|
||
},
|
||
success: res => {
|
||
if (res.code == 0) {
|
||
var wxJS = new Weixin();
|
||
wxJS.init(res.data);
|
||
wxJS.weixin.showOptionMenu();
|
||
|
||
wxJS.setShareData({
|
||
title: shareData.title ?? '',
|
||
desc: shareData.desc ?? '',
|
||
link: shareData.link ?? location.href,
|
||
imgUrl: shareData.imgUrl ? this.img(shareData.imgUrl) : ''
|
||
}, (res) => {
|
||
typeof callback == 'function' && callback(res);
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 复制当前页面的小程序路径(含 query)到剪贴板(仅微信小程序)
|
||
* 小程序本身没有"页面 URL"概念,仅可复制形如 pages/goods/detail?id=123 的内部路径,
|
||
* 便于分享者在聊天里粘贴给对方识别。
|
||
* 内部复用 common/js/uniapp.utils.js 导出的 copyText(回调形式),为保持 Promise 一致性包裹一层。
|
||
* @param {Object} options { copySuccess: '', copyFailed: '' }
|
||
* @returns {Promise<String>} 返回实际复制的文本
|
||
*/
|
||
// #ifdef MP-WEIXIN
|
||
copyCurrentPath(options = {}) {
|
||
const { copySuccess = '', copyFailed = '' } = options;
|
||
const route = this.getCurrentRoute();
|
||
// 去掉前导斜杠,保持与转发 path 兼容
|
||
const text = (route && route.path ? route.path : '').replace(/^\//, '');
|
||
return new Promise((resolve, reject) => {
|
||
copyText(text, { copySuccess, copyFailed }, (success, err) => {
|
||
if (success) resolve(text);
|
||
else reject(err || new Error('复制失败'));
|
||
});
|
||
});
|
||
},
|
||
// #endif
|
||
//获取当前路由
|
||
getCurrentRoute() {
|
||
let currentRoutes = getCurrentPages(); // 获取当前打开过的页面路由数组
|
||
let currentRoute = currentRoutes[currentRoutes.length - 1].route //获取当前页面路由
|
||
let currentParam = currentRoutes[currentRoutes.length - 1].options; //获取路由参数
|
||
// 拼接参数
|
||
let param = [];
|
||
for (let key in currentParam) {
|
||
param.push(key + '=' + currentParam[key])
|
||
}
|
||
let currentPath = '/' + currentRoute;
|
||
let currentQuery = param.join('&');
|
||
if (currentQuery) currentPath += '?' + currentQuery;
|
||
|
||
return {
|
||
path: currentPath,
|
||
query: currentQuery,
|
||
}
|
||
},
|
||
//获取分享路由
|
||
getCurrentShareRoute(member_id) {
|
||
let route = this.getCurrentRoute();
|
||
//去掉原来的分享人数据
|
||
route.path = route.path.replace(/[?|&]source_member=\d+/, '');
|
||
if (member_id) {
|
||
//路径的处理
|
||
if (route.path.indexOf('?') > 0) {
|
||
route.path += '&';
|
||
} else {
|
||
route.path += '?';
|
||
}
|
||
route.path += 'source_member=' + member_id;
|
||
//参数的处理
|
||
if (route.query) {
|
||
route.query += '&';
|
||
}
|
||
route.query += 'source_member=' + member_id;
|
||
}
|
||
return route;
|
||
},
|
||
/**
|
||
* 对象转 style 字符串
|
||
* - 自动将 camelCase 转 kebab-case(fontSize → font-size),CSS 自定义属性(以 -- 开头)保持原样
|
||
* - 自动过滤值为 undefined / null / '' 的项,避免输出脏数据
|
||
* - 末尾补一个 ';',方便与其他 style 拼接
|
||
* - 非对象入参一律返回 ''
|
||
* @param {Object} obj
|
||
* @return {String}
|
||
*/
|
||
objToStyle(obj) {
|
||
if (!obj || typeof obj !== 'object') return '';
|
||
const s = [];
|
||
const keys = Object.keys(obj);
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const k = keys[i];
|
||
const v = obj[k];
|
||
if (v === undefined || v === null || v === '') continue;
|
||
// 以 -- 开头的 CSS 自定义属性保持原样;其余 camelCase 转 kebab-case
|
||
const key = k.indexOf('--') === 0 ? k : k.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
||
s.push(key + ':' + v);
|
||
}
|
||
return s.length ? s.join(';') + ';' : '';
|
||
},
|
||
/**
|
||
* 颜色减值
|
||
* @param {Object} c1
|
||
* @param {Object} c2
|
||
* @param {Object} ratio
|
||
*/
|
||
colourBlend(c1, c2, ratio) {
|
||
ratio = Math.max(Math.min(Number(ratio), 1), 0)
|
||
let r1 = parseInt(c1.substring(1, 3), 16)
|
||
let g1 = parseInt(c1.substring(3, 5), 16)
|
||
let b1 = parseInt(c1.substring(5, 7), 16)
|
||
let r2 = parseInt(c2.substring(1, 3), 16)
|
||
let g2 = parseInt(c2.substring(3, 5), 16)
|
||
let b2 = parseInt(c2.substring(5, 7), 16)
|
||
let r = Math.round(r1 * (1 - ratio) + r2 * ratio)
|
||
let g = Math.round(g1 * (1 - ratio) + g2 * ratio)
|
||
let b = Math.round(b1 * (1 - ratio) + b2 * ratio)
|
||
r = ('0' + (r || 0).toString(16)).slice(-2)
|
||
g = ('0' + (g || 0).toString(16)).slice(-2)
|
||
b = ('0' + (b || 0).toString(16)).slice(-2)
|
||
return '#' + r + g + b
|
||
},
|
||
/**
|
||
* 生成贝塞尔曲线轨迹
|
||
* @param {Object} points
|
||
* @param {Object} times
|
||
*/
|
||
bezier(points, times) {
|
||
var bezier_points = [];
|
||
var points_D = [];
|
||
var points_E = [];
|
||
const DIST_AB = Math.sqrt(Math.pow(points[1]['x'] - points[0]['x'], 2) + Math.pow(points[1]['y'] - points[0][
|
||
'y'
|
||
], 2));
|
||
// 邻控制BC点间距
|
||
const DIST_BC = Math.sqrt(Math.pow(points[2]['x'] - points[1]['x'], 2) + Math.pow(points[2]['y'] - points[1][
|
||
'y'
|
||
], 2));
|
||
// D每次在AB方向上移动的距离
|
||
if (points[0]['x'] > points[2]['x']) {
|
||
var EACH_MOVE_AD = -(DIST_AB / times);
|
||
// E每次在BC方向上移动的距离
|
||
var EACH_MOVE_BE = -(DIST_BC / times);
|
||
} else {
|
||
var EACH_MOVE_AD = +(DIST_AB / times);
|
||
// E每次在BC方向上移动的距离
|
||
var EACH_MOVE_BE = +(DIST_BC / times);
|
||
}
|
||
// 点AB的正切
|
||
const TAN_AB = (points[1]['y'] - points[0]['y']) / (points[1]['x'] - points[0]['x']);
|
||
// 点BC的正切
|
||
const TAN_BC = (points[2]['y'] - points[1]['y']) / (points[2]['x'] - points[1]['x']);
|
||
// 点AB的弧度值
|
||
const RADIUS_AB = Math.atan(TAN_AB);
|
||
// 点BC的弧度值
|
||
const RADIUS_BC = Math.atan(TAN_BC);
|
||
// 每次执行
|
||
for (var i = 1; i <= times; i++) {
|
||
// AD的距离
|
||
var dist_AD = EACH_MOVE_AD * i;
|
||
// BE的距离
|
||
var dist_BE = EACH_MOVE_BE * i;
|
||
// D点的坐标
|
||
var point_D = {};
|
||
point_D['x'] = dist_AD * Math.cos(RADIUS_AB) + points[0]['x'];
|
||
point_D['y'] = dist_AD * Math.sin(RADIUS_AB) + points[0]['y'];
|
||
points_D.push(point_D);
|
||
// E点的坐标
|
||
var point_E = {};
|
||
point_E['x'] = dist_BE * Math.cos(RADIUS_BC) + points[1]['x'];
|
||
point_E['y'] = dist_BE * Math.sin(RADIUS_BC) + points[1]['y'];
|
||
points_E.push(point_E);
|
||
// 此时线段DE的正切值
|
||
var tan_DE = (point_E['y'] - point_D['y']) / (point_E['x'] - point_D['x']);
|
||
// tan_DE的弧度值
|
||
var radius_DE = Math.atan(tan_DE);
|
||
// 地市DE的间距
|
||
var dist_DE = Math.sqrt(Math.pow((point_E['x'] - point_D['x']), 2) + Math.pow((point_E['y'] - point_D['y']),
|
||
2));
|
||
// 此时DF的距离
|
||
var dist_DF = (dist_AD / DIST_AB) * dist_DE;
|
||
// 此时DF点的坐标
|
||
var point_F = {};
|
||
point_F['x'] = dist_DF * Math.cos(radius_DE) + point_D['x'];
|
||
point_F['y'] = dist_DF * Math.sin(radius_DE) + point_D['y'];
|
||
bezier_points.push(point_F);
|
||
}
|
||
return {
|
||
'bezier_points': bezier_points
|
||
};
|
||
},
|
||
/**
|
||
* 验证手机号
|
||
* @param {string} mobile 被验证的mobile
|
||
* @return {object} 验证后的结果
|
||
**/
|
||
verifyMobile(mobile) {
|
||
var parse = /^\d{11}$/.test(mobile);
|
||
return parse;
|
||
},
|
||
/**
|
||
* 判断是否为快应用环境
|
||
* @returns {boolean} 是否为快应用
|
||
*/
|
||
isQuickApp() {
|
||
// #ifndef QUICKAPP-WEBVIEW
|
||
let _isQuickApp = false;
|
||
try {
|
||
const ua = navigator?.userAgent?.toLowerCase();
|
||
_isQuickApp = ua.indexOf('quickapp') !== -1;
|
||
|
||
if (!_isQuickApp) {
|
||
// 特殊说明由于网站是嵌入到快应用的Webview组件中<web>,UA中不会包含quickapp字符串,
|
||
// 但是会包含uniacid参数,所以这里通过uniacid参数来判断是否为快应用环境
|
||
_isQuickApp = ua.indexOf('uniacid=') !== -1;
|
||
}
|
||
} catch (e) { }
|
||
|
||
if (!_isQuickApp) {
|
||
const systemInfo = this.getDeviceInfo();
|
||
_isQuickApp = systemInfo?.platform === 'quickapp' || process?.env?.UNI_PLATFORM === 'quickapp';
|
||
}
|
||
return _isQuickApp;
|
||
// #endif
|
||
|
||
return true;
|
||
},
|
||
/**
|
||
* 判断是否为华为快应用环境
|
||
* @returns {boolean} 是否为华为快应用
|
||
*/
|
||
isHuaweiQuickApp() {
|
||
if (!this.isQuickApp()) return false;
|
||
|
||
// #ifndef QUICKAPP-WEBVIEW-HUAWEI
|
||
const systemInfo = this.getDeviceInfo();
|
||
return systemInfo.brand === 'HUAWEI' || systemInfo.manufacturer === 'HUAWEI' ||
|
||
(typeof qh !== 'undefined' && qh.platform === 'huawei');
|
||
// #endif
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* 蛇形命名转驼峰命名
|
||
* @param {string} str 蛇形命名字符串
|
||
* @returns {string} 驼峰命名字符串
|
||
*/
|
||
snakeToCamel(str) {
|
||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||
},
|
||
|
||
/**
|
||
* 蛇形命名转驼峰命名(递归处理对象中的所有属性名)
|
||
* @param {Object} obj 包含蛇形命名字符串属性的对象
|
||
* @returns {Object} 驼峰命名字符串属性的对象
|
||
*/
|
||
snakeToCamelForObj(obj) {
|
||
if (typeof obj !== 'object' || obj === null) return obj;
|
||
if (Array.isArray(obj)) return obj.map(this.snakeToCamelForObj.bind(this));
|
||
const newObj = {};
|
||
for (const key in obj) {
|
||
if (Object.hasOwnProperty.call(obj, key)) {
|
||
const newKey = this.snakeToCamel(key);
|
||
newObj[newKey] = this.snakeToCamelForObj(obj[key]);
|
||
}
|
||
}
|
||
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
|
||
}
|
||
} |