实现浮动按钮跳转AI聊天页时自动隐藏底部tabBar,通过__dyu标记区分入口来源: 1. 浮动按钮点击时注入__dyu=FloatBtn来源标记 2. AI聊天页根据该标记临时隐藏tabBar并在卸载时恢复 3. 兼容旧store无对应mutation的场景
2108 lines
71 KiB
JavaScript
2108 lines
71 KiB
JavaScript
import Config from './config.js'
|
||
import store from '@/store/index.js'
|
||
import Http from './http.js'
|
||
import Platform from './platform.js'
|
||
import Lang from './lang.js'
|
||
import {
|
||
Weixin
|
||
} from 'common/js/wx-jssdk.js';
|
||
import { copyText } from './uniapp.utils.js';
|
||
|
||
// 跳转相关工具集(纯函数, 不依赖 util.js 自身, 避免循环依赖)
|
||
import {
|
||
isHarmonyQuickAppWebview,
|
||
_toParamValueString,
|
||
parsePageParams as _parsePageParams,
|
||
decodeEntity,
|
||
cleanParamUrls,
|
||
normalizeUrl as _normalizeUrlBase,
|
||
appendQueryParams as _appendQueryParamsBase,
|
||
extractInternalAppPath as _extractInternalAppPath,
|
||
resolveNavMode as _resolveNavMode,
|
||
hasStaleUniacidInUrl as _hasStaleUniacidInUrl,
|
||
pickCurrentUniacid as _pickCurrentUniacid,
|
||
_shouldEncodeUrl,
|
||
_safeEncodeForPlatform,
|
||
_navigateTo,
|
||
} from './util-navigate.js';
|
||
|
||
// TabBar / 子包路径相关工具集(纯函数, 依赖 util-navigate 的 _toParamValueString)
|
||
import {
|
||
resolvePageUrl as _resolvePageUrl,
|
||
isPageInRouteList as isPageInRouteListFn,
|
||
resolveLinkUrl as _resolveLinkUrlPure,
|
||
makeSystemTabBarList as _makeSystemTabBarList,
|
||
adaptSubpackageUrl,
|
||
isTabBarRootPage as _isTabBarRootPagePure,
|
||
isCurrentPageTabBarRoot as _isCurrentPageTabBarRootPure,
|
||
checkTabBarActive as _checkTabBarActivePure,
|
||
} from './util-tabbar.js';
|
||
|
||
|
||
// =============================================================
|
||
// HTML 实体解码 / 参数序列化 / page_params 解析
|
||
// (直接 re-export util-navigate 的同名函数, 保持 API 兼容)
|
||
// =============================================================
|
||
|
||
export { decodeEntity, cleanParamUrls, _toParamValueString, adaptSubpackageUrl };
|
||
|
||
/**
|
||
* 获取系统当前的 uniacid(运行时单点真值)。
|
||
*
|
||
* 必须【与 common/js/http.js 发送请求时使用的 uniacid 完全一致】(行 168):
|
||
* data.uniacid = uni.getStorageSync('uniacid') || Config.uniacid
|
||
* 否则 uniacid 守卫(脏数据判断)和实际网络请求会基于不同的真值,
|
||
* 产生不一致行为。
|
||
*
|
||
* 真值源:
|
||
* 1) uni.getStorageSync('uniacid') — 持久化, 由登录 / 初始化写入
|
||
* 2) Config.uniacid — 编译期兜底(从 .local.config.js / vue.config.js 注入)
|
||
*
|
||
* 0 视为"无 uniacid": hasStaleUniacidInUrl 收到 0 时直接返回 false (不强行判断)。
|
||
*
|
||
* @returns {number} 当前 uniacid; 无法获取时返回 0
|
||
*/
|
||
function _getCurrentUniacid() {
|
||
let storageVal;
|
||
try {
|
||
if (typeof uni !== 'undefined' && uni && typeof uni.getStorageSync === 'function') {
|
||
storageVal = uni.getStorageSync('uniacid');
|
||
}
|
||
} catch (e) {}
|
||
let configVal;
|
||
try {
|
||
if (Config && Config.uniacid) configVal = Config.uniacid;
|
||
} catch (e) {}
|
||
return _pickCurrentUniacid(storageVal, configVal);
|
||
}
|
||
|
||
/**
|
||
* 把 URL 上的 page_params 还原成对象。是 _toParamValueString 的逆操作。
|
||
* 容错策略:任何解析失败都安全降级到 defaultValue,不抛异常。
|
||
*
|
||
* 注意: 与 util-navigate.js 的 parsePageParams 略有差异 —— 本版本对空串 '' 也返回 defaultValue,
|
||
* 保持原 util.js 的旧行为(老代码 `'page_params' in link` 但 page_params === '' 时,
|
||
* 旧版会落到 defaultValue, 新版会落到 defaultValue 同样的结果)。
|
||
*
|
||
* @example
|
||
* parsePageParams('{"a":1}') // { a: 1 }
|
||
* parsePageParams({a: 1}) // { a: 1 } (已是对象直接返回)
|
||
* parsePageParams('[object Object]') // {} (老 bug 残留安全降级)
|
||
* parsePageParams('') // {}
|
||
* parsePageParams(null) // {}
|
||
* parsePageParams(undefined) // {}
|
||
* parsePageParams('not json', {x:1}) // { x: 1 } (解析失败走默认值)
|
||
*
|
||
* @param {*} raw page_params 原始值(可能是 object / string / null / undefined)
|
||
* @param {Object} [defaultValue={}] 解析失败时的兜底值
|
||
* @returns {Object}
|
||
*/
|
||
export function parsePageParams(raw, defaultValue = {}) {
|
||
return _parsePageParams(raw, defaultValue);
|
||
}
|
||
|
||
|
||
/**
|
||
* 把任意值(通常是 URL 参数 / query string 解析出的字符串)强转为布尔值。
|
||
*
|
||
* 设计动机:URL query 中布尔值的合法写法不止一种——
|
||
* ?show_home=1 -> '1' -> true
|
||
* ?show_home=true -> 'true' -> true
|
||
* ?show_home=true -> true -> true
|
||
* ?show_home=0 -> '0' -> false
|
||
* ?show_home=false -> 'false' -> false
|
||
* ?show_home= -> '' -> false
|
||
* 未传 -> undefined -> false
|
||
*
|
||
* 与裸 `Boolean(x)` 的差异:
|
||
* Boolean('false') === true (任何非空字符串都是 true)
|
||
* Boolean('0') === true (同上)
|
||
* 本函数对这两个值返回 false,更适合 URL 参数解析场景。
|
||
*
|
||
* @param {*} val 任意值
|
||
* @returns {boolean}
|
||
*/
|
||
export function coerceBool(val) {
|
||
if (val === true || val === 1) return true;
|
||
if (typeof val === 'string') {
|
||
const s = val.trim().toLowerCase();
|
||
return s === '1' || s === 'true';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
|
||
// 关于页面路径,使用常量导出
|
||
export const INDEX_PAGE_URL = '/pages/index/index';
|
||
export const CATEGORY_PAGE_URL = '/pages_goods/category';
|
||
export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
|
||
export const MEMBER_PAGE_URL = '/pages_tool/member/index';
|
||
export const LOGIN_PAGE_URL = '/pages_tool/login/login';
|
||
export const AI_CHAT_PAGE_URL = '/pages_tool/ai-chat/index';
|
||
export const AI_AGENT_PAGE_URL = '/pages_tool/ai-chat/index'; // AI 智能体入口页(智能客服是首个 agent)
|
||
|
||
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
|
||
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
|
||
export const PAGE_KEY_MAP = {
|
||
// === 主页 & 商品 ===
|
||
PAGE_INDEX: '/pages/index/index',
|
||
PAGE_GOODS_LIST: '/pages_goods/list',
|
||
PAGE_GOODS_CATEGORY: '/pages_goods/category',
|
||
PAGE_GOODS_DETAIL: '/pages_goods/detail',
|
||
PAGE_GOODS_CART: '/pages_goods/cart',
|
||
PAGE_GOODS_COUPON: '/pages_tool/goods/coupon',
|
||
PAGE_GOODS_BRAND: '/pages_tool/goods/brand',
|
||
|
||
// === 订单 ===
|
||
PAGE_ORDER_LIST: '/pages/order/list',
|
||
PAGE_ORDER_DETAIL: '/pages/order/detail',
|
||
PAGE_ORDER_CREATE: '/pages/order/create',
|
||
PAGE_ORDER_REFUND: '/pages/order/refund',
|
||
PAGE_ORDER_ACTIVIST: '/pages/order/activist',
|
||
|
||
// === 会员中心 ===
|
||
PAGE_MEMBER_INDEX: '/pages_tool/member/index',
|
||
PAGE_MEMBER_INFO: '/pages_tool/member/info',
|
||
PAGE_MEMBER_ACCOUNT: '/pages_tool/member/account',
|
||
PAGE_MEMBER_COLLECTION: '/pages_tool/member/collection',
|
||
PAGE_MEMBER_FOOTPRINT: '/pages_tool/member/footprint',
|
||
PAGE_MEMBER_COUPON: '/pages_tool/member/coupon',
|
||
PAGE_MEMBER_BALANCE: '/pages_tool/member/balance_detail',
|
||
PAGE_MEMBER_ADDRESS: '/pages_tool/member/address',
|
||
PAGE_MEMBER_LEVEL: '/pages_tool/member/level',
|
||
PAGE_MEMBER_SIGNIN: '/pages_tool/member/signin',
|
||
PAGE_MEMBER_CARD: '/pages_tool/member/card',
|
||
PAGE_MEMBER_CONTACT: '/pages_tool/member/contact',
|
||
|
||
// === 公告/文章/帮助 ===
|
||
PAGE_NOTICE_LIST: '/pages_tool/notice/list',
|
||
PAGE_ARTICLE_LIST: '/pages_tool/article/list',
|
||
PAGE_ARTICLE_DETAIL: '/pages_tool/article/detail',
|
||
PAGE_HELP_LIST: '/pages_tool/help/list',
|
||
|
||
// === 积分商城 ===
|
||
PAGE_POINT_LIST: '/pages_promotion/point/list',
|
||
PAGE_POINT_ORDER_LIST: '/pages_promotion/point/order_list',
|
||
|
||
// === 分销 ===
|
||
PAGE_FENXIAO_INDEX: '/pages_promotion/fenxiao/index',
|
||
PAGE_FENXIAO_WITHDRAW: '/pages_promotion/fenxiao/withdraw_list',
|
||
PAGE_FENXIAO_ORDER: '/pages_promotion/fenxiao/order',
|
||
PAGE_FENXIAO_TEAM: '/pages_promotion/fenxiao/team',
|
||
PAGE_FENXIAO_PROMOTE: '/pages_promotion/fenxiao/promote_code',
|
||
|
||
// === 商户 ===
|
||
PAGE_MERCH_CATEGORY: '/pages_promotion/merch/merchcategory',
|
||
|
||
// === 案例展示 ===
|
||
PAGE_CASES_INDEX: '/pages_tool/cases/index',
|
||
|
||
// === 电子名片 ===
|
||
PAGE_CONTACT_CARD: '/pages/contact/contact',
|
||
|
||
// === VR ===
|
||
PAGE_VR_INDEX: '/pages_tool/vr/index',
|
||
|
||
// === 介质材料/结构选型 ===
|
||
PAGE_SEAL_MEDIUM: '/pages_tool/seal/medium/search',
|
||
PAGE_SEAL_STRUCTURE: '/pages_tool/seal/structure',
|
||
|
||
// === 充值/核销 ===
|
||
PAGE_RECHARGE_LIST: '/pages_tool/recharge/list',
|
||
PAGE_VERIFICATION_INDEX: '/pages_tool/verification/index',
|
||
|
||
// === 登录/联系 ===
|
||
PAGE_LOGIN: '/pages_tool/login/login',
|
||
PAGE_REGISTER: '/pages_tool/login/register',
|
||
PAGE_CONTACT: '/pages_tool/contact/contact',
|
||
|
||
// === 文件/微页面 ===
|
||
PAGE_FILES_LIST: '/pages_tool/files/list',
|
||
PAGE_FILES_DETAIL: '/pages_tool/files/detail',
|
||
PAGE_DIY_VIEW: '/pages_tool/index/diy',
|
||
|
||
// === 其他 ===
|
||
PAGE_AI_CHAT: '/pages_tool/ai-chat/index',
|
||
PAGE_WEBVIEW: '/pages_tool/webview/webview'
|
||
};
|
||
|
||
// ================== TabBar 高亮映射配置 ==================
|
||
|
||
// TabBar 标识(使用各 Tab 根页面的 wap_url)
|
||
export const TAB_BAR_KEYS = {
|
||
HOME: '/pages/index/index',
|
||
CATEGORY: '/pages_goods/category',
|
||
MERCHANT: '/pages_promotion/merch/merchcategory',
|
||
CART: '/pages_goods/cart',
|
||
MEMBER: '/pages_tool/member/index'
|
||
};
|
||
|
||
// 明确不归属任何 TabBar 的独立页面:进入这些页面时 TabBar 全不亮
|
||
export const NO_TAB_PAGES = new Set([
|
||
// 登录/注册/找回密码
|
||
'/pages_tool/login/login',
|
||
'/pages_tool/login/register',
|
||
'/pages_tool/login/find',
|
||
|
||
// 支付/收银流程
|
||
'/pages_tool/pay/index',
|
||
'/pages_tool/pay/wx_pay',
|
||
'/pages_tool/pay/cashier',
|
||
'/pages_tool/pay/result',
|
||
|
||
// 订单独立流程页(详情/退款/物流/评价)
|
||
'/pages_order/payment',
|
||
'/pages_order/detail',
|
||
'/pages_order/detail_local_delivery',
|
||
'/pages_order/detail_pickup',
|
||
'/pages_order/detail_point',
|
||
'/pages_order/refund',
|
||
'/pages_order/refund_detail',
|
||
'/pages_order/refund_goods_select',
|
||
'/pages_order/refund_type_select',
|
||
'/pages_order/refund_batch',
|
||
'/pages_order/logistics',
|
||
'/pages_order/activist',
|
||
'/pages_order/order/evaluate',
|
||
'/pages_order/detail_virtual',
|
||
|
||
// 表单
|
||
'/pages_tool/form/form',
|
||
'/pages_tool/form/formdata',
|
||
|
||
// 外部/特殊页面
|
||
'/pages_tool/webview/webview',
|
||
'/pages_tool/ai-chat/index',
|
||
'/pages_tool/vr/index',
|
||
'/pages_tool/cases/index',
|
||
'/pages_tool/contact/contact',
|
||
'/pages_tool/weapp/order_shipping',
|
||
'/pages_tool/file-preview/file-preview',
|
||
|
||
// 帮助/公告/文章详情
|
||
'/pages_tool/help/detail',
|
||
'/pages_tool/notice/detail',
|
||
'/pages_tool/article/detail',
|
||
|
||
// 介质材料
|
||
'/pages_tool/seal/structure',
|
||
'/pages_tool/seal/medium/detail'
|
||
]);
|
||
|
||
// 页面级 TabBar 精确归属映射(key: 页面路径, value: TabBar 根路径)
|
||
// value 为 null 表示"该页明确不点亮任何 Tab" (e.g. 全屏流程页)
|
||
// 未在此映射中的页面, 走 NO_TAB_PAGES 判断 + 下方 PAGE_TAB_WILDCARDS 通配符 + history-walk 同/跨包策略
|
||
export const PAGE_TAB_MAP = {
|
||
// 首页
|
||
'/pages/index/index': TAB_BAR_KEYS.HOME,
|
||
'/pages/empty/index': TAB_BAR_KEYS.HOME,
|
||
|
||
// 分类 — tab 根页面 (子页面用通配符, 见 PAGE_TAB_WILDCARDS)
|
||
'/pages_goods/category': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_goods/list': TAB_BAR_KEYS.CATEGORY,
|
||
'/pages_goods/detail': TAB_BAR_KEYS.CATEGORY,
|
||
|
||
// 入驻企业 — tab 根页面 (子页面用通配符)
|
||
'/pages_promotion/merch/merchcategory': TAB_BAR_KEYS.MERCHANT,
|
||
'/pages_promotion/merch/detail': TAB_BAR_KEYS.MERCHANT,
|
||
|
||
// 购物车 (单页)
|
||
'/pages_goods/cart': TAB_BAR_KEYS.CART,
|
||
|
||
// 我的 (会员中心 tab 根页面; 子页面用通配符)
|
||
'/pages_tool/member/index': TAB_BAR_KEYS.MEMBER,
|
||
|
||
// 我的频道下的其他独立功能页
|
||
'/pages_order/list': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/recharge/list': TAB_BAR_KEYS.MEMBER,
|
||
'/pages_tool/recharge/order_list': TAB_BAR_KEYS.MEMBER,
|
||
|
||
// 帮助/公告/文章列表 (单页, 归首页频道)
|
||
'/pages_tool/help/list': TAB_BAR_KEYS.HOME,
|
||
'/pages_tool/notice/list': TAB_BAR_KEYS.HOME,
|
||
'/pages_tool/article/list': TAB_BAR_KEYS.HOME,
|
||
|
||
// 文件列表 (视业务可归首页, 暂不归任何 Tab)
|
||
'/pages_tool/files/list': null,
|
||
|
||
// 注: /pages_tool/index/diy 不在此映射中
|
||
// 走 history-walk 自动归属: home → diy → HOME; 深链 → diy → 不点亮
|
||
};
|
||
|
||
// 页面级 TabBar 通配符映射 (按子树批量归属, 减少维护成本)
|
||
// 匹配规则: pattern 以 '/*' 结尾, '*' 匹配"前缀之后的至少一个字符段"
|
||
// 优先级: PAGE_TAB_MAP 精确匹配 > PAGE_TAB_WILDCARDS 通配符
|
||
// 查找函数: util-tabbar.js#lookupPageTabMap
|
||
export const PAGE_TAB_WILDCARDS = [
|
||
// 我的频道 — /pages_tool/member/* 下所有子页面 (30+ 页压缩为 1 行)
|
||
{ pattern: '/pages_tool/member/*', tab: TAB_BAR_KEYS.MEMBER },
|
||
|
||
// 商品工具 — /pages_tool/goods/* 下所有子页面 (search/brand/coupon/...)
|
||
{ pattern: '/pages_tool/goods/*', tab: TAB_BAR_KEYS.CATEGORY },
|
||
];
|
||
|
||
|
||
// =============================================================
|
||
// 基于 util-tabbar / util-navigate 的本文件 wrapper
|
||
// (这些函数都依赖本文件导出的 PAGE_KEY_MAP, 所以要包一层)
|
||
// =============================================================
|
||
|
||
/**
|
||
* 根据 page_key + params 解析得到实际跳转URL
|
||
* @param {string} page_key 页面语义常量,如 PAGE_GOODS_LIST
|
||
* @param {Object} params 页面参数(JSON对象)
|
||
* @returns {string|null} 实际URL(已拼接参数)或 null(未匹配)
|
||
*/
|
||
export const resolvePageUrl = (page_key, params = {}) => {
|
||
return _resolvePageUrl(page_key, params, PAGE_KEY_MAP);
|
||
};
|
||
|
||
/**
|
||
* 判断当前路由是否命中 page_routes 列表
|
||
* 支持 3 种写法,可混合使用:
|
||
* 1) PAGE_KEY_MAP 的 key → 如 "PAGE_INDEX"
|
||
* 2) 实际页面路径 → 如 "/pages/index/index"
|
||
* 3) 通配符路径 → 如 "/pages_tool/*" 或 "PAGE_ORDER_*"
|
||
* @param {string} currentRoute 当前页面路径(会自动补 / 前缀)
|
||
* @param {string[]} routeList 后台配置的 page_routes 数组
|
||
* @returns {boolean} 是否命中
|
||
*/
|
||
export const isPageInRouteList = (currentRoute, routeList) => {
|
||
return isPageInRouteListFn(currentRoute, routeList, PAGE_KEY_MAP);
|
||
};
|
||
|
||
/**
|
||
* 解析 link 对象得到最终可跳转的 URL
|
||
* 同时把 wap_url 自带的 query 参数与 page_params 合并, 写入 link.page_params;
|
||
* 并把 link.wap_url 改为不含 query 的 path, 避免后续 redirectTo 重复附加
|
||
* 优先级: page_params > wap_url query (新机制优先)
|
||
*
|
||
* @param {Object} link
|
||
* @returns {string|null}
|
||
*/
|
||
export const resolveLinkUrl = (link) => {
|
||
if (!link) return null;
|
||
// 0. 先走 util-tabbar 的纯函数(合并 wap_url 的 query 到 page_params)
|
||
const after = _resolveLinkUrlPure(link, {
|
||
pageKeyMap: PAGE_KEY_MAP,
|
||
parsePageParams: parsePageParams,
|
||
decodeEntity: decodeEntity,
|
||
});
|
||
// 1. 优先使用 page_key(新机制)
|
||
if (link.page_key) {
|
||
const params = parsePageParams(link.page_params);
|
||
const url = resolvePageUrl(link.page_key, params);
|
||
if (url) return url;
|
||
}
|
||
// 2. 回退: 旧 wap_url / 外部 http 链接
|
||
return after;
|
||
};
|
||
|
||
// 当前最新的 tabBar.list (由 util-tabbar 的 makeSystemTabBarList 生成)
|
||
export const systemTabBarList = _makeSystemTabBarList(
|
||
INDEX_PAGE_URL,
|
||
CATEGORY_PAGE_URL,
|
||
CONTACT_PAGE_URL,
|
||
MEMBER_PAGE_URL,
|
||
);
|
||
|
||
/**
|
||
* 判断给定路径是否是 TabBar 根页面(与 diy_bottom_nav 中任意一项精确相等)
|
||
* @param {string} pagePath 当前页面路径(可以带/不带前导斜杠、可以带 query)
|
||
* @param {Object|null} tabBarList tabBar 配置对象($store.state.tabBarList)
|
||
* @returns {boolean} 如果是 TabBar 根页面返回 true;路径无效/未配置 tabBarList 时返回 false
|
||
*/
|
||
export const isTabBarRootPage = (pagePath, tabBarList) => {
|
||
return _isTabBarRootPagePure(pagePath, tabBarList);
|
||
};
|
||
|
||
/**
|
||
* 判断**当前**页面是否是 TabBar 根页面
|
||
* 封装了 getCurrentPages() + isTabBarRootPage 的组合,供业务组件在弹窗/操作前判断
|
||
* 例: 登录弹窗在会员中心页打开时,应当跳过 hideTabBar 的调用
|
||
* @returns {boolean} 当前页是 TabBar 项时返回 true,否则 false(getCurrentPages 不可用时也返回 false)
|
||
*/
|
||
export const isCurrentPageTabBarRoot = () => {
|
||
return _isCurrentPageTabBarRootPure({ store });
|
||
};
|
||
|
||
/**
|
||
* 检查tabBar是否激活
|
||
* @param {string} linkUrl 链接路径 /pages/goods/category
|
||
* @param {string} currentPageRoute 当前页面路径 /pages_goods/category
|
||
* @param {Object} tabBarList tabBar配置对象,用于动态判断
|
||
* @param {Object} packageEntryTab 追踪每个分包的入口Tab { 'pages_goods': '/pages_goods/category' }
|
||
* @param {Object} currentPageQuery 当前页面 query 参数,用于读取 from_tab 来源记忆
|
||
* @returns {boolean} 是否激活
|
||
*/
|
||
export const checkTabBarActive = (linkUrl, currentPageRoute, tabBarList, packageEntryTab, currentPageQuery = {}) => {
|
||
return _checkTabBarActivePure(
|
||
linkUrl,
|
||
currentPageRoute,
|
||
tabBarList,
|
||
packageEntryTab,
|
||
currentPageQuery,
|
||
{
|
||
noTabPages: NO_TAB_PAGES,
|
||
pageTabMap: PAGE_TAB_MAP,
|
||
pageTabWildcards: PAGE_TAB_WILDCARDS,
|
||
},
|
||
);
|
||
};
|
||
|
||
|
||
// =============================================================
|
||
// default export - 保持 this.$util.* 旧 API 完全兼容
|
||
// =============================================================
|
||
|
||
export default {
|
||
/** 导出页面URL常量 */
|
||
MEMBER_PAGE_URL,
|
||
CATEGORY_PAGE_URL,
|
||
CONTACT_PAGE_URL,
|
||
INDEX_PAGE_URL,
|
||
LOGIN_PAGE_URL,
|
||
AI_CHAT_PAGE_URL,
|
||
|
||
/** page_key 到路径的映射表(统一管理所有页面) */
|
||
PAGE_KEY_MAP,
|
||
|
||
/** TabBar 业务配置 */
|
||
TAB_BAR_KEYS,
|
||
NO_TAB_PAGES,
|
||
PAGE_TAB_MAP,
|
||
PAGE_TAB_WILDCARDS,
|
||
systemTabBarList,
|
||
|
||
isHarmonyQuickAppWebview,
|
||
|
||
/** 根据 page_key + params 解析实际URL */
|
||
resolvePageUrl,
|
||
|
||
/** 判断当前路由是否命中 page_routes 列表(支持 PAGE_KEY / 实际路径 / 通配符) */
|
||
isPageInRouteList,
|
||
|
||
/** 解析 link 对象得到最终可跳转的 URL(优先级: page_key → wap_url) */
|
||
resolveLinkUrl,
|
||
|
||
/** 把 page_params 还原成对象(JSON.parse 的安全封装) */
|
||
parsePageParams,
|
||
|
||
/** 把任意值强转为布尔值,专治 URL query 参数布尔解析(?show_home=1/'true'/true) */
|
||
coerceBool,
|
||
|
||
/** HTML 实体解码 */
|
||
decodeEntity,
|
||
|
||
/**
|
||
* 递归清理任意对象/数组中所有"看起来像 URL"的字符串字段:
|
||
* - 去除反引号 / 各种空白 / 零宽字符
|
||
* - 修复 scheme 后多余斜杠(https:////host → https://host)
|
||
*
|
||
* "看起来像 URL" 的判定: 含 `://`(大小写不敏感)
|
||
* - 命中: 走 cleanUrlString
|
||
* - 不命中: 原样保留(name / title / 标识符等普通字符串字段不被误伤)
|
||
*
|
||
* 设计: 不限定 key 列表,递归遍历,新加任何 URL 字段都自动覆盖
|
||
*/
|
||
cleanParamUrls,
|
||
|
||
/** 判断给定路径是否是 TabBar 根页面(用于动态控制返回按钮显示) */
|
||
isTabBarRootPage,
|
||
|
||
/** 判断当前页面是否是 TabBar 根页面(供业务组件在弹窗前判断是否需要隐藏 TabBar) */
|
||
isCurrentPageTabBarRoot,
|
||
|
||
/** 检查 tabBar 是否激活(动态 tabBar 高亮判断) */
|
||
checkTabBarActive,
|
||
|
||
/** 子包路径前缀映射(/pages/order/ → /pages_order/) */
|
||
adaptSubpackageUrl,
|
||
|
||
/**
|
||
* URL 规范化(严格规范化已有部分,绝不自动补 https / host)
|
||
* @see util-navigate.js#normalizeUrl
|
||
*/
|
||
normalizeUrl(urlString) {
|
||
return _normalizeUrlBase(urlString);
|
||
},
|
||
|
||
/**
|
||
* 给 URL 追加 query 参数(已存在的 key 会被覆盖)
|
||
* @see util-navigate.js#appendQueryParams
|
||
*/
|
||
appendQueryParams(url, params) {
|
||
return _appendQueryParamsBase(url, params);
|
||
},
|
||
|
||
/**
|
||
* 页面跳转
|
||
* @param {string} to 跳转链接 /pages/idnex/index
|
||
* @param {Object} param 参数 {key : value, ...}, 默认{}
|
||
* @param {string} mode 模式 navigateTo、redirectTo、reLaunch、tabbar, 默认navigateTo
|
||
*/
|
||
redirectTo(to, param = {}, mode = 'navigateTo') {
|
||
// 注意: URL 清理只在 diyRedirectTo 入口做(更上游,职责清晰)。
|
||
// redirectTo 接收到的 param 应当是干净的;不重复清理,避免性能浪费 + 误伤边界。
|
||
//
|
||
// 0. 智能默认 mode (2026-07-23, 修正 2026-07-23):
|
||
// 跳登录入口 LOGIN_PAGE_URL 时, 默认使用 reLaunch 模式 (即使调用方没传 mode).
|
||
// 修复场景:
|
||
// - 注销 / 退出登录后, token 被清空, cancelstatus.vue 等页面再次进入
|
||
// 会调 redirectTo(LOGIN_PAGE_URL), 历史上走 navigateTo 模式, 旧页面
|
||
// (cancelstatus) 还在栈中, 用户按返回键回到 cancelstatus 后又触发
|
||
// onShow 死循环跳登录页.
|
||
// 排除场景 (重要, 避免影响):
|
||
// - 带 back 参数的: 表示登录后要回到原页面 (订单详情/退款详情等),
|
||
// 必须保留 navigateTo 模式让 back 参数生效. 例如:
|
||
// refund_detail.vue: redirectTo(LOGIN_PAGE_URL, { back: '/pages_tool/order/refund_detail?id=xxx' })
|
||
// - 显式传 'navigateTo' / 'redirectTo' / 其他 mode: 保持原意.
|
||
// 触发条件: mode 是默认 'navigateTo' AND 没传 back 参数 AND 目标是 LOGIN_PAGE_URL.
|
||
if (mode === 'navigateTo' && typeof to === 'string' && !param?.back) {
|
||
const cleanPath = to.indexOf('?') === -1 ? to : to.substring(0, to.indexOf('?'));
|
||
if (cleanPath === LOGIN_PAGE_URL) {
|
||
mode = 'reLaunch';
|
||
console.log('[navigate] redirectTo: 跳 LOGIN_PAGE_URL 自动改为 reLaunch 模式 (没带 back 参数)');
|
||
}
|
||
}
|
||
|
||
// 1. 拆分 path 与已有 query,避免后面 indexOf('?') 误判
|
||
console.log('[navigate] redirectTo entry: to=', to, 'mode=', mode, 'param=', JSON.stringify(param));
|
||
|
||
const queryIdx = to.indexOf('?');
|
||
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
|
||
const existingParams = {};
|
||
|
||
if (queryIdx !== -1) {
|
||
to.substring(queryIdx + 1).split('&').forEach(kv => {
|
||
if (!kv) return;
|
||
// 用 indexOf('=') 取首个等号,兼容 value 里带 '=' 的场景
|
||
const eqIdx = kv.indexOf('=');
|
||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||
if (k) existingParams[k] = v;
|
||
});
|
||
}
|
||
|
||
// 2. 合并 param:同 key 时新值覆盖旧值,杜绝 ?name=A&name=B 这种重复
|
||
// page_params 永远放在 URL 最末:从 param 中抽离后追加到 merged 末尾
|
||
// param 已在函数顶部 0 号步骤做了 URL 清理,这里直接用
|
||
const { page_params, ...restParam } = param;
|
||
const merged = Object.assign({}, existingParams, restParam);
|
||
if (page_params !== undefined) {
|
||
merged.page_params = page_params;
|
||
}
|
||
|
||
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
|
||
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
|
||
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
|
||
// 是已编码字符串(典型表现:商品分类 → 商品列表,
|
||
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
|
||
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
|
||
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
|
||
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
|
||
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
|
||
// 成 top-level 分隔符丢参数)
|
||
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
|
||
// 做 decode roundtrip + encode 一次)
|
||
const queryStr = Object.keys(merged)
|
||
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
|
||
.join('&');
|
||
|
||
let url = path + (queryStr ? '?' + queryStr : '');
|
||
console.log(`[redirectTo]0 url= ${url}`, {
|
||
to,
|
||
param
|
||
});
|
||
|
||
// 替换url中的前缀
|
||
url = adaptSubpackageUrl(url);
|
||
|
||
console.log('[redirectTo]1 to=', to, 'param=', param, '-> url=', url);
|
||
|
||
// 针对tabBar.list中的路径,直接切换tabBar
|
||
for (let i = 0; i < systemTabBarList.length; i++) {
|
||
const tabBarUrl = systemTabBarList[i];
|
||
if (url.indexOf(tabBarUrl) == 0) {
|
||
// 首页使用switchTab(pages.json中注册的tabBar页面),其他tab页面使用reLaunch
|
||
// reLaunch关闭所有页面再打开目标页,确保页面栈与浏览器历史一致,避免返回按钮异常
|
||
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
|
||
if (tabBarUrl == INDEX_PAGE_URL) {
|
||
// #ifdef MP-HARMONY
|
||
// 鸿蒙元服务环境下系统 TabBar 点击 uni.switchTab 静默失败, 用 reLaunch 兜底
|
||
// 用不携带 query 的干净路径, 避免 HarmonyOS 对带 query 的 tabBar 跳转有兼容性问题
|
||
const cleanUrl = url.split('?')[0];
|
||
console.log('[navigate][HARMONY] tabBar INDEX navigate, url=', cleanUrl);
|
||
let navigated = false;
|
||
uni.switchTab({
|
||
url: cleanUrl,
|
||
success: () => { navigated = true; console.log('[navigate][HARMONY] switchTab success:', cleanUrl); },
|
||
fail: (err) => {
|
||
navigated = true;
|
||
console.warn('[navigate][HARMONY] switchTab fail, fallback reLaunch:', cleanUrl, err);
|
||
uni.reLaunch({ url: cleanUrl });
|
||
}
|
||
});
|
||
// 兜底: 500ms 后若仍无回调或未完成, 强制 reLaunch
|
||
// (鸿蒙下 switchTab 经常静默失败, success/fail 都不调用)
|
||
setTimeout(() => {
|
||
if (!navigated) {
|
||
console.warn('[navigate][HARMONY] switchTab silent fail, forcing reLaunch:', cleanUrl);
|
||
uni.reLaunch({ url: cleanUrl });
|
||
}
|
||
}, 500);
|
||
// #endif
|
||
|
||
// #ifndef MP-HARMONY
|
||
uni.switchTab({ url });
|
||
// #endif
|
||
} else {
|
||
uni.reLaunch({ url });
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 动态 diy tabBar:如果目标是 diy_bottom_nav 中的 tab 根页
|
||
// 注意:uni.switchTab 仅对 pages.json 中 tabBar.list 注册的页面有效,
|
||
// 其他 DIY 配置页面调用 switchTab 会静默失败(点击无响应)。
|
||
// 因此使用 uni.redirectTo 替换当前页:不进栈 → 无返回按钮 → 对任意页面都有效。
|
||
const diyTabBar = store.state.tabBarList;
|
||
if (diyTabBar && diyTabBar.list && Array.isArray(diyTabBar.list)) {
|
||
const cleanUrl = url.split('?')[0];
|
||
const isExcludedPage = NO_TAB_PAGES.has(cleanUrl);
|
||
if (!isExcludedPage) {
|
||
const isDiyTabRoot = diyTabBar.list.some(item => {
|
||
if (!item || !item.link || !item.link.wap_url) return false;
|
||
const normalizedTab = adaptSubpackageUrl(item.link.wap_url.startsWith('/') ? item.link.wap_url : '/' + item.link.wap_url).split('?')[0];
|
||
const normalizedUrl = adaptSubpackageUrl(cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl).split('?')[0];
|
||
return normalizedTab === normalizedUrl;
|
||
});
|
||
if (isDiyTabRoot) {
|
||
const adaptedUrl = adaptSubpackageUrl(cleanUrl);
|
||
const inSystemTabBar = systemTabBarList.some(sysUrl => adaptedUrl === sysUrl || cleanUrl === sysUrl);
|
||
// 首页用switchTab,其他DIY tab根页用reLaunch确保页面栈干净
|
||
let navApi = inSystemTabBar ? (adaptedUrl === INDEX_PAGE_URL ? uni.switchTab : uni.reLaunch) : uni.reLaunch;
|
||
|
||
// 鸿蒙元服务环境下 uni.switchTab 有兼容性问题, 全部使用 reLaunch
|
||
// #ifdef MP-HARMONY
|
||
navApi = uni.reLaunch;
|
||
// #endif
|
||
|
||
navApi({
|
||
url,
|
||
fail: (err) => {
|
||
console.error('[navigate] tabBar 跳转失败:', cleanUrl, err);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log(`====>mode=${mode}, url=${url}`);
|
||
|
||
// 如果url不包含tabBarList中的路径,根据mode判断跳转方式
|
||
switch (mode) {
|
||
case 'tabbar':
|
||
// 跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面。重置页面栈,仅保留 tabBar 页面
|
||
// 鸿蒙元服务环境下 uni.switchTab 静默失败, 改用 reLaunch
|
||
// #ifdef MP-HARMONY
|
||
uni.reLaunch({
|
||
url
|
||
});
|
||
// #endif
|
||
|
||
// #ifndef MP-HARMONY
|
||
uni.switchTab({
|
||
url
|
||
});
|
||
// #endif
|
||
break;
|
||
case 'redirectTo':
|
||
// 关闭当前页面,跳转到应用内的某个页面。替换栈顶页面,长度不变(上限10层)
|
||
uni.redirectTo({
|
||
url
|
||
});
|
||
break;
|
||
case 'reLaunch':
|
||
// 关闭所有页面,打开到应用内的某个页面。清空栈后打开新页面,长度=1
|
||
// H5 平台修复 (2026-07-23):
|
||
// uni.reLaunch 在 H5 上是 history.back() + router.replace 的组合,
|
||
// 并不能真正删除浏览器历史条目。在某些浏览器 (微信内置浏览器 /
|
||
// Android 定制浏览器) 中, 按浏览器返回键仍可回到原页面, 导致
|
||
// 注销/退出登录后用户能看到旧会员信息。
|
||
// location.replace 不产生新历史条目, 直接替换当前条目,
|
||
// 真正"清空历史栈"。对快应用 (H5 webview 嵌入) 同样生效。
|
||
// #ifdef H5
|
||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined'
|
||
&& typeof window.location.replace === 'function') {
|
||
window.location.replace(url);
|
||
return;
|
||
}
|
||
// #endif
|
||
// 小程序平台修复 (2026-07-23):
|
||
// 微信小程序在以下场景会抛 "appLaunch with non-empty page stack" native 错误:
|
||
// - 短时间内连发 reLaunch (页面销毁未完成, 新 launch 时栈非空)
|
||
// - 异步回调/onLoad 中调 reLaunch (上下文丢失)
|
||
// 解决方案:
|
||
// 1) 跳转锁: 300ms 内不重复 reLaunch (避免连发)
|
||
// 2) setTimeout(0) 包裹: 让 native 层先完成当前页面销毁, 再发新 launch
|
||
// #ifdef MP
|
||
if (typeof globalThis !== 'undefined' && globalThis.__reLaunchLock) {
|
||
console.warn('[redirectTo] reLaunch 锁定中, 忽略本次跳转:', url);
|
||
return;
|
||
}
|
||
globalThis.__reLaunchLock = true;
|
||
setTimeout(() => {
|
||
uni.reLaunch({ url });
|
||
// 300ms 后释放锁
|
||
setTimeout(() => { globalThis.__reLaunchLock = false; }, 300);
|
||
}, 0);
|
||
return;
|
||
// #endif
|
||
// APP / 其他平台: 原生 reLaunch
|
||
uni.reLaunch({
|
||
url
|
||
});
|
||
break;
|
||
case 'navigateTo':
|
||
default:
|
||
// 保留当前页面,跳转到应用内的某个页面,新增页面到栈顶,长度+1(上限10层)
|
||
uni.navigateTo({
|
||
url
|
||
});
|
||
}
|
||
},
|
||
/**
|
||
* 图片路径转换
|
||
* @param {String} img_path 图片地址
|
||
* @param {Object} params 参数,针对商品、相册里面的图片区分大中小,size: big、mid、small
|
||
*/
|
||
img(img_path, params) {
|
||
var path = "";
|
||
if (img_path != undefined && img_path != "") {
|
||
if (img_path.split(',').length > 1) {
|
||
img_path = img_path.split(',')[0];
|
||
}
|
||
if (params && img_path != this.getDefaultImage().goods) {
|
||
// 过滤默认图
|
||
let arr = img_path.split(".");
|
||
let suffix = arr[arr.length - 1];
|
||
arr.pop();
|
||
arr[arr.length - 1] = arr[arr.length - 1] + "_" + params.size.toUpperCase();
|
||
arr.push(suffix);
|
||
// if(img_path.indexOf('attachment') == -1){
|
||
// img_path = arr.join(".");
|
||
// }
|
||
|
||
}
|
||
if (img_path.indexOf("http://") == -1 && img_path.indexOf("https://") == -1) {
|
||
path = Config.imgDomain + "/" + img_path;
|
||
} else {
|
||
// console.log(img_path)
|
||
path = img_path;
|
||
}
|
||
// 修复:去除 URL 中的双斜杠,避免微信 downloadFile 域名匹配失败
|
||
path = path.replace(/([^:]\/)\/+/g, "$1");
|
||
}
|
||
|
||
// path += '?t=' + parseInt(new Date().getTime() / 1000);
|
||
return path;
|
||
},
|
||
/**
|
||
* 时间戳转日期格式
|
||
* @param {Object} timeStamp
|
||
*/
|
||
timeStampTurnTime(timeStamp, type = "") {
|
||
if (timeStamp != undefined && timeStamp != "" && timeStamp > 0) {
|
||
var date = new Date();
|
||
date.setTime(timeStamp * 1000);
|
||
var y = date.getFullYear();
|
||
var m = date.getMonth() + 1;
|
||
m = m < 10 ? ('0' + m) : m;
|
||
var d = date.getDate();
|
||
d = d < 10 ? ('0' + d) : d;
|
||
var h = date.getHours();
|
||
h = h < 10 ? ('0' + h) : h;
|
||
var minute = date.getMinutes();
|
||
var second = date.getSeconds();
|
||
minute = minute < 10 ? ('0' + minute) : minute;
|
||
second = second < 10 ? ('0' + second) : second;
|
||
if (type) {
|
||
if (type == 'yearMonthDay') {
|
||
return y + Lang.lang('util.dateYear') + m + Lang.lang('util.dateMonth') + d + Lang.lang('util.dateDay');
|
||
}
|
||
return y + '-' + m + '-' + d;
|
||
} else {
|
||
return y + '-' + m + '-' + d + ' ' + h + ':' + minute + ':' + second;
|
||
}
|
||
} else {
|
||
return "";
|
||
}
|
||
},
|
||
/**
|
||
* 日期格式转时间戳
|
||
* @param {Object} string
|
||
*/
|
||
timeTurnTimeStamp(string) {
|
||
var f = string.split(' ', 2);
|
||
var d = (f[0] ? f[0] : '').split('-', 3);
|
||
var t = (f[1] ? f[1] : '').split(':', 3);
|
||
return (new Date(
|
||
parseInt(d[0], 10) || null,
|
||
(parseInt(d[1], 10) || 1) - 1,
|
||
parseInt(d[2], 10) || null,
|
||
parseInt(t[0], 10) || null,
|
||
parseInt(t[1], 10) || null,
|
||
parseInt(t[2], 10) || null
|
||
)).getTime() / 1000;
|
||
},
|
||
/**
|
||
* 倒计时
|
||
* @param {Object} seconds 秒
|
||
*/
|
||
countDown(seconds) {
|
||
let [day, hour, minute, second] = [0, 0, 0, 0]
|
||
if (seconds > 0) {
|
||
day = Math.floor(seconds / (60 * 60 * 24))
|
||
hour = Math.floor(seconds / (60 * 60)) - (day * 24)
|
||
minute = Math.floor(seconds / 60) - (day * 24 * 60) - (hour * 60)
|
||
second = Math.floor(seconds) - (day * 24 * 60 * 60) - (hour * 60 * 60) - (minute * 60)
|
||
}
|
||
if (day < 10) {
|
||
day = '0' + day
|
||
}
|
||
if (hour < 10) {
|
||
hour = '0' + hour
|
||
}
|
||
if (minute < 10) {
|
||
minute = '0' + minute
|
||
}
|
||
if (second < 10) {
|
||
second = '0' + second
|
||
}
|
||
return {
|
||
d: day,
|
||
h: hour,
|
||
i: minute,
|
||
s: second
|
||
};
|
||
},
|
||
/**
|
||
* 数值去重
|
||
* @param {Array} arr 数组
|
||
* @param {string} field 字段
|
||
*/
|
||
unique(arr, field) {
|
||
const res = new Map();
|
||
return arr.filter((a) => !res.has(a[field]) && res.set(a[field], 1));
|
||
},
|
||
/**
|
||
* 判断值是否在数组中
|
||
* @param {Object} elem
|
||
* @param {Object} arr
|
||
*/
|
||
inArray: function (elem, arr) {
|
||
return arr == null ? -1 : arr.indexOf(elem);
|
||
},
|
||
/**
|
||
* 获取某天日期
|
||
* @param {Object} day
|
||
*/
|
||
getDay: function (day) {
|
||
var today = new Date();
|
||
var targetday_milliseconds = today.getTime() + 1000 * 60 * 60 * 24 * day;
|
||
today.setTime(targetday_milliseconds);
|
||
|
||
const doHandleMonth = function (month) {
|
||
var m = month;
|
||
if (month.toString().length == 1) {
|
||
m = "0" + month;
|
||
}
|
||
return m
|
||
}
|
||
|
||
var tYear = today.getFullYear();
|
||
var tMonth = today.getMonth();
|
||
var tDate = today.getDate();
|
||
var tWeek = today.getDay();
|
||
var time = parseInt(today.getTime() / 1000);
|
||
tMonth = doHandleMonth(tMonth + 1);
|
||
tDate = doHandleMonth(tDate);
|
||
|
||
const week = [
|
||
Lang.lang('util.weekSunday'),
|
||
Lang.lang('util.weekMonday'),
|
||
Lang.lang('util.weekTuesday'),
|
||
Lang.lang('util.weekWednesday'),
|
||
Lang.lang('util.weekThursday'),
|
||
Lang.lang('util.weekFriday'),
|
||
Lang.lang('util.weekSaturday')
|
||
]
|
||
return {
|
||
't': time,
|
||
'y': tYear,
|
||
'm': tMonth,
|
||
'd': tDate,
|
||
'w': week[tWeek]
|
||
};
|
||
},
|
||
/**
|
||
* 图片选择加上传
|
||
* @param num
|
||
* @param params
|
||
* @param callback
|
||
* @param url
|
||
*/
|
||
upload: function (num, params, callback, url) {
|
||
const { appType, appTypeName } = Platform.getPlatformInfo();
|
||
var data = {
|
||
token: store.state.token,
|
||
app_type: appType,
|
||
app_type_name: appTypeName
|
||
}
|
||
data = Object.assign(data, params);
|
||
|
||
var imgs_num = num;
|
||
var _self = this;
|
||
|
||
uni.chooseImage({
|
||
count: imgs_num,
|
||
sizeType: ['compressed'], //可以指定是原图还是压缩图,默认二者都有
|
||
sourceType: ['album', 'camera'], //从相册或者拍照
|
||
success: async function (res) {
|
||
const tempFilePaths = res.tempFilePaths;
|
||
var _data = data;
|
||
var imgs = [];
|
||
uni.showLoading({
|
||
title: Lang.lang('util.uploading')
|
||
})
|
||
for (var i = 0; i < tempFilePaths.length; i++) {
|
||
var path = await _self.upload_file_server(tempFilePaths[i], _data, params.path,
|
||
url);
|
||
imgs.push(path);
|
||
if (imgs.length == tempFilePaths.length) {
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: Lang.lang('util.uploadSuccess'),
|
||
icon: 'none'
|
||
})
|
||
typeof callback == 'function' && callback(imgs);
|
||
}
|
||
}
|
||
},
|
||
fail: err => {
|
||
uni.hideLoading()
|
||
uni.showToast({
|
||
title: Lang.lang('util.uploadFailed'),
|
||
icon: 'none'
|
||
})
|
||
}
|
||
});
|
||
},
|
||
//上传
|
||
upload_file_server(tempFilePath, data, path, url = "", callback) {
|
||
if (url) {
|
||
var uploadUrl = Config.baseUrl + url
|
||
} else {
|
||
var uploadUrl = Config.baseUrl + '/api/upload/' + path
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
uni.uploadFile({
|
||
url: uploadUrl,
|
||
filePath: tempFilePath,
|
||
name: 'file',
|
||
fileType: data.fileType || 'image',
|
||
formData: data,
|
||
success: function (res) {
|
||
var path_str = JSON.parse(res.data);
|
||
if (path_str.code >= 0) {
|
||
resolve(path_str.data.pic_path);
|
||
typeof callback == 'function' && callback(path_str.data.pic_path);
|
||
} else {
|
||
reject("error");
|
||
}
|
||
}
|
||
});
|
||
|
||
});
|
||
|
||
},
|
||
/**
|
||
* 复制
|
||
* @param {Object} value
|
||
* @param {Object} callback
|
||
*/
|
||
copy(value, callback) {
|
||
// #ifdef H5
|
||
var oInput = document.createElement('input'); //创建一个隐藏input(重要!)
|
||
oInput.value = value; //赋值
|
||
oInput.setAttribute("readonly", "readonly");
|
||
document.body.appendChild(oInput);
|
||
oInput.select(); // 选择对象
|
||
document.execCommand("Copy"); // 执行浏览器复制命令
|
||
oInput.className = 'oInput';
|
||
oInput.style.display = 'none';
|
||
uni.hideKeyboard();
|
||
this.showToast({
|
||
title: Lang.lang('util.copySuccess')
|
||
});
|
||
|
||
typeof callback == 'function' && callback();
|
||
// #endif
|
||
|
||
// #ifdef MP || APP-PLUS
|
||
uni.setClipboardData({
|
||
data: value,
|
||
success: () => {
|
||
typeof callback == 'function' && callback();
|
||
}
|
||
});
|
||
// #endif
|
||
},
|
||
/**
|
||
* 是否是微信浏览器
|
||
* @deprecated 请使用 Platform.isWeiXin()
|
||
*/
|
||
isWeiXin() {
|
||
return Platform.isWeiXin();
|
||
},
|
||
/**
|
||
* 判断是否在微信小程序的web-view中打开H5
|
||
* @returns {boolean} 是否在微信小程序的web-view中
|
||
*
|
||
* 鸿蒙元服务(JSVM)没有 navigator 全局,改用 typeof 守卫兜底,让它直接返回 false
|
||
*/
|
||
isWeChatMiniProgram() {
|
||
if (typeof navigator === 'undefined' || !navigator.userAgent) return false;
|
||
const userAgent = navigator.userAgent.toLowerCase();
|
||
|
||
// 微信小程序的web-view User-Agent会包含以下特征之一
|
||
const miniProgramIndicators = [
|
||
'miniprogram', // 普通微信小程序
|
||
'wxwork', // 企业微信小程序
|
||
'micromessenger/[0-9]+\.[0-9]+\.[0-9]+ nettype/wifi wxwebviewtype/1'
|
||
];
|
||
|
||
return miniProgramIndicators.some(indicator => {
|
||
if (typeof indicator === 'string' && indicator.includes('/')) {
|
||
// 正则表达式匹配
|
||
const regex = new RegExp(indicator, 'i');
|
||
return regex.test(userAgent);
|
||
}
|
||
return userAgent.includes(indicator);
|
||
});
|
||
},
|
||
/**
|
||
* 判断是否在微信浏览器中打开H5(非小程序的web-view)
|
||
* @returns {boolean} 是否在微信浏览器中打开的H5
|
||
*/
|
||
isWeChatBrowser() {
|
||
return isWeiXin() && !isWeChatMiniProgram();
|
||
},
|
||
/**
|
||
* 显示消息提示框
|
||
* @param {Object} params 参数
|
||
*/
|
||
showToast(params = {}) {
|
||
params.title = params.title || "";
|
||
params.icon = params.icon || "none";
|
||
// params.position = params.position || 'bottom';
|
||
params.duration = params.duration || 1500;
|
||
uni.showToast(params);
|
||
if (params.success) params.success();
|
||
},
|
||
/**
|
||
* 检测苹果X以上的手机
|
||
*/
|
||
isIPhoneX() {
|
||
let res = this.getDeviceInfo();
|
||
if (res?.model?.search('iPhone X') != -1) {
|
||
return true;
|
||
}
|
||
return false;
|
||
},
|
||
//判断安卓还是iOS
|
||
isAndroid() {
|
||
let platform = this.getDeviceInfo()?.platform;
|
||
if (platform == 'ios') {
|
||
return false;
|
||
} else if (platform == 'android') {
|
||
return true;
|
||
}
|
||
},
|
||
/**
|
||
* 获取设备信息(包含降级处理)
|
||
*/
|
||
getDeviceInfo() {
|
||
let info = {};
|
||
try { info = uni.getDeviceInfo(); } catch (e) {
|
||
try { info = uni.getSystemInfoSync(); } catch (e) {}
|
||
}
|
||
return info;
|
||
},
|
||
/**
|
||
* 获取窗口信息(包含降级处理)
|
||
*/
|
||
getWindowInfo() {
|
||
let info;
|
||
try {info = uni.getWindowInfo();} catch (e) {
|
||
try {info = uni.getSystemInfoSync();} catch (e) {}
|
||
}
|
||
return info;
|
||
},
|
||
/**
|
||
* 深度拷贝对象
|
||
* @param {Object} obj
|
||
*/
|
||
deepClone(obj) {
|
||
const isObject = function (obj) {
|
||
return typeof obj == 'object';
|
||
}
|
||
|
||
if (!isObject(obj)) {
|
||
throw new Error(Lang.lang('util.deepCloneNotObject'))
|
||
}
|
||
//判断传进来的是对象还是数组
|
||
let isArray = Array.isArray(obj)
|
||
let cloneObj = isArray ? [] : {}
|
||
//通过for...in来拷贝
|
||
for (let key in obj) {
|
||
cloneObj[key] = isObject(obj[key]) ? this.deepClone(obj[key]) : obj[key]
|
||
}
|
||
return cloneObj
|
||
},
|
||
|
||
|
||
|
||
/**
|
||
* 打开微信企业客服
|
||
* @param {Function} fallbackFunc 降级处理函数
|
||
*/
|
||
openWxWorkServiceChat(fallbackFunc) {
|
||
// #ifdef MP-WEIXIN
|
||
let wxworkConfig = store.state?.wxworkConfig
|
||
if (wxworkConfig?.enable && wxworkConfig?.contact_url) {
|
||
// 直接使用活码链接跳转
|
||
wx.navigateToMiniProgram({
|
||
appId: 'wxeb490c6f9b154ef9', // 是企业微信官方小程序的AppID(固定值, 由腾讯企业微信团队维护, 不需要修改,用于展示"添加企业微信联系人"的官方页面)
|
||
path: `pages/contacts/externalContactDetail?url=${encodeURIComponent(wxworkConfig?.contact_url)}`,
|
||
success: () => {
|
||
},
|
||
fail: (err) => {
|
||
}
|
||
});
|
||
} else {
|
||
fallbackFunc && fallbackFunc();
|
||
}
|
||
// #endif
|
||
},
|
||
|
||
/**
|
||
* 自定义模板的跳转链接
|
||
* @param {Object} link
|
||
*/
|
||
diyRedirectTo(link) {
|
||
|
||
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
|
||
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
|
||
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
|
||
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
|
||
const title = link.title || 'AI 智能体';
|
||
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
|
||
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
|
||
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
|
||
return this.redirectTo(AI_AGENT_PAGE_URL, {
|
||
agent_id: agentId,
|
||
title: encodeURIComponent(title),
|
||
enable_set_title: link.enable_set_title ? 1 : 0,
|
||
...(dyuEntry ? { __dyu: dyuEntry } : {}),
|
||
});
|
||
}
|
||
|
||
console.log(`-----> diyRedirectTo`, JSON.stringify(link));
|
||
//if (link == null || Object.keys(link).length == 1) return;
|
||
|
||
// 0. 兜底清理 link 里所有"看起来像 URL"的字符串字段
|
||
// 递归遍历,任意 key / 任意嵌套层级都被命中
|
||
// (去除反引号 / 各种空白 / 零宽字符,修复 scheme 后多余斜杠)
|
||
// 这是最后一道防线,防止后台脏数据(如 wap_url 字段被粘贴时带 markdown 反引号)
|
||
// 把脏 URL 一直传到 web-view,导致微信 web-view 解析失败
|
||
link = this.cleanParamUrls(link) || {};
|
||
|
||
if (link.wap_url) {
|
||
link.wap_url = decodeEntity(link.wap_url);
|
||
}
|
||
|
||
// 修复: wap_url 自带的 uniacid query 必须与系统当前 uniacid 一致
|
||
// 原因: uniacid 是本平台租户 ID,会随租户迁移 / 平台调整而变化。
|
||
// 装修数据里的 wap_url?uniacid=2482 一旦与系统当前 uniacid(比如 2483)不一致,
|
||
// 说明这份数据可能是历史遗留或别的租户的(脏数据),
|
||
// 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。
|
||
// 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。
|
||
// ★ 必须在 resolveLinkUrl 之前判断 —— 否则 URL 的 query 被抽走就分不清了。
|
||
// ★ open_mode=internal 时不覆盖 —— 用户显式强制内部, 表示"我知道我在做什么"。
|
||
if (_hasStaleUniacidInUrl(link.wap_url, _getCurrentUniacid())
|
||
&& _resolveNavMode(link) !== 'internal') {
|
||
link.open_mode = 'online';
|
||
}
|
||
|
||
// 优先用 page_key 解析实际URL(新机制,向后兼容)
|
||
const resolvedUrl = resolveLinkUrl(link);
|
||
if (link.page_key && resolvedUrl) {
|
||
link.wap_url = resolvedUrl;
|
||
}
|
||
|
||
// 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata)
|
||
// 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接":
|
||
// - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失
|
||
// - MP 走 web-view → 本应原生跳转的内部页被塞进 web-view
|
||
// 这里把"指向本 App 内部页面"的绝对 URL 还原为相对路径,使其改走下方内部页面分支(SPA 跳转)。
|
||
//
|
||
// 由 open_mode 统一控制(顶层字段 或 page_params):
|
||
// - 'auto'(默认): 双闸门 —— host 与本 App 域名【完全一致】且路径带 /pages 段才还原。
|
||
// 不同子域/不同域名一律当外链,彻底避免误伤 Shopify、Facebook 等外站。
|
||
// - 'internal': 强制内部 —— 跳过 host 校验, 只要路径带 /pages 段就还原为 SPA 跳转。
|
||
// 用途: 数据里链接域名与当前部署域名不同(如 xcx3 vs xcx6)但确认是同一 App 内部页。
|
||
// - 'online': 强制线上 —— 不还原, 保持整页刷新 / web-view。
|
||
// 用途: 某些自域名页面是非 SPA / 遗留页,必须整页或 web-view 打开。
|
||
if (link.wap_url && /^https?:\/\//i.test(link.wap_url)) {
|
||
const navMode = _resolveNavMode(link); // 'internal' | 'online' | 'auto'
|
||
if (navMode !== 'online') {
|
||
let internalPath;
|
||
if (navMode === 'internal') {
|
||
// 强制内部: 传 true 跳过 host 校验
|
||
internalPath = _extractInternalAppPath(link.wap_url, true);
|
||
} else {
|
||
// auto: host 完全一致才还原
|
||
const appHosts = [];
|
||
// #ifdef H5
|
||
try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) { }
|
||
// #endif
|
||
try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) { }
|
||
try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) { }
|
||
internalPath = _extractInternalAppPath(link.wap_url, appHosts);
|
||
}
|
||
if (internalPath) {
|
||
link.wap_url = internalPath;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 外部链接
|
||
if (link.wap_url && link.wap_url.indexOf('http') != -1) {
|
||
// 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params)
|
||
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||
// page_params 放在最后,确保其在最终 URL 中位于 title/enable_set_title 之后
|
||
const params = Object.assign(
|
||
{},
|
||
{
|
||
title: link?.title ?? '',
|
||
enable_set_title: link?.enable_set_title ? 1 : 0
|
||
},
|
||
pageParams
|
||
);
|
||
let url = this.normalizeUrl(decodeEntity(link.wap_url));
|
||
url = this.appendQueryParams(url, params);
|
||
|
||
// #ifdef H5
|
||
// console.log(`url ---->`, url, link)
|
||
window.location.href = url;
|
||
// #endif
|
||
|
||
// #ifdef MP
|
||
console.log(`外部链接,跳转到webview`, url);
|
||
this.redirectTo(PAGE_KEY_MAP.PAGE_WEBVIEW, {
|
||
...link,
|
||
src: url
|
||
});
|
||
// #endif
|
||
|
||
} else if (link.appid) {
|
||
// 跳转其他小程序
|
||
// page_params 转 query string,拼到 path 后面(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params)
|
||
let path = link.page || '';
|
||
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||
const paramKeys = Object.keys(pageParams);
|
||
if (paramKeys.length > 0) {
|
||
const qs = paramKeys
|
||
.filter(k => pageParams[k] !== undefined && pageParams[k] !== null && pageParams[k] !== '')
|
||
.map(k => `${k}=${pageParams[k]}`)
|
||
.join('&');
|
||
if (qs) {
|
||
path += (path.indexOf('?') !== -1 ? '&' : '?') + qs;
|
||
}
|
||
}
|
||
uni.navigateToMiniProgram({
|
||
appId: link.appid,
|
||
path
|
||
})
|
||
|
||
} else if (link.name == 'MOBILE' && !link.wap_url) {
|
||
// 拨打电话
|
||
uni.makePhoneCall({
|
||
phoneNumber: link.mobile,
|
||
success: (res) => {
|
||
},
|
||
fail: (res) => {
|
||
}
|
||
});
|
||
|
||
} else if (link.name == 'MEMBER_CONTACT') {
|
||
// 客服
|
||
let servicerConfig = store.state.servicerConfig
|
||
let config = {
|
||
type: 'none'
|
||
};
|
||
|
||
// #ifdef H5
|
||
config = servicerConfig.h5;
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
config = servicerConfig.weapp;
|
||
// #endif
|
||
|
||
switch (config.type) {
|
||
case 'wxwork':
|
||
// 企业微信客服
|
||
|
||
// #ifdef H5
|
||
window.location.href = config.wxwork_url;
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
this.openWxWorkServiceChat(() => {
|
||
wx.openCustomerServiceChat({
|
||
extInfo: {
|
||
url: config.wxwork_url
|
||
},
|
||
corpId: config.corpid,
|
||
showMessageCard: true,
|
||
sendMessageTitle: 'this.sendMessageTitle',
|
||
sendMessagePath: 'this.sendMessagePath',
|
||
sendMessageImg: 'this.sendMessageImg'
|
||
});
|
||
});
|
||
|
||
// #endif
|
||
break;
|
||
case 'third':
|
||
// 第三方客服
|
||
window.location.href = config.third_url;
|
||
break;
|
||
case 'niushop':
|
||
// Niushop客服
|
||
this.redirectTo('/pages_tool/chat/room', { ...link });
|
||
break;
|
||
case 'weapp':
|
||
// 微信小程序,由于需要手动点击按钮触发,所以要跳转到中间页
|
||
this.redirectTo(link.wap_url, { ...link });
|
||
break;
|
||
default:
|
||
// 拨打客服电话
|
||
let siteInfo = store.state.siteInfo;
|
||
if (siteInfo && siteInfo.site_tel) {
|
||
uni.makePhoneCall({
|
||
phoneNumber: siteInfo.site_tel
|
||
});
|
||
} else {
|
||
this.showToast({
|
||
title: Lang.lang('customerService.noServiceOffline'),
|
||
})
|
||
}
|
||
}
|
||
|
||
} else if (link.wap_url) { // 微页面/内部页面
|
||
// resolveLinkUrl 已把 wap_url 中的 query 合并到 link.page_params
|
||
// 这里过滤掉 link 元数据字段, 避免 wap_url/name/parent/title 等被错误附加到目标 URL
|
||
// title/enable_set_title/page_params 仍需透传到目标 URL,page_params 放最后
|
||
const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link;
|
||
this.redirectTo(link.wap_url, {
|
||
...navParams,
|
||
...(link.title != null && { title: link.title }),
|
||
...(link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 }),
|
||
...(link.page_params || {})
|
||
});
|
||
}
|
||
},
|
||
/**
|
||
* 获取默认图
|
||
*/
|
||
getDefaultImage() {
|
||
let defaultImg = store.state.defaultImg;
|
||
defaultImg.goods = this.img(defaultImg.goods);
|
||
defaultImg.head = this.img(defaultImg.head);
|
||
defaultImg.store = this.img(defaultImg.store);
|
||
defaultImg.article = this.img(defaultImg.article);
|
||
defaultImg.kefu = this.img(defaultImg.kefu);
|
||
defaultImg.phone = this.img(defaultImg.phone);
|
||
return defaultImg;
|
||
},
|
||
/**
|
||
* 判断手机是否为iphoneX系列
|
||
*/
|
||
uniappIsIPhoneX() {
|
||
let isIphoneX = false;
|
||
let systemInfo = this.getDeviceInfo();
|
||
// #ifdef MP
|
||
if (systemInfo.model.search('iPhone X') != -1 || systemInfo.model.search('iPhone 11') != -1 || systemInfo.model.search('iPhone 12') != -1 || systemInfo.model.search('iPhone 13') != -1) {
|
||
isIphoneX = true;
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef H5
|
||
var u = navigator.userAgent;
|
||
var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端
|
||
if (isIOS) {
|
||
if (systemInfo.screenWidth == 375 && systemInfo.screenHeight == 812 && systemInfo.pixelRatio == 3) {
|
||
isIphoneX = true;
|
||
} else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 3) {
|
||
isIphoneX = true;
|
||
} else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 2) {
|
||
isIphoneX = true;
|
||
}
|
||
}
|
||
// #endif
|
||
return isIphoneX;
|
||
},
|
||
/**
|
||
* 判断手机是否为iphone11系列
|
||
*/
|
||
uniappIsIPhone11() {
|
||
let isIphone11 = false;
|
||
let systemInfo = this?.getDeviceInfo();
|
||
// #ifdef MP
|
||
if (systemInfo.model.search('iPhone 11') != -1) {
|
||
isIphone11 = true;
|
||
}
|
||
// #endif
|
||
return isIphone11;
|
||
},
|
||
// #ifdef H5
|
||
//判断该浏览器是否为safaria浏览器
|
||
isSafari() {
|
||
var ua = navigator?.userAgent?.toLowerCase();
|
||
if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 &&
|
||
ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 &&
|
||
ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) {
|
||
return true;
|
||
} else {
|
||
return false;
|
||
}
|
||
},
|
||
// #endif
|
||
numberFixed(e, f) {
|
||
if (!f) {
|
||
f = 0;
|
||
}
|
||
return Number(e).toFixed(f);
|
||
},
|
||
/**
|
||
* 获取url参数
|
||
*/
|
||
getUrlCode(callback) {
|
||
var url = location.search;
|
||
var theRequest = new Object();
|
||
if (url.indexOf('?') != -1) {
|
||
var str = url.substr(1);
|
||
var strs = str.split('&');
|
||
for (var i = 0; i < strs.length; i++) {
|
||
theRequest[strs[i].split('=')[0]] = strs[i].split('=')[1];
|
||
}
|
||
}
|
||
typeof callback == 'function' && callback(theRequest);
|
||
},
|
||
/**
|
||
* 获取当前页面路由
|
||
*/
|
||
getCurrRoute() {
|
||
let routes = getCurrentPages(); // 获取当前打开过的页面路由数组
|
||
return routes.length ? routes[routes.length - 1].route : '';
|
||
},
|
||
goBack(backUrl = INDEX_PAGE_URL) {
|
||
if (getCurrentPages().length == 1) {
|
||
this.redirectTo(backUrl);
|
||
} else {
|
||
uni.navigateBack();
|
||
}
|
||
},
|
||
/**
|
||
* 隐藏“返回首页/小房子”按钮
|
||
* 这个函数,用到页面show, onshow 的生命周期时
|
||
*/
|
||
hideHomeButton() {
|
||
// #ifdef MP-WEIXIN
|
||
wx.hideHomeButton();
|
||
// #endif
|
||
},
|
||
/**
|
||
*
|
||
* @param val 转化时间字符串 (转化时分秒)
|
||
* @returns {string}
|
||
*/
|
||
getTimeStr(val) {
|
||
var h = parseInt(val / 3600).toString();
|
||
var m = parseInt((val % 3600) / 60).toString();
|
||
if (m.length == 1) {
|
||
m = '0' + m;
|
||
}
|
||
if (h.length == 1) {
|
||
h = '0' + h;
|
||
}
|
||
return h + ':' + m;
|
||
},
|
||
/**
|
||
* 获取定位信息
|
||
*/
|
||
getLocation(param = {}) {
|
||
/*uni.getLocation({
|
||
type: param.type ?? 'gcj02',
|
||
success: res => {
|
||
store.commit('setLocation', res);
|
||
typeof param.success == 'function' && param.success(res);
|
||
},
|
||
fail: res => {
|
||
typeof param.fail == 'function' && param.fail(res);
|
||
},
|
||
complete: res => {
|
||
typeof param.complete == 'function' && param.complete(res);
|
||
}
|
||
});*/
|
||
},
|
||
// 计算两个经纬度之间的距离
|
||
getDistance(lat1, lng1, lat2, lng2) {
|
||
var radLat1 = lat1 * Math.PI / 180.0;
|
||
var radLat2 = lat2 * Math.PI / 180.0;
|
||
var a = radLat1 - radLat2;
|
||
var b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0;
|
||
var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) +
|
||
Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
|
||
s = s * 6378.137; // EARTH_RADIUS;
|
||
s = Math.round(s * 10000) / 10000;
|
||
return s;
|
||
},
|
||
//记录分享人
|
||
onSourceMember(source_member) {
|
||
Http.sendRequest({
|
||
url: '/api/Member/alterShareRelation',
|
||
data: {
|
||
share_member: source_member,
|
||
},
|
||
success: res => {
|
||
if (res.code >= 0) {
|
||
uni.removeStorage({
|
||
key: 'source_member',
|
||
success: res => {
|
||
}
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 微信订阅消息
|
||
*/
|
||
subscribeMessage(keywords) {
|
||
let url = ""
|
||
// #ifdef MP-WEIXIN
|
||
url = '/weapp/api/weapp/messagetmplids';
|
||
// #endif
|
||
// #ifdef MP-ALIPAY
|
||
url = '/aliapp/api/aliapp/messagetmplids';
|
||
// #endif
|
||
Http.sendRequest({
|
||
url,
|
||
data: {
|
||
keywords: keywords
|
||
},
|
||
success: res => {
|
||
if (res.data.length) {
|
||
// #ifdef MP-WEIXIN
|
||
uni.requestSubscribeMessage({
|
||
tmplIds: res.data,
|
||
success: (res) => {
|
||
},
|
||
fail: (res) => {
|
||
}
|
||
})
|
||
// #endif
|
||
|
||
// #ifdef MP-ALIPAY
|
||
my.requestSubscribeMessage({
|
||
entityIds: res.data,
|
||
success: res => {
|
||
},
|
||
fail: res => {
|
||
}
|
||
});
|
||
// #endif
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 获取小程序分享内容数据
|
||
*/
|
||
getMpShare(path) {
|
||
//如果没有特别指定 则获取当前页面的路由
|
||
if (!path) {
|
||
let route = this.getCurrentRoute();
|
||
path = route.path;
|
||
if (path == '/pages_tool/member/index') {
|
||
return new Promise((resolve, reject) => {
|
||
resolve({})
|
||
});
|
||
}
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
Http.sendRequest({
|
||
url: "/weapp/api/weapp/share",
|
||
data: {
|
||
path: path
|
||
},
|
||
success: res => {
|
||
if (res.code >= 0) {
|
||
let shareConfig = res.data.data;
|
||
if (shareConfig) {
|
||
//分享给好友
|
||
let appMessageData = {
|
||
title: shareConfig.title,
|
||
path: shareConfig.path,
|
||
imageUrl: shareConfig.imageUrl,
|
||
success: res => {
|
||
},
|
||
fail: res => {
|
||
}
|
||
}
|
||
//分享到朋友圈
|
||
let query = '';
|
||
if (shareConfig.path.indexOf('?') > 0) {
|
||
query = shareConfig.path.split('?')[1];
|
||
}
|
||
let timeLineData = {
|
||
title: shareConfig.title,
|
||
query: shareConfig.path,
|
||
imageUrl: shareConfig.imageUrl,
|
||
}
|
||
resolve({
|
||
appMessage: appMessageData,
|
||
timeLine: timeLineData,
|
||
})
|
||
} else {
|
||
reject(res.data);
|
||
}
|
||
} else {
|
||
reject(res.data)
|
||
}
|
||
}
|
||
});
|
||
})
|
||
},
|
||
/**
|
||
* 设置公众号分享
|
||
* @param shareData
|
||
* @param callback
|
||
*/
|
||
setPublicShare(shareData, callback) {
|
||
if (!this.isWeiXin()) return;
|
||
Http.sendRequest({
|
||
url: '/wechat/api/wechat/jssdkconfig',
|
||
data: {
|
||
url: uni.getSystemInfoSync().platform == 'ios' ? uni.getStorageSync('initUrl') : window.location
|
||
.href
|
||
},
|
||
success: res => {
|
||
if (res.code == 0) {
|
||
var wxJS = new Weixin();
|
||
wxJS.init(res.data);
|
||
wxJS.weixin.showOptionMenu();
|
||
|
||
wxJS.setShareData({
|
||
title: shareData.title ?? '',
|
||
desc: shareData.desc ?? '',
|
||
link: shareData.link ?? location.href,
|
||
imgUrl: shareData.imgUrl ? this.img(shareData.imgUrl) : ''
|
||
}, (res) => {
|
||
typeof callback == 'function' && callback(res);
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
/**
|
||
* 复制当前页面的小程序路径(含 query)到剪贴板(仅微信小程序)
|
||
* 小程序本身没有"页面 URL"概念,仅可复制形如 pages/goods/detail?id=123 的内部路径,
|
||
* 便于分享者在聊天里粘贴给对方识别。
|
||
* 内部复用 common/js/uniapp.utils.js 导出的 copyText(回调形式),为保持 Promise 一致性包裹一层。
|
||
* @param {Object} options { copySuccess: '', copyFailed: '' }
|
||
* @returns {Promise<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);
|
||
},
|
||
|
||
} |