Files
lucky_shop/common/js/util.js
ZF sun f3032a6004 fix(util-navigate): 修复多平台搜索关键词编码乱码问题
重构跨平台参数编码逻辑:
1. 微信小程序/鸿蒙元服务:仅处理编码边界符和decode roundtrip,不主动编码,交由平台原生处理
2. H5/华为快应用:保留主动编码逻辑,适配浏览器query解析规则
3. 新增专项测试用例覆盖搜索关键词跨端流转场景,修复商品分类页跳转列表页关键词显示乱码的bug
2026-07-18 08:34:43 +08:00

1998 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
const queryStr = Object.keys(merged)
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&');
let url = path + (queryStr ? '?' + queryStr : '');
console.log(`[redirectTo]0 url= ${url}`, {
to,
param
});
// 替换url中的前缀
url = adaptSubpackageUrl(url);
console.log('[redirectTo]1 to=', to, 'param=', param, '-> url=', url);
// 针对tabBar.list中的路径,直接切换tabBar
for (let i = 0; i < systemTabBarList.length; i++) {
const tabBarUrl = systemTabBarList[i];
if (url.indexOf(tabBarUrl) == 0) {
// 首页使用switchTab(pages.json中注册的tabBar页面),其他tab页面使用reLaunch
// reLaunch关闭所有页面再打开目标页,确保页面栈与浏览器历史一致,避免返回按钮异常
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
if (tabBarUrl == INDEX_PAGE_URL) {
// #ifdef MP-HARMONY
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
// 用不携带 query 的干净路径, 避免 HarmonyOS 对带 query 的 tabBar 跳转有兼容性问题
const cleanUrl = url.split('?')[0];
console.log('[navigate][HARMONY] tabBar INDEX navigate, url=', cleanUrl);
let navigated = false;
uni.switchTab({
url: cleanUrl,
success: () => { navigated = true; console.log('[navigate][HARMONY] switchTab success:', cleanUrl); },
fail: (err) => {
navigated = true;
console.warn('[navigate][HARMONY] switchTab fail, fallback reLaunch:', cleanUrl, err);
uni.reLaunch({ url: cleanUrl });
}
});
// 兜底: 500ms 后若仍无回调或未完成, 强制 reLaunch
// (鸿蒙下 switchTab 经常静默失败, success/fail 都不调用)
setTimeout(() => {
if (!navigated) {
console.warn('[navigate][HARMONY] switchTab silent fail, forcing reLaunch:', cleanUrl);
uni.reLaunch({ url: cleanUrl });
}
}, 500);
// #endif
// #ifndef MP-HARMONY
uni.switchTab({ url });
// #endif
} else {
uni.reLaunch({ url });
}
return;
}
}
// 动态 diy tabBar:如果目标是 diy_bottom_nav 中的 tab 根页
// 注意:uni.switchTab 仅对 pages.json 中 tabBar.list 注册的页面有效,
// 其他 DIY 配置页面调用 switchTab 会静默失败(点击无响应)。
// 因此使用 uni.redirectTo 替换当前页:不进栈 → 无返回按钮 → 对任意页面都有效。
const diyTabBar = store.state.tabBarList;
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
const cleanUrl = url.split('?')[0];
const 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<String>} 返回实际复制的文本
*/
// #ifdef MP-WEIXIN
copyCurrentPath(options = {}) {
const { copySuccess = '', copyFailed = '' } = options;
const route = this.getCurrentRoute();
// 去掉前导斜杠,保持与转发 path 兼容
const text = (route && route.path ? route.path : '').replace(/^\//, '');
return new Promise((resolve, reject) => {
copyText(text, { copySuccess, copyFailed }, (success, err) => {
if (success) resolve(text);
else reject(err || new Error('复制失败'));
});
});
},
// #endif
//获取当前路由
getCurrentRoute() {
let currentRoutes = getCurrentPages(); // 获取当前打开过的页面路由数组
let currentRoute = currentRoutes[currentRoutes.length - 1].route //获取当前页面路由
let currentParam = currentRoutes[currentRoutes.length - 1].options; //获取路由参数
// 拼接参数
let param = [];
for (let key in currentParam) {
param.push(key + '=' + currentParam[key])
}
let currentPath = '/' + currentRoute;
let currentQuery = param.join('&');
if (currentQuery) currentPath += '?' + currentQuery;
return {
path: currentPath,
query: currentQuery,
}
},
//获取分享路由
getCurrentShareRoute(member_id) {
let route = this.getCurrentRoute();
//去掉原来的分享人数据
route.path = route.path.replace(/[?|&]source_member=\d+/, '');
if (member_id) {
//路径的处理
if (route.path.indexOf('?') > 0) {
route.path += '&';
} else {
route.path += '?';
}
route.path += 'source_member=' + member_id;
//参数的处理
if (route.query) {
route.query += '&';
}
route.query += 'source_member=' + member_id;
}
return route;
},
/**
* 对象转 style 字符串
* - 自动将 camelCase 转 kebab-case(fontSize → font-size),CSS 自定义属性(以 -- 开头)保持原样
* - 自动过滤值为 undefined / null / '' 的项,避免输出脏数据
* - 末尾补一个 ';',方便与其他 style 拼接
* - 非对象入参一律返回 ''
* @param {Object} obj
* @return {String}
*/
objToStyle(obj) {
if (!obj || typeof obj !== 'object') return '';
const s = [];
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
const v = obj[k];
if (v === undefined || v === null || v === '') continue;
// 以 -- 开头的 CSS 自定义属性保持原样;其余 camelCase 转 kebab-case
const key = k.indexOf('--') === 0 ? k : k.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
s.push(key + ':' + v);
}
return s.length ? s.join(';') + ';' : '';
},
/**
* 颜色减值
* @param {Object} c1
* @param {Object} c2
* @param {Object} ratio
*/
colourBlend(c1, c2, ratio) {
ratio = Math.max(Math.min(Number(ratio), 1), 0)
let r1 = parseInt(c1.substring(1, 3), 16)
let g1 = parseInt(c1.substring(3, 5), 16)
let b1 = parseInt(c1.substring(5, 7), 16)
let r2 = parseInt(c2.substring(1, 3), 16)
let g2 = parseInt(c2.substring(3, 5), 16)
let b2 = parseInt(c2.substring(5, 7), 16)
let r = Math.round(r1 * (1 - ratio) + r2 * ratio)
let g = Math.round(g1 * (1 - ratio) + g2 * ratio)
let b = Math.round(b1 * (1 - ratio) + b2 * ratio)
r = ('0' + (r || 0).toString(16)).slice(-2)
g = ('0' + (g || 0).toString(16)).slice(-2)
b = ('0' + (b || 0).toString(16)).slice(-2)
return '#' + r + g + b
},
/**
* 生成贝塞尔曲线轨迹
* @param {Object} points
* @param {Object} times
*/
bezier(points, times) {
var bezier_points = [];
var points_D = [];
var points_E = [];
const DIST_AB = Math.sqrt(Math.pow(points[1]['x'] - points[0]['x'], 2) + Math.pow(points[1]['y'] - points[0][
'y'
], 2));
// 邻控制BC点间距
const DIST_BC = Math.sqrt(Math.pow(points[2]['x'] - points[1]['x'], 2) + Math.pow(points[2]['y'] - points[1][
'y'
], 2));
// D每次在AB方向上移动的距离
if (points[0]['x'] > points[2]['x']) {
var EACH_MOVE_AD = -(DIST_AB / times);
// E每次在BC方向上移动的距离
var EACH_MOVE_BE = -(DIST_BC / times);
} else {
var EACH_MOVE_AD = +(DIST_AB / times);
// E每次在BC方向上移动的距离
var EACH_MOVE_BE = +(DIST_BC / times);
}
// 点AB的正切
const TAN_AB = (points[1]['y'] - points[0]['y']) / (points[1]['x'] - points[0]['x']);
// 点BC的正切
const TAN_BC = (points[2]['y'] - points[1]['y']) / (points[2]['x'] - points[1]['x']);
// 点AB的弧度值
const RADIUS_AB = Math.atan(TAN_AB);
// 点BC的弧度值
const RADIUS_BC = Math.atan(TAN_BC);
// 每次执行
for (var i = 1; i <= times; i++) {
// AD的距离
var dist_AD = EACH_MOVE_AD * i;
// BE的距离
var dist_BE = EACH_MOVE_BE * i;
// D点的坐标
var point_D = {};
point_D['x'] = dist_AD * Math.cos(RADIUS_AB) + points[0]['x'];
point_D['y'] = dist_AD * Math.sin(RADIUS_AB) + points[0]['y'];
points_D.push(point_D);
// E点的坐标
var point_E = {};
point_E['x'] = dist_BE * Math.cos(RADIUS_BC) + points[1]['x'];
point_E['y'] = dist_BE * Math.sin(RADIUS_BC) + points[1]['y'];
points_E.push(point_E);
// 此时线段DE的正切值
var tan_DE = (point_E['y'] - point_D['y']) / (point_E['x'] - point_D['x']);
// tan_DE的弧度值
var radius_DE = Math.atan(tan_DE);
// 地市DE的间距
var dist_DE = Math.sqrt(Math.pow((point_E['x'] - point_D['x']), 2) + Math.pow((point_E['y'] - point_D['y']),
2));
// 此时DF的距离
var dist_DF = (dist_AD / DIST_AB) * dist_DE;
// 此时DF点的坐标
var point_F = {};
point_F['x'] = dist_DF * Math.cos(radius_DE) + point_D['x'];
point_F['y'] = dist_DF * Math.sin(radius_DE) + point_D['y'];
bezier_points.push(point_F);
}
return {
'bezier_points': bezier_points
};
},
/**
* 验证手机号
* @param {string} mobile 被验证的mobile
* @return {object} 验证后的结果
**/
verifyMobile(mobile) {
var parse = /^\d{11}$/.test(mobile);
return parse;
},
/**
* 判断是否为快应用环境
* @returns {boolean} 是否为快应用
*/
isQuickApp() {
// #ifndef QUICKAPP-WEBVIEW
let _isQuickApp = false;
try {
const ua = navigator?.userAgent?.toLowerCase();
_isQuickApp = ua.indexOf('quickapp') !== -1;
if (!_isQuickApp) {
// 特殊说明由于网站是嵌入到快应用的Webview组件中<web>,UA中不会包含quickapp字符串,
// 但是会包含uniacid参数,所以这里通过uniacid参数来判断是否为快应用环境
_isQuickApp = ua.indexOf('uniacid=') !== -1;
}
} catch (e) { }
if (!_isQuickApp) {
const systemInfo = this.getDeviceInfo();
_isQuickApp = systemInfo?.platform === 'quickapp' || process?.env?.UNI_PLATFORM === 'quickapp';
}
return _isQuickApp;
// #endif
return true;
},
/**
* 判断是否为华为快应用环境
* @returns {boolean} 是否为华为快应用
*/
isHuaweiQuickApp() {
if (!this.isQuickApp()) return false;
// #ifndef QUICKAPP-WEBVIEW-HUAWEI
const systemInfo = this.getDeviceInfo();
return systemInfo.brand === 'HUAWEI' || systemInfo.manufacturer === 'HUAWEI' ||
(typeof qh !== 'undefined' && qh.platform === 'huawei');
// #endif
return true;
},
// #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);
},
}