diff --git a/.gitignore b/.gitignore index 021a738..634879d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,9 @@ /iconfont-preview.html .codebuddy +.workbuddy .vscode -.trae +.trae* # 本地调试配置 (含 uniacid / 域名, 每人不同, 不应入仓) # 注意: 如果 .local.config.js 已经被之前的 commit 入仓, # 需要手动 `git rm --cached .local.config.js` 并用 filter-branch diff --git a/.local.config.js b/.local.config.js index 48a5fa1..564de8a 100644 --- a/.local.config.js +++ b/.local.config.js @@ -68,7 +68,7 @@ const localDevConfig = ({ uniacid: 11, domain: 'http://localhost:8050/', }, - '100': { + '100': {// 口译教学 uniacid: 100, domain: 'https://5g.aigc-quickapp.com/', }, @@ -76,15 +76,15 @@ const localDevConfig = ({ uniacid: 1, domain: 'https://5g.aigc-quickapp.com/', }, - '115': { + '115': {// 低压断路器 uniacid: 115, domain: 'https://5g.aigc-quickapp.com/', }, - '5g-11': { + '5g-11': {// 铝箔袋包装 uniacid: 11, domain: 'https://5g.aigc-quickapp.com/', }, - '5g-206': { + '5g-206': {// 气体探测 uniacid: 206, domain: 'https://5g.aigc-quickapp.com/', }, @@ -92,19 +92,19 @@ const localDevConfig = ({ uniacid: 165, domain: 'https://5g.aigc-quickapp.com/', }, - '47': { // 修改弹窗 + '47': { // 免疫细胞干细胞外泌体 ,修改弹窗 uniacid: 47, domain: 'https://5g.aigc-quickapp.com/', }, - '145': { // 表单链接转义问题 + '145': { //昆山昆创净化科技有限公司 , 表单链接转义问题 uniacid: 145, domain: 'https://5g.aigc-quickapp.com/', }, - '5g-213': { // 单图组点击链接无法跳转 + '5g-213': { // 碱性康养水天然泡茶水 , 单图组点击链接无法跳转 uniacid: 213, domain: 'https://5g.aigc-quickapp.com/', }, - '5g-4': { // 公告点击链接无法跳转 + '5g-4': { // 高端硅钢制造装备 , 公告点击链接无法跳转 uniacid: 4, domain: 'https://5g.aigc-quickapp.com/', }, @@ -116,11 +116,11 @@ const localDevConfig = ({ uniacid: 50, domain: 'https://5g.aigc-quickapp.com/', }, - '5g-237': { // 商品分类,没有立即咨询文字 + '5g-237': { //高活性氧化镁 商品分类,没有立即咨询文字 uniacid: 237, domain: 'https://5g.aigc-quickapp.com/', }, - '2847': { // + '2847': { // 家居装饰品 uniacid: 2847, domain: 'https://xcx.aigc-quickapp.com/', }, @@ -128,7 +128,7 @@ const localDevConfig = ({ uniacid: 2482, domain: 'https://xcx6.aigc-quickapp.com/', }, - '2485': { // 验证电子表单前后端数据一致性 + '2485': { // 智能物流仓储 , 验证电子表单前后端数据一致性 uniacid: 2485, domain: 'https://xcx6.aigc-quickapp.com/', }, @@ -152,11 +152,11 @@ const localDevConfig = ({ uniacid: 12, domain: 'https://test.aigc-quickapp.com', }, - '248': { // 大宗商品贸易,支持自定义字体大小、颜色 + '248': { // 工业称重系统垃圾吊电子秤,支持自定义字体大小、颜色 uniacid: 248, domain: 'https://5g.aigc-quickapp.com/', }, - '228': { // 商品分类,没有立即咨询文字 + '228': { // 反渗透阻垢剂RO装置 商品分类,没有立即咨询文字 uniacid: 228, domain: 'https://5g.aigc-quickapp.com/', }, @@ -164,7 +164,7 @@ const localDevConfig = ({ uniacid: 2797, domain: 'https://xcx.aigc-quickapp.com/', }, - '5g-95': { // 商品分类,没有立即咨询文字 + '5g-95': { // 加热器 ,商品分类,没有立即咨询文字 uniacid: 95, domain: 'https://5g.aigc-quickapp.com/', }, @@ -185,7 +185,7 @@ const localDevConfig = ({ domain: 'http://192.168.8.118:8050/', // domain: 'http://localhost:8050/', }, - '5g-59': { // 合成孔径雷达,一级分类背景字体颜色 + '5g-59': { // 脱模剂粘合剂乐瑞固Lorechem uniacid: 59, domain: 'https://5g.aigc-quickapp.com/', }, @@ -210,7 +210,7 @@ const localDevConfig = ({ uniacid: 2505, domain: 'https://xcx.aigc-quickapp.com/', }, - '3007': { // 煤矿钻机 + '3007': { // 钎焊材料 uniacid: 3007, domain: 'https://xcx.aigc-quickapp.com/', }, @@ -218,6 +218,10 @@ const localDevConfig = ({ uniacid: 317, domain: 'https://5g.aigc-quickapp.com/', }, + '5g-312': { // 天然植物功效活性物 + uniacid: 312, + domain: 'https://5g.aigc-quickapp.com/', + }, // })['5g-237']; // 商品分类,没有立即咨询文字 // })['5g-97']; // 大宗商品贸易,支持自定义字体大小、颜色 // })['5g-50']; // 燃气安全产品机器人,购物车无反应 @@ -230,10 +234,16 @@ const localDevConfig = ({ // })['2881']; // 鸡肉调理品, 优惠卷显示不全,及逻辑错误 // })['2724']; // 生物菌肥 // })['2811']; // 选择要使用的环境配置 +// })['5g-351']; //洗车机 +// })['5g-204']; //热固性塑料 +// })['5g-59']; //合成孔径雷达 +// })['2709']; //中央厨房 +// })['3007']; //钎焊材料 +// })['2505']; //煤矿钻机 // })['local-11']; // 选择要使用的环境配置 // })['2811']; // 本地 - -})['5g-317']; // 本地 +//})['5g-317']; // 电磁兼容 +})['5g-351']; // // })['local-2']; // 本地 // })['local-11']; // 本地 // })['1-test']; // test diff --git a/App.vue b/App.vue index 740e5f5..de1a252 100644 --- a/App.vue +++ b/App.vue @@ -6,6 +6,11 @@ import PrivacyManager from 'common/js/privacy.js'; import splashState from 'common/js/splash-state.js'; + // 记录 App 是否已至少展示过一次: 首次 onShow = 启动展示(由 onLaunch 完成初始化), + // 之后每一次 onShow(含微信 ... 菜单"重新进入小程序"、从后台切回)都视为"重新进入"。 + // 微信"重新进入小程序"不一定触发 onHide,所以用"是否展示过"判断更稳妥。 + let _appHasShown = false; + import { Weixin } from 'common/js/wx-jssdk.js'; @@ -491,6 +496,14 @@ }, onShow: function(options) { // #ifdef MP + // 微信小程序"重新进入小程序"(...) 等场景: onLaunch 不重跑,但模块级内存缓存仍在, + // 会让 cachedStorage / 语言包缓存继续返回进入前的旧值。首次 onShow 是启动展示, + // 之后每次 onShow(含 ... 重新进入、从后台切回前台)都视为重新进入,主动失效缓存并重新对齐。 + if (_appHasShown) { + try { Lang.onAppReentry(); } catch (e) {} + } + _appHasShown = true; + // 自动授权登录 this.getAuthInfo(); if (this.$store.state.token) { diff --git a/common/js/ai-service.js b/common/js/ai-service.js index 2584cba..b31f899 100644 --- a/common/js/ai-service.js +++ b/common/js/ai-service.js @@ -13,28 +13,10 @@ let currentConversationId = null; // - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu) // - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent) // - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由 -// 注意: 媒体上传端点按 addon 名注册 (aikefu = /api/aikefu/upload/*), 与普通接口 -// /api/kefu/* 不同名, 该差异由 common/js/media-upload.js 内部的 uploadPath() 处理。 export const DEFAULT_AGENT_ID = 'agent_aikefu'; let currentAgentId = DEFAULT_AGENT_ID; - -// 多端/多 agent 会话隔离: 不同 agent 的 conversation_id 单独存 -export const CONVERSATION_KEY_PREFIX = 'ai_conversation_id_'; -export function getConversationKey(agentId) { - return CONVERSATION_KEY_PREFIX + (agentId || DEFAULT_AGENT_ID); -} -// 兼容旧代码: 单 key 别名(指向默认 agent) -export const CONVERSATION_KEY = getConversationKey(DEFAULT_AGENT_ID); - -// 统一 SSE/WS 事件分发器 (透出所有事件类型) -let eventHandlers = { onEvent: null }; -function dispatchEvent(onEvent, event, data) { - if (typeof onEvent === 'function') { - try { onEvent(event, data); } - catch (e) { console.error('[AI Service] onEvent handler threw:', e); } - } -} +export const CONVERSATION_KEY = 'ai_conversation_id'; /** * PR-7: agent_id -> 后端 addon 路由映射 @@ -83,9 +65,9 @@ function getDomainParams() { }; } -// 初始化时从本地读取会话ID(按当前 agent 隔离) +// 初始化时从本地读取会话ID try { - const saved = uni.getStorageSync(getConversationKey(currentAgentId)); + const saved = uni.getStorageSync(CONVERSATION_KEY); if (saved) { currentConversationId = saved; } @@ -216,99 +198,41 @@ function ensureWebSocketConnected() { // 以下为聊天消息响应,只在有活跃消息时处理 if (!wsMessageActive) return; - // 优先走 onEvent 事件分发(透出 thought/tool_call/question 等) - var onEvt = eventHandlers.onEvent; - - switch (data.event) { - case 'message': { - var text = data.answer || data.content || data.text || ''; - // 兼容嵌套 data.data.content 格式 - if (!text && data.data) { - text = data.data.content || data.data.answer || ''; - } - // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 - if (text && text.startsWith('> ')) { - dispatchEvent(onEvt, 'thought', { content: text, message_id: data.message_id }); - break; - } - wsMessageContent += text; - if (wsMessageOnChunk) wsMessageOnChunk(text); - dispatchEvent(onEvt, 'message', { content: text, message_id: data.message_id }); - break; + if (data.event === 'message') { + var text = data.answer || data.content || data.text || ''; + // 兼容嵌套 data.data.content 格式 + if (!text && data.data) { + text = data.data.content || data.data.answer || ''; } - case 'thought': - case 'thinking': - case 'reasoning': { - // 兼容字段: thought / thinking / reasoning / content / answer - var thoughtText = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; - console.log('[AI WS] thought event:', data.event, 'length:', thoughtText.length, 'msgId:', data.message_id); - dispatchEvent(onEvt, 'thought', { content: thoughtText, message_id: data.message_id }); - break; - } - case 'tool_call': - dispatchEvent(onEvt, 'tool_call', data.tool_call || {}); - break; - case 'question': - dispatchEvent(onEvt, 'question', { - pending_question: data.pending_question || (data.data && data.data.pending_question), - message_id: data.message_id, - conversation_id: data.conversation_id - }); - break; - case 'message_end': - case 'done': { - // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) - var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; - console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); - // 保存会话ID(按当前 agent 隔离) - if (conversationId) { - if (conversationId !== currentConversationId) { - currentConversationId = conversationId; - try { - uni.setStorageSync(getConversationKey(currentAgentId), conversationId); - console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); - } catch (e) { - console.error('保存会话ID失败:', e); - } + wsMessageContent += text; + if (wsMessageOnChunk) wsMessageOnChunk(text); + } else if (data.event === 'message_end' || data.event === 'done') { + // 兼容两种结束信号:message_end(流结束)或 done(服务端完成) + // 兼容两种数据层级:conversation_id 可能在顶层或 data.data 中 + var conversationId = data.conversation_id || (data.data && data.data.conversation_id) || ''; + console.log('[AI WS] 流结束信号, event:', data.event, 'type:', data.type, 'conversationId:', conversationId, '当前内存:', currentConversationId); + // 保存会话ID(独立函数,不使用 this) + if (conversationId) { + if (conversationId !== currentConversationId) { + currentConversationId = conversationId; + try { + uni.setStorageSync(CONVERSATION_KEY, conversationId); + console.log('[AI WS] 会话ID已持久化到 storage:', conversationId); + } catch (e) { + console.error('保存会话ID失败:', e); } } - var content = wsMessageContent; - var convId = conversationId; - // 重置消息状态,但不断开连接 - var endPayload = { - conversation_id: convId, - message_id: data.message_id, - content: data.content || content, - thoughts: data.thoughts || '', - tool_calls: data.tool_calls || [], - tokens: data.tokens || null, - phase: data.phase || 'completed', - stop_reason: data.stop_reason || '', - effective_model: data.effective_model || '' - }; - wsMessageActive = false; - wsMessageContent = ''; - dispatchEvent(onEvt, 'message_end', endPayload); - if (wsMessageOnComplete) wsMessageOnComplete(endPayload); - if (wsMessageResolve) wsMessageResolve(endPayload); - break; } - case 'error': - dispatchEvent(onEvt, 'error', { code: data.code || -1, message: data.message || 'unknown' }); - break; - case 'ping': - // keep-alive, 忽略 - break; - default: - // 兜底: 老格式 data.data.content - if (data.data && data.data.content) { - var defText = data.data.content; - wsMessageContent += defText; - if (wsMessageOnChunk) wsMessageOnChunk(defText); - dispatchEvent(onEvt, 'message', { content: defText }); - } else { - console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); - } + var content = wsMessageContent; + var convId = conversationId; + // 重置消息状态,但不断开连接 + wsMessageActive = false; + wsMessageContent = ''; + if (wsMessageOnComplete) wsMessageOnComplete({ content: content, conversation_id: convId }); + if (wsMessageResolve) wsMessageResolve({ content: content, conversation_id: convId }); + } else { + // 兜底日志:打印所有未识别的消息类型 + console.log('[AI WS] 未识别的消息:', JSON.stringify(data).substring(0, 200)); } } catch (e) { console.error('WebSocket 消息解析失败:', e, '原始数据:', res.data); @@ -405,7 +329,6 @@ export default { response_mode: 'blocking', user: getUserId(), conversation_id: this.getConversationId() || '', - attachments: (options && options.attachments) || [], ...getDomainParams() }, header: { @@ -439,92 +362,44 @@ export default { * 流式消息入口(自动适配平台) * 微信小程序:复用长连接 WebSocket */ - async sendStreamMessage(message, onEvent, onComplete, opts = {}) { + async sendStreamMessage(message, onChunk, onComplete) { // #ifdef MP try { await ensureWebSocketConnected(); } catch (e) { - // WebSocket 不可用(真机常见: socket 合法域名未配置 / 后端 WS 未部署) - // 自动降级为 HTTP 阻塞式请求, 复用与 H5 相同的后端接口 - console.warn('[AI Service] MP WebSocket 不可用,降级为 HTTP blocking 请求:', e && e.message); - return this._fallbackToHttp(message, onEvent, onComplete, opts); + if (onComplete) onComplete({ error: e.message }); + throw e; } wsMessageContent = ''; wsMessageActive = true; - eventHandlers.onEvent = (typeof onEvent === 'function') ? onEvent : null; - // onEvent 模式: 走事件分发; 否则兼容老的 onChunk/onComplete - wsMessageOnChunk = (typeof onEvent === 'function') ? null : (onEvent || null); - wsMessageOnComplete = (typeof onEvent === 'function') ? null : (onComplete || null); + wsMessageOnChunk = onChunk || null; + wsMessageOnComplete = onComplete || null; return new Promise((resolve, reject) => { wsMessageResolve = resolve; wsMessageReject = reject; - try { - wsSocketTask.send({ - data: JSON.stringify({ - action: 'chat', - agent_id: getAgentId(), - uniacid: this.getUniacid(), - query: message, - user_id: getUserId(), - conversation_id: this.getConversationId() || '', - attachments: opts.attachments || [], - ...getDomainParams() - }) - }); - } catch (sendErr) { - console.warn('[AI Service] MP WebSocket send 失败,降级为 HTTP blocking 请求:', sendErr && sendErr.message); - // 用降级结果决定该 promise 的最终状态, 避免同时弹出错误气泡 - this._fallbackToHttp(message, onEvent, onComplete, opts) - .then((res) => resolve(res)) - .catch((err) => reject(err)); - } + wsSocketTask.send({ + data: JSON.stringify({ + action: 'chat', + agent_id: getAgentId(), + uniacid: this.getUniacid(), + query: message, + user_id: getUserId(), + conversation_id: this.getConversationId() || '', + ...getDomainParams() + }) + }); }); // #endif }, - /** - * [MP 降级] WebSocket 不可用时, 改用 HTTP 阻塞式请求完成一次完整回复 - * 只要「request 合法域名」已配置即可工作, 不依赖 socket 合法域名 - */ - async _fallbackToHttp(message, onEvent, onComplete, opts = {}) { - try { - const result = await this.sendMessage(message, { attachments: (opts && opts.attachments) || [] }); - const content = result && result.content ? result.content : ''; - // 阻塞式降级也尝试把后端返回的思考过程/用量透出来, 让小程序在 WS 不可用时仍能显示"思考过程 (N 步)" - const fallbackPayload = { - content, - conversation_id: result.conversationId, - thoughts: result.thoughts || '', - tokens: result.tokens || null - }; - if (content || result.thoughts) { - if (typeof onEvent === 'function') { - if (result.thoughts) onEvent('thought', { content: result.thoughts }); - onEvent('message', { content }); - onEvent('message_end', fallbackPayload); - } else { - if (onComplete) onComplete(fallbackPayload); - } - return fallbackPayload; - } - const emptyErr = new Error(lang.lang('aiService.serviceUnavailable')); - if (onComplete) onComplete({ error: emptyErr.message }); - throw emptyErr; - } catch (err) { - console.error('[AI Service] MP HTTP 降级请求也失败:', err); - if (onComplete) onComplete({ error: (err && err.message) || lang.lang('aiService.serviceUnavailable') }); - throw err; - } - }, - /** * HTTP 流式请求(仅 H5 使用) * PR-7: URL 按 agent_id 路由 */ - async sendHttpStream(message, onEvent, onComplete, opts = {}) { + async sendHttpStream(message, onChunk, onComplete) { // #ifdef H5 try { var chatUrl = resolveApiUrl('chat'); @@ -544,7 +419,6 @@ export default { agent_id: getAgentId(), user_id: getUserId(), conversation_id: this.getConversationId() || '', - attachments: (opts && opts.attachments) || [], ...getDomainParams() }) }); @@ -558,7 +432,6 @@ export default { let buffer = ''; let content = ''; let conversationId = ''; - const fire = (evt, payload) => { if (typeof onEvent === 'function') onEvent(evt, payload); }; while (true) { const { done, value } = await reader.read(); @@ -571,79 +444,57 @@ export default { for (const line of lines) { const trimmed = line.trim(); - if (!trimmed.startsWith('data:')) continue; - const jsonStr = trimmed.slice(5).trim(); - if (!jsonStr || jsonStr === '[DONE]') continue; - try { - const data = JSON.parse(jsonStr); - switch (data.event) { - case 'message': { - var text = data.answer || data.text || data.content || ''; - if (!text && data.data) text = data.data.content || data.data.answer || ''; - // B 路 Dify 把思考内容混在 answer 里, 以 '> ' 前缀标识 - if (text && text.startsWith('> ')) { - fire('thought', { content: text, message_id: data.message_id }); - break; + if (trimmed.startsWith('data:')) { + try { + const jsonStr = trimmed.slice(5).trim(); + if (jsonStr && jsonStr !== '[DONE]') { + const data = JSON.parse(jsonStr); + if (data.event === 'message') { + var text = data.answer || data.text || ''; + if (!text && data.data) { + text = data.data.content || data.data.answer || ''; + } + content += text; + if (onChunk) onChunk(text); } - content += text; - fire('message', { content: text, message_id: data.message_id }); - break; - } - case 'thought': - case 'thinking': - case 'reasoning': { - // 兼容字段: thought / thinking / reasoning / content / answer - var thoughtText2 = data.thought || data.thinking || data.reasoning || data.content || data.answer || ''; - fire('thought', { content: thoughtText2, message_id: data.message_id }); - break; - } - case 'tool_call': - fire('tool_call', data.tool_call || {}); - break; - case 'question': - fire('question', { - pending_question: data.pending_question || (data.data && data.data.pending_question), - message_id: data.message_id, - conversation_id: data.conversation_id - }); - break; - case 'message_end': - case 'done': { - const cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; - if (cid) conversationId = cid; - console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); - fire('message_end', { - conversation_id: cid, - message_id: data.message_id, - content: data.content || content, - thoughts: data.thoughts || '', - tool_calls: data.tool_calls || [], - tokens: data.tokens || null, - phase: data.phase || 'completed', - stop_reason: data.stop_reason || '', - effective_model: data.effective_model || '' - }); - break; - } - case 'error': - fire('error', { code: data.code || -1, message: data.message || 'unknown' }); - break; - default: - // B 路 Dify 把思考塞 answer 里 (> 前缀) - if (data.event === 'message' && data.answer && data.answer.startsWith('> ')) { - fire('thought', { content: data.answer }); - } else if (data.event === 'ping') { - // keep-alive - } else { - console.log('[AI SSE] 未识别事件:', data.event, JSON.stringify(data).substring(0, 200)); + var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; + if (cid) { + conversationId = cid; } + if (data.event === 'message_end' || data.event === 'done') { + console.log('[AI SSE] 收到完成信号:', data.event, 'conversationId:', cid); + } + } + } catch (e) { + console.warn('解析失败:', e, line); } - } catch (e) { - console.warn('解析失败:', e, line); } } } + if (buffer.trim().startsWith('data:')) { + try { + const jsonStr = buffer.trim().slice(5); + if (jsonStr) { + const data = JSON.parse(jsonStr); + if (data.event === 'message') { + var text = data.answer || data.text || ''; + if (!text && data.data) { + text = data.data.content || data.data.answer || ''; + } + content += text; + if (onChunk) onChunk(text); + } + var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; + if (cid) { + conversationId = cid; + } + } + } catch (e) { + console.warn('最后 buffer 解析失败:', e); + } + } + console.log('[AI SSE] 流完成, conversationId:', conversationId, 'content长度:', content.length); if (onComplete) { onComplete({ content, conversation_id: conversationId }); @@ -665,9 +516,7 @@ export default { success: true, content: response.data?.answer || response.data?.content || response.data?.reply || response.data, conversationId: response.data?.conversation_id, - messageId: response.data?.message_id, - thoughts: response.data?.thoughts || '', - tokens: response.data?.tokens || null + messageId: response.data?.message_id }; } else { throw new Error(response.message || lang.lang('aiService.serviceResponseError')); @@ -688,7 +537,7 @@ export default { if (id && id !== currentConversationId) { currentConversationId = id; try { - uni.setStorageSync(getConversationKey(currentAgentId), id); + uni.setStorageSync(CONVERSATION_KEY, id); } catch (e) { console.error('保存会话ID失败:', e); } @@ -701,7 +550,7 @@ export default { clearConversationId() { currentConversationId = null; try { - uni.removeStorageSync(getConversationKey(currentAgentId)); + uni.removeStorageSync(CONVERSATION_KEY); } catch (e) { console.error('清除会话ID失败:', e); } @@ -783,14 +632,6 @@ export default { async: false, }); console.log('[AI Service] getServiceConfig response:', JSON.stringify(response)); - // 媒体上传需要 config_uuid (后端校验 config_uid/config_uuid 不能为空)。 - // 此前从未写入过该缓存, 导致上传表单里 config_uuid 恒为空。 - // 这里从 info 响应中提取并持久化, 兼容多种字段名。 - const cfg = response && response.data; - if (cfg && typeof cfg === 'object') { - const uuid = cfg.config_uuid || cfg.config_uid || cfg.uuid || (cfg.info && (cfg.info.config_uuid || cfg.info.uuid)) || ''; - if (uuid) uni.setStorageSync('current_config_uuid', uuid); - } return response.data; } catch (error) { console.error('[AI Service] getServiceConfig error:', error); @@ -820,41 +661,6 @@ export default { } }, - /** - * 向 WorkBuddy Agent 回传 AskUserQuestion 的用户选项 - * @param {Object} params { conversation_id, message_id, tool_call_id, rpc_id, answers } - * @returns {Promise} - */ - async elicitResponse({ conversation_id, message_id, tool_call_id, rpc_id, answers }) { - // #ifdef MP-WEIXIN || APP-PLUS || MP-HARMONY - try { - await ensureWebSocketConnected(); - } catch (e) { - console.warn('[ai-service] elicitResponse WS 不可用:', e && e.message); - return false; - } - wsSocketTask.send({ - data: JSON.stringify({ - action: 'elicit_response', - conversation_id, - message_id, - tool_call_id, - rpc_id, - answers, - uniacid: this.getUniacid(), - token: store.state.token || 'test_token' - }) - }); - return true; - // #endif - // #ifdef H5 - // H5 无原生 WS 长连接, HTTP SSE 也无 elicit_response 端点 - // 兜底: 仅展示用户选项, 30s 超时由后端 pending_stale 兜底 - console.warn('[ai-service] H5 elicitResponse 无可用通道, 仅展示用户选项 + 超时由 pending_stale 兜底'); - return false; - // #endif - }, - /** * 获取会话历史 * PR-7: URL 按 agent_id 路由 @@ -1008,46 +814,6 @@ export default { if (oldAddon !== newAddon) { try { disconnectWebSocket(); } catch (e) { /* ignore */ } } - // 切 agent 后恢复该 agent 的会话 ID (per-agent 隔离) - currentConversationId = null; - try { - const saved = uni.getStorageSync(getConversationKey(currentAgentId)); - if (saved) currentConversationId = saved; - } catch (e) { /* ignore */ } - }, - - /** - * 获取当前 agent 的会话存储 key (per-agent 隔离) - */ - getConversationKey(agentId) { - return getConversationKey(agentId); - }, - - /** - * 获取全部 agent 的会话 ID 映射 (用于会话列表, 可选) - */ - getAllConversations() { - const out = {}; - try { - const info = uni.getStorageInfoSync(); - (info.keys || []).filter(k => k.indexOf(CONVERSATION_KEY_PREFIX) === 0) - .forEach(k => { out[k.substring(CONVERSATION_KEY_PREFIX.length)] = uni.getStorageSync(k); }); - } catch (e) { /* ignore */ } - return out; - }, - - /** - * 获取当前 config_uuid (媒体上传路由用) - */ - getCurrentConfigUuid() { - return uni.getStorageSync('current_config_uuid') || ''; - }, - - /** - * 获取用户 ID (媒体上传字段用) - */ - getUserId() { - return getUserId(); }, /** diff --git a/common/js/lang-loader.js b/common/js/lang-loader.js index 7d7316e..98aa425 100644 --- a/common/js/lang-loader.js +++ b/common/js/lang-loader.js @@ -284,6 +284,21 @@ export async function loadBundleTree(options = {}) { const version = String(payload.version || ''); const sign = payload.sign || ''; + // 诊断(真机定位用): tree 顶层结构不完整(非对象/为空/缺 common 或 components)时, + // 打印实际拿到的 tree 顶层 key + 请求上下文, 便于排查为什么 isBundleComplete 失败 + // (真机上若看到这条 warn, 说明服务端返回了残缺 bundle, 这才是英文回退/ toast 的根因) + if (!tree || typeof tree !== 'object' || !tree.common || !tree.components) { + console.warn( + '[lang-loader] bundle tree INCOMPLETE. ' + + 'topLevelKeys=' + JSON.stringify(Object.keys(tree || {})) + + ' lang=' + lang + + ' appType=' + appType + + ' uniacid=' + uniacid + + ' version=' + version + + ' hasPayloadTree=' + ((payload && payload.tree) !== undefined) + ); + } + // 4) 签名校验(可选) const secret = langConfig.remote.signSecret || ''; const signValid = validateSign(tree, version, sign, secret); diff --git a/common/js/lang.js b/common/js/lang.js index 6735224..4669648 100644 --- a/common/js/lang.js +++ b/common/js/lang.js @@ -628,6 +628,26 @@ function loadLangPackSync(lang, path) { * 获得当前本地语言 * @returns */ +/** + * 语言切换开关钩子(由后台 shop_info.ischia 决定,经 store/index.js 的 setShopInfo 写入)。 + * + * 设计原则: 只"在关闭时清理持久化语种", 绝不在开启态加任何门控 —— 否则一旦 ischia + * 取值/语义判断偏差, 就会误伤"开启双语后切换英文"的正常行为(回归)。 + * + * 行为: + * - 后台关闭双语/小程序(ischia 为假): 清掉用户此前持久化到 storage 的语种 + 缓存, + * 使刷新/重新进入时 getCurrentLocale() 回退默认中文, 满足 + * "后台关闭双语后, 刷新重新进入必须是中文" 的需求。 + * - 后台开启双语(ischia 为真): 不做任何拦截, 完全保持原有"切换英文"行为不变。 + */ +function setLangSwitchEnabled(enabled) { + if (!enabled) { + // 关闭语言切换: 清掉持久化语种 + 缓存, 强制回到默认中文 + invalidateStorageCache('lang'); + try { uni.removeStorageSync('lang'); } catch (e) {} + } +} + function getCurrentLocale() { // 关键:使用 cachedStorageGet,防止 uni.getStorageSync 触发 Vue 事件导致 lang() 递归 const val = cachedStorageGet('lang', null); @@ -973,6 +993,13 @@ export default { */ getCurrentLocale, + /** + * 语言切换功能开关(后台 shop_info.ischia 决定)。 + * 关闭后 getCurrentLocale() 强制返回默认语言(中文),并清掉已持久化的语种。 + * 详见本文件 setLangSwitchEnabled 函数与 store/index.js 的 setShopInfo 钩子。 + */ + setLangSwitchEnabled, + /** * 从后端数据中提取当前语言的文案(向后兼容) * 优先使用 xxx_map 多语言 map,没有则回退到 xxx / en_xxx 二元模式 @@ -1054,6 +1081,13 @@ export default { // force 失败时 loadBundleTree 内部直接抛错(不回退 cache), 让这里进入 catch let isComplete1 = isBundleComplete(result.tree); if (!isComplete1) { + // [debug] 首次拉取的 bundle 不完整: 打印顶层 key, 便于定位远端是否缺 common/components + try { + console.warn('[i18n:boot] bundle incomplete (1st fetch). ' + + 'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid + + ', source=' + (result && result.source) + + ', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {}))); + } catch (e) {} // 强制重试前清掉所有 cache, 避免 force 路径残留旧数据 delete remoteTreeCache[_preloadMk]; result = await loadBundleTree({ lang, appType, uniacid, force: true }); @@ -1064,6 +1098,13 @@ export default { // 不加载本地 lang 兜底(remote-only 模式, 本地 lang 已被 webpack 排除) let isComplete2 = isBundleComplete(result.tree); if (!isComplete2) { + // [debug] force 重试后仍不完整: 打印顶层 key, 确认远端确实返回残缺 tree + try { + console.warn('[i18n:boot] bundle incomplete (after force retry). ' + + 'lang=' + lang + ', appType=' + appType + ', uniacid=' + uniacid + + ', source=' + (result && result.source) + + ', topKeys=' + JSON.stringify(Object.keys(result && result.tree || {}))); + } catch (e) {} throw new Error('lang bundle unusable after force retry'); } @@ -1103,10 +1144,14 @@ export default { setRemoteStatus('fail', { reason: _reason, lang }); bootCompleted = true; emitUpdated(lang); + // 注意: 这里已用缓存兜底成功(UI 可用), 不应再标 status:'fail', + // 否则 App.vue 会弹"Failed to load i18n"误报 toast。 + // remoteStatus 仍保持 'fail'(上方已 setRemoteStatus), 用于离线指示。 return { ok: true, source: 'cache', - status: 'fail', + status: 'ok', + degraded: true, reason: _errMsg || 'fallback to cache', lang, cacheAge: cached.ts ? Date.now() - cached.ts : 0, @@ -1176,6 +1221,42 @@ export default { loadOverrides(lang); }, + /** + * 微信小程序"重新进入小程序"(右上角 ... 菜单)场景调用: + * 该操作销毁页面栈重新进首页,但小程序 JS 上下文往往不重置(热重启), + * 于是 App.onLaunch(里面跑 boot 全量初始化)不会重跑,而模块级内存缓存仍在: + * - _storageResultCache(cachedStorage): 可能返回进入前缓存的 lang/uniacid + * - _mergedLangCache / _fieldResultCache: 沿用进入前的翻译结果 + * - remote-only 模式下远端 bundle 不会被重新拉取 + * 这里主动失效所有内存缓存,并重新对齐远端 bundle,使重新进入后按当前 + * storage 的 locale 重新读取并合并,不再沿用旧值。 + * 注意: 首次 onShow(启动展示)不应调用本方法,App.vue 负责区分。 + */ + onAppReentry() { + // [debug] 验证微信"重新进入小程序"是否真的命中本分支:真机复现后搜 i18n:reentry + try { + console.log('[i18n:reentry] onAppReentry triggered, IS_REMOTE_ONLY=' + IS_REMOTE_ONLY + + ', beforeInvalidateLang=' + (cachedStorageGet('lang', null)) + + ', beforeInvalidateUniacid=' + (cachedStorageGet('uniacid', null))); + } catch (e) {} + + // 1) cachedStorage 失效: 下次 cachedStorageGet 必须重新读 storage(locale/uniacid) + invalidateStorageCache(); + // 2) 语言包合并/字段缓存失效,并 bump _overrideVersion → lang() 用当前 locale 重新合并 + invalidateLangCaches(); + // [debug] 失效后立刻重新读,确认 cachedStorage 已重新走 storage(而非命中旧缓存) + try { + console.log('[i18n:reentry] after invalidate, freshLang=' + getCurrentLocale() + + ', freshUniacid=' + getCurrentUniacid()); + } catch (e) {} + // 3) remote-only: 重新进入时 onLaunch 不重跑,这里重新拉当前 locale 的 bundle 并推送重渲染 + if (IS_REMOTE_ONLY) { + this.boot(); + } else { + this.refresh(true); + } + }, + /** * * 订阅远端状态变化(status: 'unknown' | 'ok' | 'fail', detail.reason 有 fail 分类) * @param {(status:string, detail:object) => void} fn diff --git a/common/js/media-upload.js b/common/js/media-upload.js new file mode 100644 index 0000000..004a9ba --- /dev/null +++ b/common/js/media-upload.js @@ -0,0 +1,325 @@ +// common/js/media-upload.js +// ai-chat / ai-agent 媒体上传: 图片 (PHP 中转 + STS 直传) / 音视频 / 其他文件 +// +// PR-FRONTEND-MEDIA-UPLOAD (2026-09-16): 之前 ai-chat-tools-panel.vue 引用了 +// uploadImage / uploadMedia, 但本文件从未落库, 选图 / 选文件立即抛 +// "Cannot find module '@/common/js/media-upload.js'". 此次按 +// docs/ai-agent-integration.md §3.4 Step 1 落地, 顺手补 store import (设计稿漏). +// +// 调用方: +// ai-chat-tools-panel.vue → selectImage() / _uploadOneFile() +// ai-chat-message.vue → 直接用 aiService.sendMessage 传 attachments (本模块不出场) +// +// 上传策略: +// 图片: 优先 PHP 中转 (≤2MB 走 backend), 超过或商家选了 oss_sts/auto → STS 直传 +// 音视频/文件: 一律 STS 直传 (体积大概率 >2MB) +// +// 端点: +// PHP 中转: POST //api/upload/ multipart/form-data +// STS 凭证: POST //api/upload/ossSts application/json +// STS 直传: PUT / 云存储协议 (4-field STS 或 single-URL) + +import aiService from './ai-service.js' +import Config from './config.js' +import store from '@/store/index.js' + +const SIZE_LIMIT_PHP_PASSTHROUGH = 2 * 1024 * 1024 // 2MB + +/** + * 上传图片 (type=image) + * @param {string} filePath - uni.chooseImage 返回的 tempFilePaths[0] + * @returns {Promise<{url, mime, size, name, kind, used_strategy}>} + */ +export async function uploadImage(filePath) { + const addon = aiService.getAddonName() // 'aikefu' | 'aiagent' + const config = await aiService.getServiceConfig() + const strategy = config?.upload_strategy || 'backend' + + // H5 端: >10MB 先压 + let compressedPath = filePath + // #ifdef H5 + const fileSize = await getFileSize(filePath) + if (fileSize > 10 * 1024 * 1024) { + try { + const compressed = await uni.compressImage({ + src: filePath, + quality: 80, + compressedWidth: 2048, + compressedHeight: 2048, + }) + compressedPath = compressed.tempFilePath || filePath + } catch (e) { + console.warn('[media-upload] compressImage failed, use original:', e) + } + } + // #endif + + // 检查是否需要走 STS (>2MB 或商家选 oss_sts) + const fileSizeFinal = await getFileSize(compressedPath) + const useSts = strategy === 'oss_sts' + || (strategy === 'auto' && fileSizeFinal > SIZE_LIMIT_PHP_PASSTHROUGH) + + if (useSts) { + return await uploadViaSts(compressedPath, 'image', addon, aiService.getAgentId(), config) + } else { + return await uploadViaPhp(compressedPath, 'image', addon, aiService.getAgentId()) + } +} + +/** + * 上传 audio / video / file (一律 STS, 因为体积大概率 >2MB) + * @param {string} filePath + * @param {'audio'|'video'|'file'} kind + */ +export async function uploadMedia(filePath, kind) { + if (!['audio', 'video', 'file'].includes(kind)) { + throw new Error(`invalid kind: ${kind}`) + } + const addon = aiService.getAddonName() + const config = await aiService.getServiceConfig() + return await uploadViaSts(filePath, kind, addon, aiService.getAgentId(), config) +} + +/** + * PHP 中转 (multipart/form-data) + * 端点必须用 addon 模式 URL: //api/upload/ + * 注释: InitRoute 注册 aikefu/api/upload 时挂在 addon namespace 下, + * /api/aikefu/upload/* 才是命中路径; /api/kefu/upload/* 不命中 + * (那是普通接口命名空间, 上传走的是 addon 专属路由) + */ +async function uploadViaPhp(filePath, kind, addon, agentId) { + const url = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + + (kind === 'image' ? 'image' : 'file') + + // #ifdef MP-WEIXIN + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: Config.baseUrl + url, + filePath, + name: 'file', + formData: { + kind, + agent_id: addon === 'aiagent' ? agentId : undefined, + config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, + uniacid: aiService.getUniacid(), + user_id: aiService.getUserId?.() || '', + }, + success: (res) => { + try { + const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data + if (data.code === 0 || data.success) { + resolve({ + url: data.data.url, + mime: data.data.mime, + size: data.data.size, + name: data.data.name, + kind: data.data.kind || kind, + used_strategy: data.data.used_strategy || 'backend', + oss_sts_required: data.data.oss_sts_required || false, + }) + } else { + reject(new Error(data.msg || data.message || '上传失败')) + } + } catch (e) { + reject(new Error('解析响应失败: ' + e.message)) + } + }, + fail: (err) => reject(new Error('网络错误: ' + (err.errMsg || err.message))), + }) + }) + // #endif + + // #ifdef H5 + const form = new FormData() + const file = await pathToFile(filePath, kind) + form.append('file', file) + form.append('kind', kind) + if (addon === 'aiagent') form.append('agent_id', agentId) + else form.append('config_uuid', aiService.getCurrentConfigUuid?.() || '') + form.append('uniacid', aiService.getUniacid()) + form.append('user_id', aiService.getUserId?.() || '') + const res = await fetch(Config.baseUrl + url, { + method: 'POST', + headers: { 'token': store.state.token || '' }, + body: form, + }) + const data = await res.json() + if (data.code === 0 || data.success) { + return { + url: data.data.url, + mime: data.data.mime, + size: data.data.size, + name: data.data.name, + kind: data.data.kind || kind, + used_strategy: data.data.used_strategy || 'backend', + oss_sts_required: data.data.oss_sts_required || false, + } + } + throw new Error(data.msg || data.message || '上传失败') + // #endif +} + +/** + * STS 直传 (拿临时凭证 → 直传云存储) + * + * PR-OSS-REGISTRY (2026-09-15) 后端支持 12 个 provider, 协议层分两类: + * A) 4-field STS (8 个: alioss/qiniu/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) + * → 前端拼 PUT URL, Authorization header = STS : + * B) single-URL (4 个: huaweiobs/volcengine/awsoss/minio) + * → sts.accessKeyId 已是完整预签 PUT URL, sts.securityToken='-SIGNED' + * 标记, 不带 Authorization + * + * 后端 stsResp.data.data 可能字段: + * { provider, bucket, region, accessKeyId, accessKeySecret, securityToken, + * key_prefix_tpl, site_id, host? (single-URL 模式), endpoint? (qiniu 7 参兼容) } + */ +async function uploadViaSts(filePath, kind, addon, agentId, config) { + // 1. 拿 STS 凭证 (addon 模式 URL) + const stsUrl = (addon === 'aikefu' ? '/aikefu/api/upload/' : '/aiagent/api/upload/') + 'ossSts' + const stsResp = await uni.request({ + url: Config.baseUrl + stsUrl, + method: 'POST', + header: { + 'Content-Type': 'application/json', + 'token': store.state.token || '' + }, + data: { + agent_id: addon === 'aiagent' ? agentId : undefined, + config_uuid: addon === 'aikefu' ? (aiService.getCurrentConfigUuid?.() || '') : undefined, + uniacid: aiService.getUniacid(), + user_id: aiService.getUserId?.() || '', + }, + }) + if (stsResp.statusCode !== 200 || (stsResp.data.code !== 0 && !stsResp.data.success)) { + throw new Error(stsResp.data?.msg || stsResp.data?.message || 'STS 凭证获取失败') + } + const sts = stsResp.data.data + const provider = sts.provider || 'alioss' // 老后端默认 alioss + + // 2. 拼 PUT URL (用后端返回的 key_prefix_tpl 替换占位符) + const ext = (filePath.split('.').pop() || 'bin').toLowerCase() + const uuid = 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0 + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16) + }) + const now = new Date() + const key = (sts.key_prefix_tpl || 'site/{site_id}/{yyyy}/{mm}/{dd}/{rand}') + .replace('{site_id}', sts.site_id || '0') + .replace('{agent_id}', agentId || '0') + .replace('{yyyy}', now.getFullYear().toString()) + .replace('{mm}', String(now.getMonth() + 1).padStart(2, '0')) + .replace('{dd}', String(now.getDate()).padStart(2, '0')) + .replace('{rand}', uuid) + + '.' + ext + + // ★ PR-OSS-REGISTRY (2026-09-15): provider 分流 + // single-URL 模式: securityToken 末尾是 -SIGNED (OBS-SIGNED / TOS-SIGNED / AWS-SIGNED / MINIO-SIGNED) + const isSingleUrlMode = typeof sts.securityToken === 'string' + && /-(SIGNED)$/.test(sts.securityToken) + + let ossUrl, headers + if (isSingleUrlMode) { + // B) single-URL: sts.accessKeyId 是完整预签 PUT URL, 不需要 Authorization header + ossUrl = sts.accessKeyId + headers = { 'Content-Type': guessMime(ext) } + } else if (provider === 'qiniu') { + // A) qiniu 兼容模式: sts.accessKeyId = uptoken, endpoint 来自 sts.endpoint 或 sts.host + ossUrl = (sts.endpoint || sts.host || `https://upload-${sts.region}.qiniup.com`) + '/' + key + headers = { + 'Authorization': 'UpToken ' + sts.accessKeyId, + 'Content-Type': guessMime(ext), + } + } else { + // A) 4-field STS 默认 (alioss/txcos/baidubos/qingstor/jdoss/ucloud/wyoss) + // endpoint 模板按 provider 区分, 后端未返 endpoint 时按 OSS 模板拼 + const tpl = (provider === 'alioss') ? `https://${sts.bucket}.${sts.region}.aliyuncs.com` + : (provider === 'txcos') ? `https://${sts.bucket}-${sts.region}.cos.${sts.region}.myqcloud.com` + : (provider === 'baidubos') ? `https://${sts.bucket}.${sts.region}.bcebos.com` + : (provider === 'jdoss') ? `https://${sts.bucket}.${sts.region}.jdcloud-oss.com` + : (provider === 'wyoss') ? `https://${sts.bucket}.${sts.host || sts.region}` + : (sts.endpoint ? sts.endpoint.replace('{bucket}', sts.bucket) : `https://${sts.bucket}.${sts.region}`) + ossUrl = `${tpl}/${key}` + headers = { + 'Authorization': `STS ${sts.accessKeyId}:${sts.accessKeySecret}`, + 'x-oss-security-token': sts.securityToken, + 'Content-Type': guessMime(ext), + } + } + + // 3. 直传云存储 + // #ifdef MP-WEIXIN + await new Promise((resolve, reject) => { + uni.uploadFile({ + url: ossUrl, + filePath, + name: 'file', + formData: {}, + header: headers, + success: (res) => { + if (res.statusCode >= 200 && res.statusCode < 300) resolve() + else reject(new Error(`${provider} PUT ${res.statusCode}`)) + }, + fail: reject, + }) + }) + // #endif + // #ifdef H5 + const file = await pathToFile(filePath, kind) + const ossRes = await fetch(ossUrl, { + method: 'PUT', + headers, + body: file, + }) + if (!ossRes.ok) throw new Error(`${provider} PUT ${ossRes.status}`) + // #endif + + // 4. 返回公网 URL + return { + url: ossUrl.split('?')[0], // single-URL 模式 URL 带 ?<签名参数>, 拼 chat attachment 用 base URL + mime: guessMime(ext), + size: file.size || 0, + name: key.split('/').pop(), + kind, + used_strategy: 'oss_sts', + provider, + } +} + +async function getFileSize(filePath) { + // #ifdef MP-WEIXIN + return new Promise((resolve) => { + uni.getFileInfo({ + filePath, + success: (res) => resolve(res.size), + fail: () => resolve(0), + }) + }) + // #endif + // #ifdef H5 + try { + const res = await fetch(filePath) + const blob = await res.blob() + return blob.size + } catch (e) { return 0 } + // #endif +} + +async function pathToFile(filePath, kind) { + // #ifdef H5 + const res = await fetch(filePath) + const blob = await res.blob() + return new File([blob], filePath.split('/').pop() || `${kind}.bin`, { type: blob.type }) + // #endif +} + +function guessMime(ext) { + const map = { + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', + mp3: 'audio/mpeg', wav: 'audio/wav', aac: 'audio/aac', + mp4: 'video/mp4', mov: 'video/quicktime', + pdf: 'application/pdf', txt: 'text/plain', + } + return map[ext] || 'application/octet-stream' +} + +export default { uploadImage, uploadMedia } \ No newline at end of file diff --git a/common/js/uniapp.utils.js b/common/js/uniapp.utils.js index 5344c44..045e801 100644 --- a/common/js/uniapp.utils.js +++ b/common/js/uniapp.utils.js @@ -5,24 +5,6 @@ import lang from './lang.js'; -/** - * 判断是否为「微信剪贴板隐私声明缺失」导致的复制失败 - * - * 微信自 2023-09-15 起把剪贴板接口纳入《小程序用户隐私保护指引》, - * 若小程序后台未声明「剪贴板」(或声明未审核通过),调用会返回: - * { errno: 112, errMsg: 'setClipboardData:fail api scope is not declared in the privacy agreement' } - * 这是平台侧配置问题,不改代码也能复现,因此不能把原始英文 errMsg 直接展示给用户。 - * - * @param {*} err uni.setClipboardData 的 fail 回调参数 - * @returns {Boolean} true=剪贴板隐私声明缺失 - */ -export const isClipboardPrivacyError = (err) => { - if (!err) return false; - if (Number(err.errno) === 112) return true; - const msg = `${err.errMsg || err.message || ''}`; - return /privacy agreement/i.test(msg) || /api scope is not declared/i.test(msg); -}; - /** * 显示错误信息 * @param {Exception} err @@ -115,12 +97,8 @@ export const copyTextAsync = (text, { copySuccess = '', copyFailed = '' } = {}) fail: (err) => { clearTimeout(timeoutId); try { - // 剪贴板隐私声明缺失(errno 112)属平台配置问题, 用可读文案替代微信原始英文错误 - const tip = isClipboardPrivacyError(err) - ? lang.lang('uniappUtils.clipboardPrivacyNotDeclared') - : (err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed')); uni.showToast({ - title: tip, + title: err.message || err.errMsg || copyFailed || lang.lang('uniappUtils.copyFailed'), icon: 'none', duration: 2000 }); diff --git a/components-diy/diy-category-item.vue b/components-diy/diy-category-item.vue index 123d7ef..4fe9db6 100644 --- a/components-diy/diy-category-item.vue +++ b/components-diy/diy-category-item.vue @@ -98,7 +98,8 @@ - + {{ category.category_name }} @@ -139,8 +140,51 @@ - - + + + + {{ category.category_name }} + + + + + + {{ item.label_name }} + + + + {{ isEnEnv ? item.en_goods_name : item.goods_name }} + + + + + {{ $lang('common.currencySymbol') }} + {{ parseFloat(showPrice(item)).toFixed(2).split('.')[0] }} + .{{ parseFloat(showPrice(item)).toFixed(2).split('.')[1] }} + + + + {{ $lang('common.currencySymbol') }} + {{ showMarketPrice(item) }} + + + + {{ $lang('components-diy.diy-category-item.consultNow') }} + + + + + + + 暂无商品 + + + + + {{ category.category_name }} - - - - {{ one.category_name }} - - - - - - {{ two.category_name }} - - - - + - + {{ category.category_name }} @@ -617,13 +647,13 @@ export default { * 三级分类 (value.level == 3) → true → 按二级分类分别显示各自商品 */ isGroupedDisplay() { + // 仅模板1 走"按二级分类分组显示商品"; 模板5 的层级由 value.level 直接决定(见下方 value.template == 5 分支) return this.value.template == 1 && this.value.level == 3; }, /** - * 模板1三级分类: 渲染所有二级分类(包括无商品的空分类) - * 不再按"是否有商品"过滤, 保证一级分类下的二级分类全部显示, - * 避免 10 个二级分类只显示 8 个(2 个被当成空分类过滤掉)的问题 - * 加载中(template1ChildLoading[id] === true)的二级分类暂不渲染, 避免闪烁 + * 模板1三级分类: 只渲染"有商品"的二级分类 + * 无商品的二级分类整块(标题 + 网格)都不渲染; 加载中的二级分类也暂不渲染(避免闪烁) + * 判定时机: 该二级分类加载完成(template1ChildLoading[id] === false)且商品列表非空 */ filteredGroupedChildren() { if (!this.isGroupedDisplay) return []; @@ -631,8 +661,9 @@ export default { return this.category.child_list.filter(one => { // 还在加载中: 暂不渲染(避免闪烁) if (this.template1ChildLoading[one.category_id]) return false; - // 加载完成(无论是否有商品)都渲染, 无商品时显示空状态 - return true; + // 加载完成: 只有该二级分类下有商品时才显示, 无商品则整块隐藏 + const list = this.template1ChildGoodsList[one.category_id]; + return !!(list && list.length); }); }, /** @@ -642,7 +673,7 @@ export default { * 若 type 不跟随更新, 模板1 会误走 goods 分支, 导致"资讯类商品=否"的商品不显示立即咨询按钮 */ type() { - return this.value.template == 1 || this.value.template == 99 ? 'category' : 'goods'; + return this.value.template == 1 || this.value.template == 5 || this.value.template == 99 ? 'category' : 'goods'; }, }, created() { @@ -664,7 +695,14 @@ export default { // 模板1:根据后台"展示分类"配置 + 数据层级决定加载方式 // 三级分类模式 (isGroupedDisplay) + 有二级分类时,按二级分类分别加载各自商品 // 其他情况(二级分类模式 / 无二级分类),直接加载当前一级分类下的所有商品 - if (this.value.template == 1 && this.category && this.category.category_id) { + // 模板5 渲染规则: + // 二级分类(value.level == 2) → 商品网格(图2), 需要加载该一级分类下所有商品(合并其二级分类) + // 三级分类(value.level == 3) → 二级分类网格(图3), 静态网格无需加载商品 + // 若该一级分类下没有子分类(兜底), 仍需要加载商品 + const template5NeedGoods = this.value.template == 5 && + (this.value.level == 2 || + !(this.category && this.category.child_list && this.category.child_list.length)); + if ((this.value.template == 1 || template5NeedGoods) && this.category && this.category.category_id) { if (this.isGroupedDisplay && this.category.child_list && this.category.child_list.length) { this.category.child_list.forEach(child => { this.getTemplate1ChildGoods(child.category_id); @@ -739,6 +777,8 @@ export default { } // 模板1:切换一级分类时重新加载该分类下的商品 // 三级分类模式 + 有二级分类时,按二级分类分别重新加载,否则直接加载当前分类 + // 注意: 模板5 是"全部分类纵向平铺", 商品由 created 首次加载 + 父级链式懒加载提供, + // 滚动时 select 会随 listenScroll 变化, 若此处也重载会导致滚动中反复清空/重复请求, 故排除 5 if (this.value.template == 1 && this.index == this.select && this.category && this.category.category_id) { this.template1GoodsList = []; this.template1ChildGoodsList = {}; @@ -1489,42 +1529,42 @@ export default { } } +// 分类网格(模板5 / legacy 商品分类): 白底卡片 + 3 列 图片 + 名称 +// 与 Branch_5cfee417 的商品分类保持一致 .category-list { - display: grid !important; - grid-template-columns: repeat(3, 1fr) !important; - background: transparent; + display: flex; + flex-wrap: wrap; + background: #fff; margin: 0 20rpx; - border-radius: 0; - padding: 0; - gap: 20rpx 0; + border-radius: 10rpx; + padding: 10rpx 0; .category-item { - width: 100% !important; + width: 33.33%; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 0; + padding: 16rpx; box-sizing: border-box; .img-box { - width: 70% !important; + width: 80%; height: 0; - padding-bottom: 70%; + padding-top: 80%; margin: 0 auto; overflow: hidden; - margin-bottom: 16rpx; + margin-bottom: 20rpx; position: relative; - background: transparent !important; - border-radius: 0 !important; + background: transparent; image { position: absolute; - width: 100% !important; - height: 100% !important; - left: 0 !important; - top: 0 !important; - transform: none !important; + width: 100%; + height: 100%; + left: 0; + top: 0; + transform: none; } } @@ -1539,7 +1579,6 @@ export default { font-size: var(--diy-category-item-name-size, 24rpx); } - // 模板1选中状态 &.selected { .img-box { border: 2rpx solid var(--base-color, #FF6B35); diff --git a/components-diy/diy-category.vue b/components-diy/diy-category.vue index 4664df9..c8faf84 100644 --- a/components-diy/diy-category.vue +++ b/components-diy/diy-category.vue @@ -122,7 +122,7 @@ { select: select == index }, { 'border-bottom': value.template == 4 && select + 1 === index }, { 'border-top': value.template == 4 && select - 1 === index }, - { 'first-category-item': index == 0 && (value.template == 99 || value.template == 1) } + { 'first-category-item': index == 0 && (value.template == 99 || value.template == 1 || value.template == 5) } ]" @tap.stop="switchOneCategory(index)"> {{ item.category_name }} @@ -152,11 +152,44 @@ + @saveSecondCategory="saveSecondCategoryName"> - + - + + + + + + + + + + + + {{ $lang('components-diy.diy-category.reachedBottom') }} + + + { if (!this.heightArea.length) this.getHeightArea(-1); + // 分类渲染完成后重新对齐到当前选中的一级分类顶部, + // 避免首屏第一个分类的标题被顶出可视区 + if (this.categoryTree && this.categoryTree.length) this.alignToCategory(this.select); }); } } @@ -536,8 +573,30 @@ export default { switchOneCategory(index) { if (index >= this.categoryTree.length) return; this.select = index; - this.categoryId = 'category-' + index; this.scrollLock = true; + this.alignToCategory(index); + }, + /** + * 把右侧滚动区定位到指定一级分类的顶部 + * + * uni-app 只在 scroll-into-view 的值"发生变化"时才重新滚动, + * 而 categoryId 的初值就是 'category-0', 且只有点击左侧分类才会被改写; + * 用户在右侧手动滚动后 categoryId 仍是 'category-0', 此时再点第一个分类(index 0) + * 值时没有变化, 右侧不会回滚到该分类, 该分类的标题就被顶出可视区 + * (看起来像"标题丢了 / 卡片被切了一半"), 而点其它分类因为值变了所以都正常。 + * 这里先置空、下一帧再设置目标 id, 保证每次点击都能重新定位到分类顶部。 + * @param {Number} index 一级分类下标 + */ + alignToCategory(index) { + const target = 'category-' + index; + if (this.categoryId === target) { + this.categoryId = ''; + this.$nextTick(() => { + this.categoryId = target; + }); + } else { + this.categoryId = target; + } }, saveSecondCategoryName(name) { this.lastSecondCategoryName = name; @@ -559,7 +618,10 @@ export default { break; } } - if (this.value.template != 1 && this.value.loadType == 'all' && this.heightArea[this.select][1] - + // 模板5 三级分类为静态分类网格平铺, 不需要懒加载商品; 这里只保留 loadType=all 的懒加载, + // 并补上 heightArea[this.select] 越界保护 + if (this.value.template != 1 && this.value.loadType == 'all' && + this.heightArea[this.select] && this.heightArea[this.select][1] - scrollTop - contentWrapHeight < 300) { if (Array.isArray(this.$refs.categoryItem) && this.$refs.categoryItem[this.select]) this.$refs.categoryItem[this.select].getGoodsList(); } @@ -1674,6 +1736,134 @@ export default { } } +// ===================== 模板5 商品分类样式(新增: 基于模板1双栏布局) ===================== +.category-template-5 { + .search-box { + padding: 20rpx 24rpx; + background: #fff; + + .search-content { + background-color: #f5f5f5; + border-radius: 40rpx; + + input { + background: #f5f5f5; + border-radius: 40rpx; + padding: 0 30rpx; + font-size: 26rpx; + color: #333; + } + + .iconfont { + right: 16rpx; + color: #89899a; + font-size: 32rpx; + } + } + } + + .content-box { + // 模板5 + 三级分类: 标题行所在整片右半边去掉白色, 与模板1一致 + background: transparent; + + .tree-wrap { + width: 170rpx; + background-color: #fff; + } + + .right-flex-wrap { + background: #f5f5f5; + + .content-wrap { + background: transparent; + } + + // 模板5 纵向平铺: 每个一级分类独占一行(与模板99"全部分类"结构一致) + .child-category { + width: 100%; + box-sizing: border-box; + } + } + } + + // 穿透到子组件 diy-category-item: 调整 banner 横向内边距 + 分类网格容器为透明 + ::v-deep .category-adv { + padding: 0 20rpx; + margin-top: 0; + } + + // 模板5: .category-list 直接沿用子组件的 legacy 白底卡片样式(不再覆盖为透明) + + // 穿透到子组件 diy-category-item: 模板5标题行定高, 与左侧第一个一级分类等高对齐 + ::v-deep .category-title-with-lines { + height: 100rpx; + box-sizing: border-box; + padding: 30rpx 0 20rpx; + } + + // 模板5: 左侧第一个一级分类定高, 与右侧标题行垂直对齐 + .tree-wrap .category-item.first-category-item { + height: 100rpx; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + white-space: nowrap; + overflow: hidden; + + view { + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + // 模板5: 分类网格的 .img-box / .name 直接沿用子组件 legacy 尺寸(80% + 名称), + // 不再做 70%/透明 覆盖, 以与 Branch_5cfee417 的商品分类保持一致 + + // 左侧分类栏样式 + .tree-wrap .category-item { + padding: 28rpx 16rpx; + background-color: var(--diy-category-tree-normal-bg-color, #f5f5f5); + border-radius: 0; + + view { + color: var(--diy-category-tree-normal-text-color, #222222); + font-size: 26rpx; + line-height: 1.3; + } + + &.border-top, + &.border-bottom { + border-radius: 0; + } + + &.select { + background: var(--diy-category-tree-select-bg-color, #fff); + + view { + color: var(--diy-category-tree-select-text-color, #333); + font-weight: bold; + font-size: 28rpx; + } + + &::before { + content: ' '; + width: 6rpx; + height: 32rpx; + background: var(--base-color, #FF6B35); + display: block; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + border-radius: 0 6rpx 6rpx 0; + } + } + } +} + // ===================== 模板99 商品分类样式 ===================== .category-template-99 { .search-box { diff --git a/components-diy/diy-group.vue b/components-diy/diy-group.vue index 4169969..7183878 100644 --- a/components-diy/diy-group.vue +++ b/components-diy/diy-group.vue @@ -377,14 +377,41 @@ export default { : index + 1; if (newItem.componentName !== 'Search' || newItem.positionWay !== 'fixed') { - newItem.pageStyle = `background-color:${newItem.pageBgColor};`; + // PR-DIY-SEARCH-MARGIN-COLOR (2026-09-17): 搜索组件的上下左右边距区域颜色 + // 与搜索框背景色(componentBgColor, 后台自定义)保持一致, 使边距和搜索框融为一体; + // 兼容旧数据: 未配置 componentBgColor 时回退 pageBgColor。其余组件仍用 pageBgColor。 + const wrapBgColor = (newItem.componentName === 'Search' && newItem.componentBgColor) + ? newItem.componentBgColor + : newItem.pageBgColor; + newItem.pageStyle = `background-color:${wrapBgColor};`; - if (newItem.margin) { - newItem.pageStyle += `padding-top:${newItem.margin.top * 2}rpx;`; - newItem.pageStyle += `padding-bottom:${newItem.margin.bottom * 2}rpx;`; - newItem.pageStyle += `padding-right:${newItem.margin.both * 2}rpx;`; - newItem.pageStyle += `padding-left:${newItem.margin.both * 2}rpx;`; - } + // 边距归一化: 兼容后台两种数据结构(否则边距不生效) + // ① 标准对象形态 margin = { top, bottom, both } —— 大多数组件 + // ② 扁平字段形态 marginTop / marginBottom / marginBoth + // —— 如"滑动图片"(ImageNav), 其 margin 是空数组 [], 左右边距存在 marginBoth 里 + // both / marginBoth 表示"左右边距"; 数值 → rpx 统一 × 2 (与其它组件一致) + const rawMargin = newItem.margin; + const objMargin = (rawMargin && typeof rawMargin === 'object' && !Array.isArray(rawMargin)) ? rawMargin : null; + const toMarginNum = (v) => { const n = Number(v); return isNaN(n) ? 0 : n; }; + const pickMargin = (objKey, flatKey) => { + if (objMargin && objMargin[objKey] !== undefined && objMargin[objKey] !== null && objMargin[objKey] !== '') { + return toMarginNum(objMargin[objKey]); + } + return toMarginNum(newItem[flatKey]); + }; + const marginTopNum = pickMargin('top', 'marginTop'); + const marginBottomNum = pickMargin('bottom', 'marginBottom'); + const marginBothNum = pickMargin('both', 'marginBoth'); + // 左右: 若有独立的 left/right 字段则优先, 否则用 both(左右一致) + const marginLeftNum = (objMargin && objMargin.left !== undefined) ? toMarginNum(objMargin.left) + : (newItem.marginLeft !== undefined ? toMarginNum(newItem.marginLeft) : marginBothNum); + const marginRightNum = (objMargin && objMargin.right !== undefined) ? toMarginNum(objMargin.right) + : (newItem.marginRight !== undefined ? toMarginNum(newItem.marginRight) : marginBothNum); + + newItem.pageStyle += `padding-top:${marginTopNum * 2}rpx;`; + newItem.pageStyle += `padding-bottom:${marginBottomNum * 2}rpx;`; + newItem.pageStyle += `padding-left:${marginLeftNum * 2}rpx;`; + newItem.pageStyle += `padding-right:${marginRightNum * 2}rpx;`; } return newItem; diff --git a/components-diy/diy-image-nav.vue b/components-diy/diy-image-nav.vue index e36e4d8..37fcd4d 100644 --- a/components-diy/diy-image-nav.vue +++ b/components-diy/diy-image-nav.vue @@ -1,10 +1,16 @@