diff --git a/common/js/form-platform-mixin.js b/common/js/form-platform-mixin.js new file mode 100644 index 0000000..4e229a3 --- /dev/null +++ b/common/js/form-platform-mixin.js @@ -0,0 +1,105 @@ +/** + * form-platform-mixin.js + * + * 表单组件共用的平台检测 mixin + * + * 集中维护 3 个 computed: + * - platform 当前运行平台 key(与后端 formConfig.terminal 键名对齐) + * - platformAliases 后端键名 → 内部 platform 名 兼容层 + * - platformConfigKeys 当前平台的有效配置键集合(用于从 formConfig.terminal 查找) + * + * 消费方: + * - components/ns-form/ns-form.vue + * - components/ns-newform/ns-newform.vue + * - 任何需要按端读取 formConfig.terminal.{h5|weapp|harmony|app} 的表单组件 + * + * 设计原则: + * 1. 单一真值:避免 ns-form / ns-newform 各自维护导致漂移 + * 2. 纯 computed mixin:不依赖 props/data/methods,便于跨组件复用 + * 3. 不依赖 util.js 等其它模块,避免循环依赖 + * 4. 通过 mixins: [...] 引入,组件侧不再写平台判断代码 + * + * 用法: + * import formPlatformMixin from '@/common/js/form-platform-mixin.js'; + * export default { + * mixins: [formPlatformMixin], + * computed: { + * config() { + * const cur = this.platform; + * const keys = this.platformConfigKeys; + * // ... + * } + * } + * }; + */ + +export default { + computed: { + /** + * 当前运行平台标识(与后端 formConfig.terminal 键名对齐) + * 后端在 formConfig.terminal 下挂的差异化配置键:h5 / weapp / mp-harmony / app + * 这里返回的 key 必须与后端设计器输出的 terminal 字段名一致 + */ + platform() { + // #ifdef H5 + return 'h5'; + // #endif + // #ifdef MP-WEIXIN + return 'weapp'; + // #endif + // #ifdef MP-ALIPAY + return 'mp-alipay'; + // #endif + // #ifdef MP-BAIDU + return 'mp-baidu'; + // #endif + // #ifdef MP-TOUTIAO + return 'mp-toutiao'; + // #endif + // #ifdef APP-PLUS + return 'app'; + // #endif + // #ifdef MP-HARMONY + return 'harmony'; + // #endif + // #ifdef QUICKAPP-WEBVIEW + return 'quickapp'; + // #endif + return 'h5'; + }, + /** + * 后端键名 → 内部 platform 名 兼容层 + * 不同后端序列化路径可能用 mp-weixin / wechat / mp-harmony 等不同的 key, + * 在这里统一映射到内部 platform 名,避免 config() 里到处写 if/else。 + */ + platformAliases() { + return { + 'h5': 'h5', + 'weapp': 'weapp', + 'mp-weixin': 'weapp', + 'wechat': 'weapp', + 'harmony': 'harmony', + 'app': 'app', + 'app-plus': 'app' + }; + }, + /** + * 当前平台的有效配置键集合 + * 用于 config() 中查找 formConfig.terminal 下的平台差异化配置: + * - 后端格式:formConfig.terminal = { h5: {...}, weapp: {...}, harmony: {...} } + * - 旧式扁平:formConfig[platformKey] = { ... } + * 返回所有映射到当前 platform 的后端键名,方便两种结构都能命中。 + */ + platformConfigKeys() { + const cur = this.platform; + const aliases = this.platformAliases; + const keys = []; + Object.keys(aliases).forEach(k => { + if (aliases[k] === cur) keys.push(k); + }); + // 加入当前平台名(兼容 formConfig[platform] 这种扁平写法) + if (keys.indexOf(cur) === -1) keys.push(cur); + return keys; + } + } +}; \ No newline at end of file diff --git a/common/js/util.js b/common/js/util.js index ad5f068..465498a 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -1,1930 +1,1993 @@ -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); -} - - -// 关于页面路径,使用常量导出 -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 根路径) -// 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, - - /** 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 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。 - // - // 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) - // - 小程序/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 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 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 - 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) { - 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; - }, - +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); +} + + +// 关于页面路径,使用常量导出 +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 根路径) +// 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, + + /** 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 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。 + // + // 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) + // - 小程序/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 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 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 + 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) { + 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); + }, + } \ No newline at end of file diff --git a/components/ns-form/ns-form.vue b/components/ns-form/ns-form.vue index ca053f6..7f101f1 100644 --- a/components/ns-form/ns-form.vue +++ b/components/ns-form/ns-form.vue @@ -223,8 +223,11 @@