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'; // 跳转相关工具集(纯函数, 不依赖 util.js 自身, 避免循环依赖) import { isHarmonyQuickAppWebview, _toParamValueString, parsePageParams as _parsePageParams, decodeEntity, cleanParamUrls, normalizeUrl as _normalizeUrlBase, appendQueryParams as _appendQueryParamsBase, extractInternalAppPath as _extractInternalAppPath, resolveNavMode as _resolveNavMode, hasStaleUniacidInUrl as _hasStaleUniacidInUrl, pickCurrentUniacid as _pickCurrentUniacid, _shouldEncodeUrl, _safeEncodeForPlatform, _navigateTo, } from './util-navigate.js'; // TabBar / 子包路径相关工具集(纯函数, 依赖 util-navigate 的 _toParamValueString) import { resolvePageUrl as _resolvePageUrl, isPageInRouteList as isPageInRouteListFn, resolveLinkUrl as _resolveLinkUrlPure, makeSystemTabBarList as _makeSystemTabBarList, adaptSubpackageUrl, isTabBarRootPage as _isTabBarRootPagePure, isCurrentPageTabBarRoot as _isCurrentPageTabBarRootPure, checkTabBarActive as _checkTabBarActivePure, } from './util-tabbar.js'; // ============================================================= // HTML 实体解码 / 参数序列化 / page_params 解析 // (直接 re-export util-navigate 的同名函数, 保持 API 兼容) // ============================================================= export { decodeEntity, cleanParamUrls, _toParamValueString, adaptSubpackageUrl }; /** * 获取系统当前的 uniacid(运行时单点真值)。 * * 必须【与 common/js/http.js 发送请求时使用的 uniacid 完全一致】(行 168): * data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid * 否则 uniacid 守卫(脏数据判断)和实际网络请求会基于不同的真值, * 产生不一致行为。 * * 真值源: * 1) uni.getStorageSync('uniacid') — 持久化, 由登录 / 初始化写入 * 2) Config.uniacid — 编译期兜底(从 .local.config.js / vue.config.js 注入) * * 0 视为"无 uniacid": hasStaleUniacidInUrl 收到 0 时直接返回 false (不强行判断)。 * * @returns {number} 当前 uniacid; 无法获取时返回 0 */ function _getCurrentUniacid() { let storageVal; try { if (typeof uni !== 'undefined' && uni && typeof uni.getStorageSync === 'function') { storageVal = uni.getStorageSync('uniacid'); } } catch (e) {} let configVal; try { if (Config && Config.uniacid) configVal = Config.uniacid; } catch (e) {} return _pickCurrentUniacid(storageVal, configVal); } /** * 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。 * 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。 * * 注意: 与 util-navigate.js 的 parsePageParams 略有差异 —— 本版本对空串 '' 也返回 defaultValue, * 保持原 util.js 的旧行为(老代码 `'page_params' in link` 但 page_params === '' 时, * 旧版会落到 defaultValue, 新版会落到 defaultValue 同样的结果)。 * * @example * parsePageParams('{"a":1}') // { a: 1 } * parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回) * 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 = {}) { return _parsePageParams(raw, defaultValue); } /** * 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。 * * 设计动机:URL query 中布尔值的合法写法不止一种—— * ?show_home=1 -> '1' -> true * ?show_home=true -> 'true' -> true * ?show_home=true -> true -> true * ?show_home=0 -> '0' -> false * ?show_home=false -> 'false' -> false * ?show_home= -> '' -> false * 未传 -> undefined -> false * * 与裸 `Boolean(x)` 的差异: * Boolean('false') === true (任何非空字符串都是 true) * Boolean('0') === true (同上) * 本函数对这两个值返回 false,更适合 URL 参数解析场景。 * * @param {*} val 任意值 * @returns {boolean} */ export function coerceBool(val) { if (val === true || val === 1) return true; if (typeof val === 'string') { const s = val.trim().toLowerCase(); return s === '1' || s === 'true'; } return false; } // 关于页面路径,使用常量导出 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'; export const AI_AGENT_PAGE_URL = '/pages_tool/ai-agent/index'; // AI 智能体入口页(智能客服是首个 agent) // 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 根路径) // value 为 null 表示"该页明确不点亮任何 Tab" (e.g. 全屏流程页) // 未在此映射中的页面, 走 NO_TAB_PAGES 判断 + 下方 PAGE_TAB_WILDCARDS 通配符 + history-walk 同/跨包策略 export const PAGE_TAB_MAP = { // 首页 '/pages/index/index': TAB_BAR_KEYS.HOME, '/pages/empty/index': TAB_BAR_KEYS.HOME, // 分类 — tab 根页面 (子页面用通配符, 见 PAGE_TAB_WILDCARDS) '/pages_goods/category': TAB_BAR_KEYS.CATEGORY, '/pages_goods/list': TAB_BAR_KEYS.CATEGORY, '/pages_goods/detail': TAB_BAR_KEYS.CATEGORY, // 入驻企业 — tab 根页面 (子页面用通配符) '/pages_promotion/merch/merchcategory': TAB_BAR_KEYS.MERCHANT, '/pages_promotion/merch/detail': TAB_BAR_KEYS.MERCHANT, // 购物车 (单页) '/pages_goods/cart': TAB_BAR_KEYS.CART, // 我的 (会员中心 tab 根页面; 子页面用通配符) '/pages_tool/member/index': 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, // 注: /pages_tool/index/diy 不在此映射中 // 走 history-walk 自动归属: home → diy → HOME; 深链 → diy → 不点亮 }; // 页面级 TabBar 通配符映射 (按子树批量归属, 减少维护成本) // 匹配规则: pattern 以 '/*' 结尾, '*' 匹配"前缀之后的至少一个字符段" // 优先级: PAGE_TAB_MAP 精确匹配 > PAGE_TAB_WILDCARDS 通配符 // 查找函数: util-tabbar.js#lookupPageTabMap export const PAGE_TAB_WILDCARDS = [ // 我的频道 — /pages_tool/member/* 下所有子页面 (30+ 页压缩为 1 行) { pattern: '/pages_tool/member/*', tab: TAB_BAR_KEYS.MEMBER }, // 商品工具 — /pages_tool/goods/* 下所有子页面 (search/brand/coupon/...) { pattern: '/pages_tool/goods/*', tab: TAB_BAR_KEYS.CATEGORY }, ]; // ============================================================= // 基于 util-tabbar / util-navigate 的本文件 wrapper // (这些函数都依赖本文件导出的 PAGE_KEY_MAP, 所以要包一层) // ============================================================= /** * 根据 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 = {}) => { return _resolvePageUrl(page_key, params, PAGE_KEY_MAP); }; /** * 判断当前路由是否命中 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) => { return isPageInRouteListFn(currentRoute, routeList, PAGE_KEY_MAP); }; /** * 解析 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. 先走 util-tabbar 的纯函数(合并 wap_url 的 query 到 page_params) const after = _resolveLinkUrlPure(link, { pageKeyMap: PAGE_KEY_MAP, parsePageParams: parsePageParams, decodeEntity: decodeEntity, }); // 1. 优先使用 page_key(新机制) if (link.page_key) { const params = parsePageParams(link.page_params); const url = resolvePageUrl(link.page_key, params); if (url) return url; } // 2. 回退: 旧 wap_url / 外部 http 链接 return after; }; // 当前最新的 tabBar.list (由 util-tabbar 的 makeSystemTabBarList 生成) export const systemTabBarList = _makeSystemTabBarList( INDEX_PAGE_URL, CATEGORY_PAGE_URL, CONTACT_PAGE_URL, MEMBER_PAGE_URL, ); /** * 判断给定路径是否是 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) => { return _isTabBarRootPagePure(pagePath, tabBarList); }; /** * 判断**当前**页面是否是 TabBar 根页面 * 封装了 getCurrentPages() + isTabBarRootPage 的组合,供业务组件在弹窗/操作前判断 * 例: 登录弹窗在会员中心页打开时,应当跳过 hideTabBar 的调用 * @returns {boolean} 当前页是 TabBar 项时返回 true,否则 false(getCurrentPages 不可用时也返回 false) */ export const isCurrentPageTabBarRoot = () => { return _isCurrentPageTabBarRootPure({ store }); }; /** * 检查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 = {}) => { return _checkTabBarActivePure( linkUrl, currentPageRoute, tabBarList, packageEntryTab, currentPageQuery, { noTabPages: NO_TAB_PAGES, pageTabMap: PAGE_TAB_MAP, pageTabWildcards: PAGE_TAB_WILDCARDS, }, ); }; // ============================================================= // default export - 保持 this.$util.* 旧 API 完全兼容 // ============================================================= 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, /** TabBar 业务配置 */ TAB_BAR_KEYS, NO_TAB_PAGES, PAGE_TAB_MAP, PAGE_TAB_WILDCARDS, systemTabBarList, isHarmonyQuickAppWebview, /** 根据 page_key + params 解析实际URL */ resolvePageUrl, /** 判断当前路由是否命中 page_routes 列表(支持 PAGE_KEY / 实际路径 / 通配符) */ isPageInRouteList, /** 解析 link 对象得到最终可跳转的 URL(优先级: page_key → wap_url) */ resolveLinkUrl, /** 把 page_params 还原成对象(JSON.parse 的安全封装) */ parsePageParams, /** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */ coerceBool, /** HTML 实体解码 */ decodeEntity, /** * 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段: * - 去除反引号 / 各种空白 / 零宽字符 * - 修复 scheme 后多余斜杠(https:////host → https://host) * * "看起来像 URL" 的判定: 含 `://`(大小写不敏感) * - 命中: 走 cleanUrlString * - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤) * * 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖 */ cleanParamUrls, /** 判断给定路径是否是 TabBar 根页面(用于动态控制返回按钮显示) */ isTabBarRootPage, /** 判断当前页面是否是 TabBar 根页面(供业务组件在弹窗前判断是否需要隐藏 TabBar) */ isCurrentPageTabBarRoot, /** 检查 tabBar 是否激活(动态 tabBar 高亮判断) */ checkTabBarActive, /** 子包路径前缀映射(/pages/order/ → /pages_order/) */ adaptSubpackageUrl, /** * URL 规范化(严格规范化已有部分,绝不自动补 https / host) * @see util-navigate.js#normalizeUrl */ normalizeUrl(urlString) { return _normalizeUrlBase(urlString); }, /** * 给 URL 追加 query 参数(已存在的 key 会被覆盖) * @see util-navigate.js#appendQueryParams */ appendQueryParams(url, params) { return _appendQueryParamsBase(url, params); }, /** * 页面跳转 * @param {string} to 跳转链接 /pages/idnex/index * @param {Object} param 参数 {key : value, ...}, 默认{} * @param {string} mode 模式 navigateTo、redirectTo、reLaunch、tabbar, 默认navigateTo */ redirectTo(to, param = {}, mode = 'navigateTo') { // 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。 // redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。 // // 0. 智能默认 mode (2026-07-23, 修正 2026-07-23): // 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode). // 修复场景: // - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入 // 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面 // (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发 // onShow 死循环跳登录页. // 排除场景 (重要, 避免影响): // - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等), // 必须保留 navigateTo 模式让 back 参数生效. 例如: // refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' }) // - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意. // 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL. if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) { const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?')); if (cleanPath === LOGIN_PAGE_URL) { mode = 'reLaunch'; console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)'); } } // 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. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform) // - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码, // JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的 // 是已编码字符串(典型表现:商品分类 → 商品列表, // 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码) // => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform // 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode) // - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url, // 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当 // 成 top-level 分隔符丢参数) // => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下 // 做 decode roundtrip + encode 一次) const queryStr = Object.keys(merged) .map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(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 isExcludedPage = NO_TAB_PAGES.has(cleanUrl); if (!isExcludedPage) { const isDiyTabRoot = diyTabBar.list.some(item => { if (!item || !item.link || !item.link.wap_url) return false; const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0]; const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0]; return normalizedTab === normalizedUrl; }); if (isDiyTabRoot) { const adaptedUrl = adaptSubpackageUrl(cleanUrl); const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl); // 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净 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 // H5 平台修复 (2026-07-23): // uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合, // 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 / // Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致 // 注销/退出登录后用户能看到旧会员信息。 // location.replace 不产生新历史条目, 直接替换当前条目, // 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。 // #ifdef H5 if (typeof window !== 'undefined' && typeof window.location !== 'undefined' && typeof window.location.replace === 'function') { window.location.replace(url); return; } // #endif // 小程序平台修复 (2026-07-23): // 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误: // - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空) // - 异步回调/onLoad 中调 reLaunch (上下文丢失) // 解决方案: // 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发) // 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch // #ifdef MP if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) { console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url); return; } globalThis.__reLaunchLock = true; setTimeout(() => { uni.reLaunch({ url }); // 300ms 后释放锁 setTimeout(() => { globalThis.__reLaunchLock = false; }, 300); }, 0); return; // #endif // APP / 其他平台: 原生 reLaunch 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() { let info = {}; try { info = uni.getDeviceInfo(); } catch (e) { try { info = uni.getSystemInfoSync(); } catch (e) {} } return info; }, /** * 获取窗口信息(包含降级处理) */ getWindowInfo() { let info; try {info = uni.getWindowInfo();} catch (e) { try {info = uni.getSystemInfoSync();} catch (e) {} } return info; }, /** * 深度拷贝对象 * @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) { // ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ---------- // 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。 if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) { const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu'; const title = link.title || 'AI 智能体'; // 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar // (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu) const dyuEntry = (link.page_params && link.page_params.__dyu) || ''; return this.redirectTo(AI_AGENT_PAGE_URL, { agent_id: agentId, title: encodeURIComponent(title), enable_set_title: link.enable_set_title ? 1 : 0, ...(dyuEntry ? { __dyu: dyuEntry } : {}), }); } 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); } // 修复: wap_url 自带的 uniacid query 必须与系统当前 uniacid 一致 // 原因: uniacid 是本平台租户 ID,会随租户迁移 / 平台调整而变化。 // 装修数据里的 wap_url?uniacid=2482 一旦与系统当前 uniacid(比如 2483)不一致, // 说明这份数据可能是历史遗留或别的租户的(脏数据), // 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。 // 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。 // ★ 必须在 resolveLinkUrl 之前判断 —— 否则 URL 的 query 被抽走就分不清了。 // ★ open_mode=internal 时不覆盖 —— 用户显式强制内部, 表示"我知道我在做什么"。 if (_hasStaleUniacidInUrl(link.wap_url, _getCurrentUniacid()) && _resolveNavMode(link) !== 'internal') { link.open_mode = 'online'; } // 优先用 page_key 解析实际URL(新机制,向后兼容) const resolvedUrl = resolveLinkUrl(link); if (link.page_key && resolvedUrl) { link.wap_url = resolvedUrl; } // 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata) // 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接": // - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失 // - MP 走 web-view → 本应原生跳转的内部页被塞进 web-view // 这里把"指向本 App 内部页面"的绝对 URL 还原为相对路径,使其改走下方内部页面分支(SPA 跳转)。 // // 由 open_mode 统一控制(顶层字段 或 page_params): // - 'auto'(默认): 双闸门 —— host 与本 App 域名【完全一致】且路径带 /pages 段才还原。 // 不同子域/不同域名一律当外链,彻底避免误伤 Shopify、Facebook 等外站。 // - 'internal': 强制内部 —— 跳过 host 校验, 只要路径带 /pages 段就还原为 SPA 跳转。 // 用途: 数据里链接域名与当前部署域名不同(如 xcx3 vs xcx6)但确认是同一 App 内部页。 // - 'online': 强制线上 —— 不还原, 保持整页刷新 / web-view。 // 用途: 某些自域名页面是非 SPA / 遗留页,必须整页或 web-view 打开。 if (link.wap_url && /^https?:\/\//i.test(link.wap_url)) { const navMode = _resolveNavMode(link); // 'internal' | 'online' | 'auto' if (navMode !== 'online') { let internalPath; if (navMode === 'internal') { // 强制内部: 传 true 跳过 host 校验 internalPath = _extractInternalAppPath(link.wap_url, true); } else { // auto: host 完全一致才还原 const appHosts = []; // #ifdef H5 try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) { } // #endif try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) { } try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) { } internalPath = _extractInternalAppPath(link.wap_url, appHosts); } if (internalPath) { link.wap_url = internalPath; } } } // 外部链接 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() { 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} 返回实际复制的文本 */ // #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组件中,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; }, // #ifdef H5 /** * H5 → 原生(快应用 webview)通信:发送一条结构化 postMessage 给宿主 * 与 stub-app-projects/mp-quickapp 中 onMessage 收信协议一一对应: * 原生识别 payload.type(payload.action / payload.cmd 兼容)值: * 'reload' / 'pullRefresh' / 'refresh' → 触发 webview.reload() * @param {string} cmd 指令名,如 'reload' / 'share' / 'close' * @param {Object} [extra] 附加字段,会合并进消息体(不可覆盖 type/source/t) * @returns {boolean} 是否成功投递(非快应用环境会直接返回 false,但不抛错) */ notifyNativeHost(cmd, extra) { try { if (!cmd || typeof cmd !== 'string') return false; if (!this.isQuickApp()) return false; var reserved = { type: cmd, source: 'h5', t: Date.now() }; var data = reserved; if (extra && typeof extra === 'object') { for (var k in extra) { if (Object.prototype.hasOwnProperty.call(extra, k) && !(k in reserved)) { data[k] = extra[k]; } } } var payload = JSON.stringify(data); // 主通道:window.parent.postMessage try { if (window.parent && window.parent !== window && typeof window.parent.postMessage === 'function') { window.parent.postMessage(payload, '*'); } } catch (parentErr) { console.error('[notifyNativeHost] parent postMessage failed:', parentErr); } // 兜底通道:在同窗口内派发 message 事件,部分宿主通过该路径收信 try { window.dispatchEvent(new MessageEvent('message', { data: payload })); } catch (evtErr) { console.error('[notifyNativeHost] dispatchEvent failed:', evtErr); } return true; } catch (e) { console.error('[notifyNativeHost] crashed:', e); return false; } }, // #endif // #ifdef H5 /** * 通知快应用宿主重新加载当前 webview(对应下拉刷新等场景) * 对应原生侧 hello.ux 的 onMessage 处理 'reload' 指令 * @returns {boolean} */ requestQuickAppReload() { return this.notifyNativeHost('reload', { source: 'pullRefresh' }); }, // #endif /** * 蛇形命名转驼峰命名 * @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; }, /** * rpx 单位按端适配转换(uni-app 标准约定) * * 后端 formConfig / diyview 等接口返回的数值字段(label_font_size / input_height / * row_min_height 等)一律以 rpx 传递。前端处理: * - 微信小程序 / App / 鸿蒙元服务:原生支持 rpx,直接返回 `${val}rpx` * - H5(WebView):浏览器不认 rpx,通过 uni.upx2px 转 px 返回 `${uni.upx2px(val)}px` * * 主要用于 JS 动态生成的 CSS 值(computed 里的 inline style、CSS 变量赋值)。 * Vue 的 :style binding 对普通 CSS 属性会自动转 rpx→px,但对 CSS 自定义属性 * (以 -- 开头的变量)不会做单位转换,因此必须经此方法处理。 * * @param {number|string} val 后端返回的 rpx 数值 * 可以是数字(如 60)、字符串数字('60')、带 px 后缀的字符串('30px',原样返回) * 或带 rpx 后缀的字符串('60rpx',先剥后缀再处理) * @returns {string} 带单位的 CSS 值(H5 返回 'Npx',其它端返回 'Nrpx'); * val 为空/undefined 时返回 '' */ rpx(val) { if (val === undefined || val === null || val === '') return ''; // 已带 px 后缀的,原样返回 if (typeof val === 'string' && /px$/i.test(val.trim())) return val.trim(); // 已带 rpx 后缀的,剥掉(避免 '60rpx' + 'rpx' → '60rpxrpx' 的双重后缀 bug) if (typeof val === 'string' && /rpx$/i.test(val.trim())) { val = parseFloat(val); } // #ifdef H5 if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') { return uni.upx2px(Number(val) || 0) + 'px'; } return (Number(val) || 0) / 2 + 'px'; // #endif // #ifndef H5 return val + 'rpx'; // #endif }, /** * 计算最长 label 宽度(rpx 单位) * * 与后端 backend/src/app/component/view/form/js/design.js calculateMaxLabelWidth * 算法对齐:取所有 title 中最长字符数(必填 +1),按 fontSize(rpx)近似换算宽度。 * 后端在 H5 中按 1rpx = 0.5px 计算宽度,再加 12 边距。 * * @param {Array} formData 表单数据数组(item.value.title 存在即参与计算) * @param {number|string} labelFontSize 标签字号(rpx) * @returns {number} 最长 label 宽度,单位 rpx */ calculateMaxLabelWidth(formData, labelFontSize) { if (!formData || formData.length === 0) return 0; const fontSize = parseInt(labelFontSize) || 28; let maxChars = 0; formData.forEach(item => { if (!item.value || !item.value.title) return; let len = (item.value.title || '').length; if (item.value.required) len += 1; if (len > maxChars) maxChars = len; }); // chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2) const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12); return Math.round(widthPx * 2); }, }