From f656be789d82eecab785e98acfdb200ce71b5b9d Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 12 Aug 2026 15:12:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-agent):=20=E6=96=B0=E5=A2=9EAI=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E9=80=9A=E7=94=A8=E6=94=AF=E6=8C=81=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增多语言文案,适配AI智能体通用场景 2. 新增AI智能体页面路由常量与跳转逻辑 3. 改造AI聊天页面支持动态agent_id和页面标题 4. 升级AI服务层,支持多agent_id自动传递 --- common/js/ai-service.js | 45 + common/js/util.js | 16 +- common/js/util.js.bak | 2090 ++++++++++++++++++++++++++++++++++ lang/en-us/ai-chat/index.js | 6 +- lang/zh-cn/ai-chat/index.js | 6 +- pages_tool/ai-chat/index.vue | 20 +- 6 files changed, 2179 insertions(+), 4 deletions(-) create mode 100644 common/js/util.js.bak diff --git a/common/js/ai-service.js b/common/js/ai-service.js index dff8592..1f5fe9a 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -4,6 +4,13 @@ import store from '@/store/index.js' import lang from './lang.js' let currentConversationId = null; + +// ====== AI Agent Generalization (customer service is the first agent) ====== +// setAgentId updates currentAgentId; default is agent_aikefu. +// Any sendMessage / sendStreamMessage / getServiceConfig call passes agent_id automatically. + +export const DEFAULT_AGENT_ID = 'agent_aikefu'; +let currentAgentId = DEFAULT_AGENT_ID; export const CONVERSATION_KEY = 'ai_conversation_id'; /** @@ -50,6 +57,20 @@ function getUserId() { return guestId; } +/** + * 获取当前 AI Agent。 + */ +function getAgentId() { + return currentAgentId || DEFAULT_AGENT_ID; +} + +/** + * 设置当前 AI Agent。 + */ +function setAgentId(agentId) { + if (agentId && typeof agentId === 'string') currentAgentId = agentId; +} + // #ifdef MP // ================================ // WebSocket 长连接管理(微信小程序) @@ -106,6 +127,7 @@ function ensureWebSocketConnected() { wsSocketTask.send({ data: JSON.stringify({ action: 'auth', + agent_id: getAgentId(), uniacid: _uniacid, token: _token, user_id: _userId @@ -241,6 +263,7 @@ export default { const params = { url: '/api/kefu/chat', data: { + agent_id: getAgentId(), user_id: getUserId(), stream: false, inputs: {}, @@ -305,6 +328,7 @@ export default { wsSocketTask.send({ data: JSON.stringify({ action: 'chat', + agent_id: getAgentId(), uniacid: this.getUniacid(), query: message, user_id: getUserId(), @@ -484,6 +508,7 @@ export default { const params = { url: '/api/kefu/createConversation', data: { + agent_id: getAgentId(), uniacid: this.getUniacid(), token: store.state.token || 'test_token', user_id: getUserId(), @@ -533,6 +558,7 @@ export default { const params = { url: '/api/kefu/info', data: { + agent_id: getAgentId(), uniacid: this.getUniacid(), token: store.state.token || 'test_token', user_id: getUserId(), @@ -563,6 +589,7 @@ export default { const response = await http.sendRequest({ url: '/api/kefu/clear-conversation', data: { + agent_id: getAgentId(), conversation_id: conversationId, ...getDomainParams() }, @@ -585,6 +612,7 @@ export default { } console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit); const requestData = { + agent_id: params.agent_id || getAgentId(), uniacid: params.uniacid || this.getUniacid(), conversation_id: params.conversation_id, user_id: params.user_id || getUserId(), @@ -632,6 +660,7 @@ export default { window.currentEventSource.close(); } const paramsObj = { + agent_id: getAgentId(), uniacid: this.getUniacid(), user_id: getUserId(), member_id: getUserId(), @@ -711,4 +740,20 @@ export default { // H5 无 WebSocket 长连接,无需操作 }, // #endif + + /** + * Set current AI Agent (default aikefu; link system passes agent_id). + * @param {string} agentId + */ + setAgentId(agentId) { + setAgentId(agentId); + }, + + /** + * Get current AI Agent. + * @returns {string} + */ + getAgentId() { + return getAgentId(); + }, }; diff --git a/common/js/util.js b/common/js/util.js index c7bdfc0..15e45ab 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -138,7 +138,8 @@ 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_CHAT_PAGE_URL = '/pages_tool/ai-chat/index'; +export const AI_AGENT_PAGE_URL = '/pages_tool/ai-chat/index'; // AI 智能体入口页(智能客服是首个 agent) // page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径) // 前端组织页面(重命名路径、分包调整)时只需要修改此映射表 @@ -1203,6 +1204,19 @@ export default { * @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 智能体'; + return this.redirectTo(AI_AGENT_PAGE_URL, { + agent_id: agentId, + title: encodeURIComponent(title), + enable_set_title: link.enable_set_title ? 1 : 0, + }); + } + console.log(`-----> diyRedirectTo`, JSON.stringify(link)); //if (link == null || Object.keys(link).length == 1) return; diff --git a/common/js/util.js.bak b/common/js/util.js.bak new file mode 100644 index 0000000..c7bdfc0 --- /dev/null +++ b/common/js/util.js.bak @@ -0,0 +1,2090 @@ +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'; + +// 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) { + console.log(`-----> diyRedirectTo`, JSON.stringify(link)); + //if (link == null || Object.keys(link).length == 1) return; + + // 0. 兜底清理 link 里所有"看起来像 URL"的字符串字段 + // 递归遍历,任意 key / 任意嵌套层级都被命中 + // (去除反引号 / 各种空白 / 零宽字符,修复 scheme 后多余斜杠) + // 这是最后一道防线,防止后台脏数据(如 wap_url 字段被粘贴时带 markdown 反引号) + // 把脏 URL 一直传到 web-view,导致微信 web-view 解析失败 + link = this.cleanParamUrls(link) || {}; + + if (link.wap_url) { + link.wap_url = decodeEntity(link.wap_url); + } + + // 修复: wap_url 自带的 uniacid query 必须与系统当前 uniacid 一致 + // 原因: uniacid 是本平台租户 ID,会随租户迁移 / 平台调整而变化。 + // 装修数据里的 wap_url?uniacid=2482 一旦与系统当前 uniacid(比如 2483)不一致, + // 说明这份数据可能是历史遗留或别的租户的(脏数据), + // 不应该被自动改写成内部 SPA(否则会跳到错租户的内页 / 拿错数据)。 + // 应当保守地走外链(整页 / web-view), 让原 URL(及其 uniacid)如实生效。 + // ★ 必须在 resolveLinkUrl 之前判断 —— 否则 URL 的 query 被抽走就分不清了。 + // ★ open_mode=internal 时不覆盖 —— 用户显式强制内部, 表示"我知道我在做什么"。 + if (_hasStaleUniacidInUrl(link.wap_url, _getCurrentUniacid()) + && _resolveNavMode(link) !== 'internal') { + link.open_mode = 'online'; + } + + // 优先用 page_key 解析实际URL(新机制,向后兼容) + const resolvedUrl = resolveLinkUrl(link); + if (link.page_key && resolvedUrl) { + link.wap_url = resolvedUrl; + } + + // 修复: 内部页面被后台存成【绝对 URL】(如 https://host/hwappx/common/pages_tool/form/formdata) + // 时,下方 `indexOf('http') != -1` 会把它误判为"外部链接": + // - H5 走 window.location.href → 整页刷新,点返回时整个 App 重启、启动屏重播、状态/滚动丢失 + // - MP 走 web-view → 本应原生跳转的内部页被塞进 web-view + // 这里把"指向本 App 内部页面"的绝对 URL 还原为相对路径,使其改走下方内部页面分支(SPA 跳转)。 + // + // 由 open_mode 统一控制(顶层字段 或 page_params): + // - 'auto'(默认): 双闸门 —— host 与本 App 域名【完全一致】且路径带 /pages 段才还原。 + // 不同子域/不同域名一律当外链,彻底避免误伤 Shopify、Facebook 等外站。 + // - 'internal': 强制内部 —— 跳过 host 校验, 只要路径带 /pages 段就还原为 SPA 跳转。 + // 用途: 数据里链接域名与当前部署域名不同(如 xcx3 vs xcx6)但确认是同一 App 内部页。 + // - 'online': 强制线上 —— 不还原, 保持整页刷新 / web-view。 + // 用途: 某些自域名页面是非 SPA / 遗留页,必须整页或 web-view 打开。 + if (link.wap_url && /^https?:\/\//i.test(link.wap_url)) { + const navMode = _resolveNavMode(link); // 'internal' | 'online' | 'auto' + if (navMode !== 'online') { + let internalPath; + if (navMode === 'internal') { + // 强制内部: 传 true 跳过 host 校验 + internalPath = _extractInternalAppPath(link.wap_url, true); + } else { + // auto: host 完全一致才还原 + const appHosts = []; + // #ifdef H5 + try { if (typeof location !== 'undefined' && location.hostname) appHosts.push(location.hostname); } catch (e) { } + // #endif + try { if (Config.h5Domain) appHosts.push(Config.h5Domain); } catch (e) { } + try { if (Config.baseUrl) appHosts.push(Config.baseUrl); } catch (e) { } + internalPath = _extractInternalAppPath(link.wap_url, appHosts); + } + if (internalPath) { + link.wap_url = internalPath; + } + } + } + + // 外部链接 + if (link.wap_url && link.wap_url.indexOf('http') != -1) { + // 公共参数 + 透传 page_params(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params) + const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {}; + // page_params 放在最后,确保其在最终 URL 中位于 title/enable_set_title 之后 + const params = Object.assign( + {}, + { + title: link?.title ?? '', + enable_set_title: link?.enable_set_title ? 1 : 0 + }, + pageParams + ); + let url = this.normalizeUrl(decodeEntity(link.wap_url)); + url = this.appendQueryParams(url, params); + + // #ifdef H5 + // console.log(`url ---->`, url, link) + window.location.href = url; + // #endif + + // #ifdef MP + console.log(`外部链接,跳转到webview`, url); + this.redirectTo(PAGE_KEY_MAP.PAGE_WEBVIEW, { + ...link, + src: url + }); + // #endif + + } else if (link.appid) { + // 跳转其他小程序 + // page_params 转 query string,拼到 path 后面(resolveLinkUrl 已把 wap_url 中的 query 合并到 page_params) + let path = link.page || ''; + const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {}; + const paramKeys = Object.keys(pageParams); + if (paramKeys.length > 0) { + const qs = paramKeys + .filter(k => pageParams[k] !== undefined && pageParams[k] !== null && pageParams[k] !== '') + .map(k => `${k}=${pageParams[k]}`) + .join('&'); + if (qs) { + path += (path.indexOf('?') !== -1 ? '&' : '?') + qs; + } + } + uni.navigateToMiniProgram({ + appId: link.appid, + path + }) + + } else if (link.name == 'MOBILE' && !link.wap_url) { + // 拨打电话 + uni.makePhoneCall({ + phoneNumber: link.mobile, + success: (res) => { + }, + fail: (res) => { + } + }); + + } else if (link.name == 'MEMBER_CONTACT') { + // 客服 + let servicerConfig = store.state.servicerConfig + let config = { + type: 'none' + }; + + // #ifdef H5 + config = servicerConfig.h5; + // #endif + + // #ifdef MP-WEIXIN + config = servicerConfig.weapp; + // #endif + + switch (config.type) { + case 'wxwork': + // 企业微信客服 + + // #ifdef H5 + window.location.href = config.wxwork_url; + // #endif + + // #ifdef MP-WEIXIN + this.openWxWorkServiceChat(() => { + wx.openCustomerServiceChat({ + extInfo: { + url: config.wxwork_url + }, + corpId: config.corpid, + showMessageCard: true, + sendMessageTitle: 'this.sendMessageTitle', + sendMessagePath: 'this.sendMessagePath', + sendMessageImg: 'this.sendMessageImg' + }); + }); + + // #endif + break; + case 'third': + // 第三方客服 + window.location.href = config.third_url; + break; + case 'niushop': + // Niushop客服 + this.redirectTo('/pages_tool/chat/room', { ...link }); + break; + case 'weapp': + // 微信小程序,由于需要手动点击按钮触发,所以要跳转到中间页 + this.redirectTo(link.wap_url, { ...link }); + break; + default: + // 拨打客服电话 + let siteInfo = store.state.siteInfo; + if (siteInfo && siteInfo.site_tel) { + uni.makePhoneCall({ + phoneNumber: siteInfo.site_tel + }); + } else { + this.showToast({ + title: Lang.lang('customerService.noServiceOffline'), + }) + } + } + + } else if (link.wap_url) { // 微页面/内部页面 + // resolveLinkUrl 已把 wap_url 中的 query 合并到 link.page_params + // 这里过滤掉 link 元数据字段, 避免 wap_url/name/parent/title 等被错误附加到目标 URL + // title/enable_set_title/page_params 仍需透传到目标 URL,page_params 放最后 + const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link; + this.redirectTo(link.wap_url, { + ...navParams, + ...(link.title != null && { title: link.title }), + ...(link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 }), + ...(link.page_params || {}) + }); + } + }, + /** + * 获取默认图 + */ + getDefaultImage() { + let defaultImg = store.state.defaultImg; + defaultImg.goods = this.img(defaultImg.goods); + defaultImg.head = this.img(defaultImg.head); + defaultImg.store = this.img(defaultImg.store); + defaultImg.article = this.img(defaultImg.article); + defaultImg.kefu = this.img(defaultImg.kefu); + defaultImg.phone = this.img(defaultImg.phone); + return defaultImg; + }, + /** + * 判断手机是否为iphoneX系列 + */ + uniappIsIPhoneX() { + let isIphoneX = false; + let systemInfo = this.getDeviceInfo(); + // #ifdef MP + if (systemInfo.model.search('iPhone X') != -1 || systemInfo.model.search('iPhone 11') != -1 || systemInfo.model.search('iPhone 12') != -1 || systemInfo.model.search('iPhone 13') != -1) { + isIphoneX = true; + } + // #endif + + // #ifdef H5 + var u = navigator.userAgent; + var isIOS = !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/); //ios终端 + if (isIOS) { + if (systemInfo.screenWidth == 375 && systemInfo.screenHeight == 812 && systemInfo.pixelRatio == 3) { + isIphoneX = true; + } else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 3) { + isIphoneX = true; + } else if (systemInfo.screenWidth == 414 && systemInfo.screenHeight == 896 && systemInfo.pixelRatio == 2) { + isIphoneX = true; + } + } + // #endif + return isIphoneX; + }, + /** + * 判断手机是否为iphone11系列 + */ + uniappIsIPhone11() { + let isIphone11 = false; + let systemInfo = this?.getDeviceInfo(); + // #ifdef MP + if (systemInfo.model.search('iPhone 11') != -1) { + isIphone11 = true; + } + // #endif + return isIphone11; + }, + // #ifdef H5 + //判断该浏览器是否为safaria浏览器 + isSafari() { + var ua = navigator?.userAgent?.toLowerCase(); + if (ua.indexOf('applewebkit') > -1 && ua.indexOf('mobile') > -1 && ua.indexOf('safari') > -1 && + ua.indexOf('linux') === -1 && ua.indexOf('android') === -1 && ua.indexOf('chrome') === -1 && + ua.indexOf('ios') === -1 && ua.indexOf('browser') === -1) { + return true; + } else { + return false; + } + }, + // #endif + numberFixed(e, f) { + if (!f) { + f = 0; + } + return Number(e).toFixed(f); + }, + /** + * 获取url参数 + */ + getUrlCode(callback) { + var url = location.search; + var theRequest = new Object(); + if (url.indexOf('?') != -1) { + var str = url.substr(1); + var strs = str.split('&'); + for (var i = 0; i < strs.length; i++) { + theRequest[strs[i].split('=')[0]] = strs[i].split('=')[1]; + } + } + typeof callback == 'function' && callback(theRequest); + }, + /** + * 获取当前页面路由 + */ + getCurrRoute() { + let routes = getCurrentPages(); // 获取当前打开过的页面路由数组 + return routes.length ? routes[routes.length - 1].route : ''; + }, + goBack(backUrl = INDEX_PAGE_URL) { + if (getCurrentPages().length == 1) { + this.redirectTo(backUrl); + } else { + uni.navigateBack(); + } + }, + /** + * 隐藏“返回首页/小房子”按钮 + * 这个函数,用到页面show, onshow 的生命周期时 + */ + hideHomeButton() { + // #ifdef MP-WEIXIN + wx.hideHomeButton(); + // #endif + }, + /** + * + * @param val 转化时间字符串 (转化时分秒) + * @returns {string} + */ + getTimeStr(val) { + var h = parseInt(val / 3600).toString(); + var m = parseInt((val % 3600) / 60).toString(); + if (m.length == 1) { + m = '0' + m; + } + if (h.length == 1) { + h = '0' + h; + } + return h + ':' + m; + }, + /** + * 获取定位信息 + */ + getLocation(param = {}) { + /*uni.getLocation({ + type: param.type ?? 'gcj02', + success: res => { + store.commit('setLocation', res); + typeof param.success == 'function' && param.success(res); + }, + fail: res => { + typeof param.fail == 'function' && param.fail(res); + }, + complete: res => { + typeof param.complete == 'function' && param.complete(res); + } + });*/ + }, + // 计算两个经纬度之间的距离 + getDistance(lat1, lng1, lat2, lng2) { + var radLat1 = lat1 * Math.PI / 180.0; + var radLat2 = lat2 * Math.PI / 180.0; + var a = radLat1 - radLat2; + var b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0; + var s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); + s = s * 6378.137; // EARTH_RADIUS; + s = Math.round(s * 10000) / 10000; + return s; + }, + //记录分享人 + onSourceMember(source_member) { + Http.sendRequest({ + url: '/api/Member/alterShareRelation', + data: { + share_member: source_member, + }, + success: res => { + if (res.code >= 0) { + uni.removeStorage({ + key: 'source_member', + success: res => { + } + }) + } + } + }) + }, + /** + * 微信订阅消息 + */ + subscribeMessage(keywords) { + let url = "" + // #ifdef MP-WEIXIN + url = '/weapp/api/weapp/messagetmplids'; + // #endif + // #ifdef MP-ALIPAY + url = '/aliapp/api/aliapp/messagetmplids'; + // #endif + Http.sendRequest({ + url, + data: { + keywords: keywords + }, + success: res => { + if (res.data.length) { + // #ifdef MP-WEIXIN + uni.requestSubscribeMessage({ + tmplIds: res.data, + success: (res) => { + }, + fail: (res) => { + } + }) + // #endif + + // #ifdef MP-ALIPAY + my.requestSubscribeMessage({ + entityIds: res.data, + success: res => { + }, + fail: res => { + } + }); + // #endif + } + } + }) + }, + /** + * 获取小程序分享内容数据 + */ + getMpShare(path) { + //如果没有特别指定 则获取当前页面的路由 + if (!path) { + let route = this.getCurrentRoute(); + path = route.path; + if (path == '/pages_tool/member/index') { + return new Promise((resolve, reject) => { + resolve({}) + }); + } + } + return new Promise((resolve, reject) => { + Http.sendRequest({ + url: "/weapp/api/weapp/share", + data: { + path: path + }, + success: res => { + if (res.code >= 0) { + let shareConfig = res.data.data; + if (shareConfig) { + //分享给好友 + let appMessageData = { + title: shareConfig.title, + path: shareConfig.path, + imageUrl: shareConfig.imageUrl, + success: res => { + }, + fail: res => { + } + } + //分享到朋友圈 + let query = ''; + if (shareConfig.path.indexOf('?') > 0) { + query = shareConfig.path.split('?')[1]; + } + let timeLineData = { + title: shareConfig.title, + query: shareConfig.path, + imageUrl: shareConfig.imageUrl, + } + resolve({ + appMessage: appMessageData, + timeLine: timeLineData, + }) + } else { + reject(res.data); + } + } else { + reject(res.data) + } + } + }); + }) + }, + /** + * 设置公众号分享 + * @param shareData + * @param callback + */ + setPublicShare(shareData, callback) { + if (!this.isWeiXin()) return; + Http.sendRequest({ + url: '/wechat/api/wechat/jssdkconfig', + data: { + url: uni.getSystemInfoSync().platform == 'ios' ? uni.getStorageSync('initUrl') : window.location + .href + }, + success: res => { + if (res.code == 0) { + var wxJS = new Weixin(); + wxJS.init(res.data); + wxJS.weixin.showOptionMenu(); + + wxJS.setShareData({ + title: shareData.title ?? '', + desc: shareData.desc ?? '', + link: shareData.link ?? location.href, + imgUrl: shareData.imgUrl ? this.img(shareData.imgUrl) : '' + }, (res) => { + typeof callback == 'function' && callback(res); + }) + } + } + }) + }, + /** + * 复制当前页面的小程序路径(含 query)到剪贴板(仅微信小程序) + * 小程序本身没有"页面 URL"概念,仅可复制形如 pages/goods/detail?id=123 的内部路径, + * 便于分享者在聊天里粘贴给对方识别。 + * 内部复用 common/js/uniapp.utils.js 导出的 copyText(回调形式),为保持 Promise 一致性包裹一层。 + * @param {Object} options { copySuccess: '', copyFailed: '' } + * @returns {Promise} 返回实际复制的文本 + */ + // #ifdef MP-WEIXIN + copyCurrentPath(options = {}) { + const { copySuccess = '', copyFailed = '' } = options; + const route = this.getCurrentRoute(); + // 去掉前导斜杠,保持与转发 path 兼容 + const text = (route && route.path ? route.path : '').replace(/^\//, ''); + return new Promise((resolve, reject) => { + copyText(text, { copySuccess, copyFailed }, (success, err) => { + if (success) resolve(text); + else reject(err || new Error('复制失败')); + }); + }); + }, + // #endif + //获取当前路由 + getCurrentRoute() { + let currentRoutes = getCurrentPages(); // 获取当前打开过的页面路由数组 + let currentRoute = currentRoutes[currentRoutes.length - 1].route //获取当前页面路由 + let currentParam = currentRoutes[currentRoutes.length - 1].options; //获取路由参数 + // 拼接参数 + let param = []; + for (let key in currentParam) { + param.push(key + '=' + currentParam[key]) + } + let currentPath = '/' + currentRoute; + let currentQuery = param.join('&'); + if (currentQuery) currentPath += '?' + currentQuery; + + return { + path: currentPath, + query: currentQuery, + } + }, + //获取分享路由 + getCurrentShareRoute(member_id) { + let route = this.getCurrentRoute(); + //去掉原来的分享人数据 + route.path = route.path.replace(/[?|&]source_member=\d+/, ''); + if (member_id) { + //路径的处理 + if (route.path.indexOf('?') > 0) { + route.path += '&'; + } else { + route.path += '?'; + } + route.path += 'source_member=' + member_id; + //参数的处理 + if (route.query) { + route.query += '&'; + } + route.query += 'source_member=' + member_id; + } + return route; + }, + /** + * 对象转 style 字符串 + * - 自动将 camelCase 转 kebab-case(fontSize → font-size),CSS 自定义属性(以 -- 开头)保持原样 + * - 自动过滤值为 undefined / null / '' 的项,避免输出脏数据 + * - 末尾补一个 ';',方便与其他 style 拼接 + * - 非对象入参一律返回 '' + * @param {Object} obj + * @return {String} + */ + objToStyle(obj) { + if (!obj || typeof obj !== 'object') return ''; + const s = []; + const keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]; + const v = obj[k]; + if (v === undefined || v === null || v === '') continue; + // 以 -- 开头的 CSS 自定义属性保持原样;其余 camelCase 转 kebab-case + const key = k.indexOf('--') === 0 ? k : k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()); + s.push(key + ':' + v); + } + return s.length ? s.join(';') + ';' : ''; + }, + /** + * 颜色减值 + * @param {Object} c1 + * @param {Object} c2 + * @param {Object} ratio + */ + colourBlend(c1, c2, ratio) { + ratio = Math.max(Math.min(Number(ratio), 1), 0) + let r1 = parseInt(c1.substring(1, 3), 16) + let g1 = parseInt(c1.substring(3, 5), 16) + let b1 = parseInt(c1.substring(5, 7), 16) + let r2 = parseInt(c2.substring(1, 3), 16) + let g2 = parseInt(c2.substring(3, 5), 16) + let b2 = parseInt(c2.substring(5, 7), 16) + let r = Math.round(r1 * (1 - ratio) + r2 * ratio) + let g = Math.round(g1 * (1 - ratio) + g2 * ratio) + let b = Math.round(b1 * (1 - ratio) + b2 * ratio) + r = ('0' + (r || 0).toString(16)).slice(-2) + g = ('0' + (g || 0).toString(16)).slice(-2) + b = ('0' + (b || 0).toString(16)).slice(-2) + return '#' + r + g + b + }, + /** + * 生成贝塞尔曲线轨迹 + * @param {Object} points + * @param {Object} times + */ + bezier(points, times) { + var bezier_points = []; + var points_D = []; + var points_E = []; + const DIST_AB = Math.sqrt(Math.pow(points[1]['x'] - points[0]['x'], 2) + Math.pow(points[1]['y'] - points[0][ + 'y' + ], 2)); + // 邻控制BC点间距 + const DIST_BC = Math.sqrt(Math.pow(points[2]['x'] - points[1]['x'], 2) + Math.pow(points[2]['y'] - points[1][ + 'y' + ], 2)); + // D每次在AB方向上移动的距离 + if (points[0]['x'] > points[2]['x']) { + var EACH_MOVE_AD = -(DIST_AB / times); + // E每次在BC方向上移动的距离 + var EACH_MOVE_BE = -(DIST_BC / times); + } else { + var EACH_MOVE_AD = +(DIST_AB / times); + // E每次在BC方向上移动的距离 + var EACH_MOVE_BE = +(DIST_BC / times); + } + // 点AB的正切 + const TAN_AB = (points[1]['y'] - points[0]['y']) / (points[1]['x'] - points[0]['x']); + // 点BC的正切 + const TAN_BC = (points[2]['y'] - points[1]['y']) / (points[2]['x'] - points[1]['x']); + // 点AB的弧度值 + const RADIUS_AB = Math.atan(TAN_AB); + // 点BC的弧度值 + const RADIUS_BC = Math.atan(TAN_BC); + // 每次执行 + for (var i = 1; i <= times; i++) { + // AD的距离 + var dist_AD = EACH_MOVE_AD * i; + // BE的距离 + var dist_BE = EACH_MOVE_BE * i; + // D点的坐标 + var point_D = {}; + point_D['x'] = dist_AD * Math.cos(RADIUS_AB) + points[0]['x']; + point_D['y'] = dist_AD * Math.sin(RADIUS_AB) + points[0]['y']; + points_D.push(point_D); + // E点的坐标 + var point_E = {}; + point_E['x'] = dist_BE * Math.cos(RADIUS_BC) + points[1]['x']; + point_E['y'] = dist_BE * Math.sin(RADIUS_BC) + points[1]['y']; + points_E.push(point_E); + // 此时线段DE的正切值 + var tan_DE = (point_E['y'] - point_D['y']) / (point_E['x'] - point_D['x']); + // tan_DE的弧度值 + var radius_DE = Math.atan(tan_DE); + // 地市DE的间距 + var dist_DE = Math.sqrt(Math.pow((point_E['x'] - point_D['x']), 2) + Math.pow((point_E['y'] - point_D['y']), + 2)); + // 此时DF的距离 + var dist_DF = (dist_AD / DIST_AB) * dist_DE; + // 此时DF点的坐标 + var point_F = {}; + point_F['x'] = dist_DF * Math.cos(radius_DE) + point_D['x']; + point_F['y'] = dist_DF * Math.sin(radius_DE) + point_D['y']; + bezier_points.push(point_F); + } + return { + 'bezier_points': bezier_points + }; + }, + /** + * 验证手机号 + * @param {string} mobile 被验证的mobile + * @return {object} 验证后的结果 + **/ + verifyMobile(mobile) { + var parse = /^\d{11}$/.test(mobile); + return parse; + }, + /** + * 判断是否为快应用环境 + * @returns {boolean} 是否为快应用 + */ + isQuickApp() { + // #ifndef QUICKAPP-WEBVIEW + let _isQuickApp = false; + try { + const ua = navigator?.userAgent?.toLowerCase(); + _isQuickApp = ua.indexOf('quickapp') !== -1; + + if (!_isQuickApp) { + // 特殊说明由于网站是嵌入到快应用的Webview组件中,UA中不会包含quickapp字符串, + // 但是会包含uniacid参数,所以这里通过uniacid参数来判断是否为快应用环境 + _isQuickApp = ua.indexOf('uniacid=') !== -1; + } + } catch (e) { } + + if (!_isQuickApp) { + const systemInfo = this.getDeviceInfo(); + _isQuickApp = systemInfo?.platform === 'quickapp' || process?.env?.UNI_PLATFORM === 'quickapp'; + } + return _isQuickApp; + // #endif + + return true; + }, + /** + * 判断是否为华为快应用环境 + * @returns {boolean} 是否为华为快应用 + */ + isHuaweiQuickApp() { + if (!this.isQuickApp()) return false; + + // #ifndef QUICKAPP-WEBVIEW-HUAWEI + const systemInfo = this.getDeviceInfo(); + return systemInfo.brand === 'HUAWEI' || systemInfo.manufacturer === 'HUAWEI' || + (typeof qh !== 'undefined' && qh.platform === 'huawei'); + // #endif + return true; + }, + + // #ifdef H5 + /** + * H5 → 原生(快应用 webview)通信:发送一条结构化 postMessage 给宿主 + * 与 stub-app-projects/mp-quickapp 中 onMessage 收信协议一一对应: + * 原生识别 payload.type(payload.action / payload.cmd 兼容)值: + * 'reload' / 'pullRefresh' / 'refresh' → 触发 webview.reload() + * @param {string} cmd 指令名,如 'reload' / 'share' / 'close' + * @param {Object} [extra] 附加字段,会合并进消息体(不可覆盖 type/source/t) + * @returns {boolean} 是否成功投递(非快应用环境会直接返回 false,但不抛错) + */ + notifyNativeHost(cmd, extra) { + try { + if (!cmd || typeof cmd !== 'string') return false; + if (!this.isQuickApp()) return false; + var reserved = { type: cmd, source: 'h5', t: Date.now() }; + var data = reserved; + if (extra && typeof extra === 'object') { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k) && !(k in reserved)) { + data[k] = extra[k]; + } + } + } + var payload = JSON.stringify(data); + // 主通道:window.parent.postMessage + try { + if (window.parent && window.parent !== window && typeof window.parent.postMessage === 'function') { + window.parent.postMessage(payload, '*'); + } + } catch (parentErr) { + console.error('[notifyNativeHost] parent postMessage failed:', parentErr); + } + // 兜底通道:在同窗口内派发 message 事件,部分宿主通过该路径收信 + try { + window.dispatchEvent(new MessageEvent('message', { data: payload })); + } catch (evtErr) { + console.error('[notifyNativeHost] dispatchEvent failed:', evtErr); + } + return true; + } catch (e) { + console.error('[notifyNativeHost] crashed:', e); + return false; + } + }, + // #endif + + // #ifdef H5 + /** + * 通知快应用宿主重新加载当前 webview(对应下拉刷新等场景) + * 对应原生侧 hello.ux 的 onMessage 处理 'reload' 指令 + * @returns {boolean} + */ + requestQuickAppReload() { + return this.notifyNativeHost('reload', { source: 'pullRefresh' }); + }, + // #endif + + /** + * 蛇形命名转驼峰命名 + * @param {string} str 蛇形命名字符串 + * @returns {string} 驼峰命名字符串 + */ + snakeToCamel(str) { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + }, + + /** + * 蛇形命名转驼峰命名(递归处理对象中的所有属性名) + * @param {Object} obj 包含蛇形命名字符串属性的对象 + * @returns {Object} 驼峰命名字符串属性的对象 + */ + snakeToCamelForObj(obj) { + if (typeof obj !== 'object' || obj === null) return obj; + if (Array.isArray(obj)) return obj.map(this.snakeToCamelForObj.bind(this)); + const newObj = {}; + for (const key in obj) { + if (Object.hasOwnProperty.call(obj, key)) { + const newKey = this.snakeToCamel(key); + newObj[newKey] = this.snakeToCamelForObj(obj[key]); + } + } + return newObj; + }, + + /** + * rpx 单位按端适配转换(uni-app 标准约定) + * + * 后端 formConfig / diyview 等接口返回的数值字段(label_font_size / input_height / + * row_min_height 等)一律以 rpx 传递。前端处理: + * - 微信小程序 / App / 鸿蒙元服务:原生支持 rpx,直接返回 `${val}rpx` + * - H5(WebView):浏览器不认 rpx,通过 uni.upx2px 转 px 返回 `${uni.upx2px(val)}px` + * + * 主要用于 JS 动态生成的 CSS 值(computed 里的 inline style、CSS 变量赋值)。 + * Vue 的 :style binding 对普通 CSS 属性会自动转 rpx→px,但对 CSS 自定义属性 + * (以 -- 开头的变量)不会做单位转换,因此必须经此方法处理。 + * + * @param {number|string} val 后端返回的 rpx 数值 + * 可以是数字(如 60)、字符串数字('60')、带 px 后缀的字符串('30px',原样返回) + * 或带 rpx 后缀的字符串('60rpx',先剥后缀再处理) + * @returns {string} 带单位的 CSS 值(H5 返回 'Npx',其它端返回 'Nrpx'); + * val 为空/undefined 时返回 '' + */ + rpx(val) { + if (val === undefined || val === null || val === '') return ''; + // 已带 px 后缀的,原样返回 + if (typeof val === 'string' && /px$/i.test(val.trim())) return val.trim(); + // 已带 rpx 后缀的,剥掉(避免 '60rpx' + 'rpx' → '60rpxrpx' 的双重后缀 bug) + if (typeof val === 'string' && /rpx$/i.test(val.trim())) { + val = parseFloat(val); + } + // #ifdef H5 + if (typeof uni !== 'undefined' && typeof uni.upx2px === 'function') { + return uni.upx2px(Number(val) || 0) + 'px'; + } + return (Number(val) || 0) / 2 + 'px'; + // #endif + // #ifndef H5 + return val + 'rpx'; + // #endif + }, + + /** + * 计算最长 label 宽度(rpx 单位) + * + * 与后端 backend/src/app/component/view/form/js/design.js calculateMaxLabelWidth + * 算法对齐:取所有 title 中最长字符数(必填 +1),按 fontSize(rpx)近似换算宽度。 + * 后端在 H5 中按 1rpx = 0.5px 计算宽度,再加 12 边距。 + * + * @param {Array} formData 表单数据数组(item.value.title 存在即参与计算) + * @param {number|string} labelFontSize 标签字号(rpx) + * @returns {number} 最长 label 宽度,单位 rpx + */ + calculateMaxLabelWidth(formData, labelFontSize) { + if (!formData || formData.length === 0) return 0; + const fontSize = parseInt(labelFontSize) || 28; + let maxChars = 0; + formData.forEach(item => { + if (!item.value || !item.value.title) return; + let len = (item.value.title || '').length; + if (item.value.required) len += 1; + if (len > maxChars) maxChars = len; + }); + // chars * (fontSize/2) * 1.1 + 12,单位 px → 转 rpx(×2) + const widthPx = Math.max(40, maxChars * (fontSize / 2) * 1.1 + 12); + return Math.round(widthPx * 2); + }, + +} \ No newline at end of file diff --git a/lang/en-us/ai-chat/index.js b/lang/en-us/ai-chat/index.js index 81a99f1..c3b6939 100644 --- a/lang/en-us/ai-chat/index.js +++ b/lang/en-us/ai-chat/index.js @@ -136,4 +136,8 @@ export const lang = { logInitNavbarFailed: 'Failed to initialize navigation bar', logChatComponentNotInit: 'Chat component not initialized', logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...' -} + + // ====== AI Agent general copy (customer service is the first agent) ====== + aiAgentDefaultTitle: 'AI Agent', + aiAgentOpenFailed: 'Failed to open AI Agent, please try again later', + aiAgentNotConfigured: 'This AI Agent is not enabled'} diff --git a/lang/zh-cn/ai-chat/index.js b/lang/zh-cn/ai-chat/index.js index 7f7c725..372810b 100644 --- a/lang/zh-cn/ai-chat/index.js +++ b/lang/zh-cn/ai-chat/index.js @@ -136,4 +136,8 @@ export const lang = { logInitNavbarFailed: '初始化导航栏失败', logChatComponentNotInit: '聊天组件未初始化', logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...' -} + + // ====== AI Agent 通用文案(智能客服是首个 agent) ====== + aiAgentDefaultTitle: 'AI 智能体', + aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试', + aiAgentNotConfigured: '该 AI 智能体暂未启用'} diff --git a/pages_tool/ai-chat/index.vue b/pages_tool/ai-chat/index.vue index 1952826..51edcc9 100644 --- a/pages_tool/ai-chat/index.vue +++ b/pages_tool/ai-chat/index.vue @@ -87,6 +87,9 @@ export default { return { initialMessages: [], fallbackWelcome: '', + // AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu) + agentId: 'agent_aikefu', + pageTitle: '', // --- 系统导航栏/状态栏相关 --- navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px @@ -127,6 +130,21 @@ export default { async onLoad(options) { this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome'); + // ====== AI Agent 通用入参(链接系统 -> 通用 agent_id/title) ====== + // 老客服入口不传 options 也能跑(智能客服是首个 agent,默认 agent_aikefu)。 + // 新增 AI 销售/HR/...智能体只需在 link.page_params.agent_id 传不同值, + // 当前 onShow/initChat 不区分 agent 行为,后续按业务扩展按 agentId 路由即可。 + try { + this.agentId = (options && options.agent_id) || 'agent_aikefu'; + const rawTitle = (options && options.title) || this.$lang('ai-chat.index.title'); + this.pageTitle = decodeURIComponent(rawTitle); + // 同步 agentId 给 ai-service,后续 sendMessage / WS auth 自动带新 agent_id + try { aiService.setAgentId(this.agentId); } catch (e) { /* 兼容旧 ai-service 版本 */ } + } catch (e) { + this.agentId = 'agent_aikefu'; + this.pageTitle = this.$lang('ai-chat.index.title'); + } + // ✅ 修改:取消权限校验,允许所有客服类型访问此页面 // const hasAccess = this.checkAccessPermission(); // if (!hasAccess) { @@ -134,7 +152,7 @@ export default { // return; // } - this.$langConfig.title(this.$lang('ai-chat.index.title')); + this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title')); this.initChat(); },