Merge branch 'dev/1.0' of http://git.aigc-quickapp.com/Uniapp/lucky_shop into temp2797

This commit is contained in:
2026-08-15 09:53:01 +08:00
11 changed files with 2483 additions and 35 deletions

View File

@@ -4,8 +4,56 @@ import store from '@/store/index.js'
import lang from './lang.js' import lang from './lang.js'
let currentConversationId = null; 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.
//
// PR-7: 按 agent_id 路由 endpoint
// - agent_aikefu (默认智能客服) -> 走 aikefu addon (/api/kefu/* + /ws/aikefu)
// - 其他 agent (knowledge / quick_reply / sales / hr) -> 走 ai-agent addon (/api/aiagent/* + /ws/aiagent)
// - 前端不关心后端两个 addon 之间的差异,只关心 agent_id 路由
export const DEFAULT_AGENT_ID = 'agent_aikefu';
let currentAgentId = DEFAULT_AGENT_ID;
export const CONVERSATION_KEY = 'ai_conversation_id'; export const CONVERSATION_KEY = 'ai_conversation_id';
/**
* PR-7: agent_id -> 后端 addon 路由映射
*
* 规则:
* - agent_aikefu (默认智能客服) -> aikefu addon (老路径 /api/kefu/*)
* - 其他 agent -> aiagent addon (新路径 /api/aiagent/*)
*
* 后端两个 addon 完全独立:
* - aikefu: lucky_aikefu_config / conversation / message
* - aiagent: lucky_ai_agent_config / conversation / message
*/
function resolveAddonName(agentId) {
if (!agentId || agentId === 'agent_aikefu') return 'aikefu';
return 'aiagent';
}
/**
* PR-7: agent_id -> API endpoint URL
* agent_aikefu -> '/api/kefu/...' (兼容老路径)
* 其他 -> '/api/aiagent/...' (ai-agent 新路径)
*/
function resolveApiUrl(apiName) {
var addon = resolveAddonName(currentAgentId);
if (addon === 'aikefu') {
return '/api/kefu/' + apiName;
}
return '/api/aiagent/' + apiName;
}
/**
* PR-7: WebSocket URL 路径(/ws/{addonName})
*/
function resolveWsPath() {
return '/ws/' + resolveAddonName(currentAgentId);
}
/** /**
* 获取域名配置参数(避免重复代码) * 获取域名配置参数(避免重复代码)
*/ */
@@ -35,12 +83,12 @@ const GUEST_ID_KEY = 'ai_guest_user_id';
function getUserId() { function getUserId() {
const memberId = store.state.memberInfo?.id; const memberId = store.state.memberInfo?.id;
if (memberId) return memberId; if (memberId) return memberId;
let guestId; let guestId;
try { try {
guestId = uni.getStorageSync(GUEST_ID_KEY); guestId = uni.getStorageSync(GUEST_ID_KEY);
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
if (!guestId) { if (!guestId) {
guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10); guestId = 'guest_' + Date.now() + '_' + Math.random().toString(36).slice(2, 10);
try { try {
@@ -50,6 +98,20 @@ function getUserId() {
return guestId; 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 // #ifdef MP
// ================================ // ================================
// WebSocket 长连接管理(微信小程序) // WebSocket 长连接管理(微信小程序)
@@ -91,7 +153,9 @@ function ensureWebSocketConnected() {
return; return;
} }
var wssUrl = 'wss://' + domain + '/ws/aikefu'; // PR-7: WebSocket 路径按当前 agent_id 路由 — agent_aikefu 走 /ws/aikefu,其他走 /ws/aiagent
var wsPath = resolveWsPath();
var wssUrl = 'wss://' + domain + wsPath;
wsConnected = false; wsConnected = false;
wsAuthenticated = false; wsAuthenticated = false;
wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} }); wsSocketTask = wx.connectSocket({ url: wssUrl, header: {} });
@@ -106,6 +170,7 @@ function ensureWebSocketConnected() {
wsSocketTask.send({ wsSocketTask.send({
data: JSON.stringify({ data: JSON.stringify({
action: 'auth', action: 'auth',
agent_id: getAgentId(),
uniacid: _uniacid, uniacid: _uniacid,
token: _token, token: _token,
user_id: _userId user_id: _userId
@@ -233,19 +298,35 @@ export default {
return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid return uni.getStorageSync('uniacid') || Config.uniacid; // 从缓存中获取uniacid,或使用默认uniacid
}, },
/**
* PR-7: 获取当前 agent 对应的 addon 名称 (aikefu / aiagent)
*/
getAddonName() {
return resolveAddonName(currentAgentId);
},
/**
* PR-7: 获取当前 agent 对应的 API endpoint 前缀
*/
getApiPrefix() {
return resolveAddonName(currentAgentId) === 'aikefu' ? '/api/kefu' : '/api/aiagent';
},
/** /**
* 发送普通消息(blocking 模式) * 发送普通消息(blocking 模式)
* PR-7: URL 按 agent_id 路由
*/ */
async sendMessage(message, options = {}) { async sendMessage(message, options = {}) {
try { try {
const params = { const params = {
url: '/api/kefu/chat', url: resolveApiUrl('chat'),
data: { data: {
agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
stream: false, stream: false,
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'blocking', // 强制 blocking response_mode: 'blocking',
user: getUserId(), user: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
...getDomainParams() ...getDomainParams()
@@ -268,7 +349,6 @@ export default {
} catch (error) { } catch (error) {
console.error('[AI Service] AiChat API请求失败,详细错误:', error); console.error('[AI Service] AiChat API请求失败,详细错误:', error);
console.error('[AI Service] 错误堆栈:', error.stack); console.error('[AI Service] 错误堆栈:', error.stack);
// 打印更多信息帮助调试
console.error('[AI Service] store 状态:', { console.error('[AI Service] store 状态:', {
memberInfo: store.state.memberInfo, memberInfo: store.state.memberInfo,
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
@@ -284,7 +364,6 @@ export default {
*/ */
async sendStreamMessage(message, onChunk, onComplete) { async sendStreamMessage(message, onChunk, onComplete) {
// #ifdef MP // #ifdef MP
// 先确保连接已就绪
try { try {
await ensureWebSocketConnected(); await ensureWebSocketConnected();
} catch (e) { } catch (e) {
@@ -292,7 +371,6 @@ export default {
throw e; throw e;
} }
// 设置当前消息回调
wsMessageContent = ''; wsMessageContent = '';
wsMessageActive = true; wsMessageActive = true;
wsMessageOnChunk = onChunk || null; wsMessageOnChunk = onChunk || null;
@@ -305,6 +383,7 @@ export default {
wsSocketTask.send({ wsSocketTask.send({
data: JSON.stringify({ data: JSON.stringify({
action: 'chat', action: 'chat',
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
query: message, query: message,
user_id: getUserId(), user_id: getUserId(),
@@ -315,13 +394,16 @@ export default {
}); });
// #endif // #endif
}, },
/** /**
* HTTP 流式请求(仅 H5 使用) * HTTP 流式请求(仅 H5 使用)
* PR-7: URL 按 agent_id 路由
*/ */
async sendHttpStream(message, onChunk, onComplete) { async sendHttpStream(message, onChunk, onComplete) {
// #ifdef H5 // #ifdef H5
try { try {
const url = Config.baseUrl + `/api/kefu/chat`; var chatUrl = resolveApiUrl('chat');
var url = Config.baseUrl + chatUrl;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -334,6 +416,7 @@ export default {
inputs: {}, inputs: {},
query: message, query: message,
response_mode: 'streaming', response_mode: 'streaming',
agent_id: getAgentId(),
user_id: getUserId(), user_id: getUserId(),
conversation_id: this.getConversationId() || '', conversation_id: this.getConversationId() || '',
...getDomainParams() ...getDomainParams()
@@ -356,9 +439,8 @@ export default {
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
// 按行分割,保留不完整的最后一行
const lines = buffer.split('\n'); const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 未完成的行留到下次 buffer = lines.pop() || '';
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
@@ -369,14 +451,12 @@ export default {
const data = JSON.parse(jsonStr); const data = JSON.parse(jsonStr);
if (data.event === 'message') { if (data.event === 'message') {
var text = data.answer || data.text || ''; var text = data.answer || data.text || '';
// 兼容嵌套 data.data 格式
if (!text && data.data) { if (!text && data.data) {
text = data.data.content || data.data.answer || ''; text = data.data.content || data.data.answer || '';
} }
content += text; content += text;
if (onChunk) onChunk(text); if (onChunk) onChunk(text);
} }
// 兼容两种层级:conversation_id 可能在顶层或 data.data 中
var cid = data.conversation_id || (data.data && data.data.conversation_id) || ''; var cid = data.conversation_id || (data.data && data.data.conversation_id) || '';
if (cid) { if (cid) {
conversationId = cid; conversationId = cid;
@@ -392,7 +472,6 @@ export default {
} }
} }
// 处理最后残留的 buffer(如果有)
if (buffer.trim().startsWith('data:')) { if (buffer.trim().startsWith('data:')) {
try { try {
const jsonStr = buffer.trim().slice(5); const jsonStr = buffer.trim().slice(5);
@@ -479,11 +558,13 @@ export default {
/** /**
* 生成新会话(如需) * 生成新会话(如需)
* PR-7: URL 按 agent_id 路由
*/ */
async generateConversationId() { async generateConversationId() {
const params = { const params = {
url: '/api/kefu/createConversation', url: resolveApiUrl('createConversation'),
data: { data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
token: store.state.token || 'test_token', token: store.state.token || 'test_token',
user_id: getUserId(), user_id: getUserId(),
@@ -503,11 +584,12 @@ export default {
/** /**
* 获取服务状态 * 获取服务状态
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceStatus() { async getServiceStatus() {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/health', url: resolveApiUrl('health'),
async: false, async: false,
data: {} data: {}
}); });
@@ -527,12 +609,14 @@ export default {
/** /**
* 获取服务配置信息 * 获取服务配置信息
* PR-7: URL 按 agent_id 路由
*/ */
async getServiceConfig() { async getServiceConfig() {
try { try {
const params = { const params = {
url: '/api/kefu/info', url: resolveApiUrl('info'),
data: { data: {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
token: store.state.token || 'test_token', token: store.state.token || 'test_token',
user_id: getUserId(), user_id: getUserId(),
@@ -542,7 +626,7 @@ export default {
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}; };
const response = await http.sendRequest({ const response = await http.sendRequest({
...params, ...params,
async: false, async: false,
@@ -557,12 +641,14 @@ export default {
/** /**
* 清除会话历史(调用后端接口) * 清除会话历史(调用后端接口)
* PR-7: URL 按 agent_id 路由
*/ */
async clearConversation(conversationId) { async clearConversation(conversationId) {
try { try {
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/clear-conversation', url: resolveApiUrl('clear-conversation'),
data: { data: {
agent_id: getAgentId(),
conversation_id: conversationId, conversation_id: conversationId,
...getDomainParams() ...getDomainParams()
}, },
@@ -577,6 +663,7 @@ export default {
/** /**
* 获取会话历史 * 获取会话历史
* PR-7: URL 按 agent_id 路由
*/ */
async getConversationHistory(params = {}) { async getConversationHistory(params = {}) {
try { try {
@@ -585,6 +672,7 @@ export default {
} }
console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit); console.log('[AI History] 请求历史, conversation_id:', params.conversation_id, 'limit:', params.limit);
const requestData = { const requestData = {
agent_id: params.agent_id || getAgentId(),
uniacid: params.uniacid || this.getUniacid(), uniacid: params.uniacid || this.getUniacid(),
conversation_id: params.conversation_id, conversation_id: params.conversation_id,
user_id: params.user_id || getUserId(), user_id: params.user_id || getUserId(),
@@ -595,7 +683,7 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const response = await http.sendRequest({ const response = await http.sendRequest({
url: '/api/kefu/getHistory', url: resolveApiUrl('getHistory'),
data: requestData, data: requestData,
header: { header: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -625,6 +713,7 @@ export default {
/** /**
* [H5 Only] EventSource 流式聊天(备用) * [H5 Only] EventSource 流式聊天(备用)
* PR-7: URL 按 agent_id 路由
*/ */
chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) { chatWithEventSource(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
@@ -632,6 +721,7 @@ export default {
window.currentEventSource.close(); window.currentEventSource.close();
} }
const paramsObj = { const paramsObj = {
agent_id: getAgentId(),
uniacid: this.getUniacid(), uniacid: this.getUniacid(),
user_id: getUserId(), user_id: getUserId(),
member_id: getUserId(), member_id: getUserId(),
@@ -642,7 +732,8 @@ export default {
...getDomainParams() ...getDomainParams()
}; };
const params = new URLSearchParams(paramsObj); const params = new URLSearchParams(paramsObj);
const url = `${Config.baseUrl}/api/kefu/chat?${params.toString()}`; var chatUrl = resolveApiUrl('chat');
const url = `${Config.baseUrl}${chatUrl}?${params.toString()}`;
try { try {
const eventSource = new EventSource(url); const eventSource = new EventSource(url);
window.currentEventSource = eventSource; window.currentEventSource = eventSource;
@@ -687,7 +778,6 @@ export default {
*/ */
async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) { async chatWithFetchStream(message, conversationId = '', onMessage, onComplete, onError) {
// #ifdef H5 // #ifdef H5
// 实际逻辑已在 sendHttpStream 中实现,此处可复用或留空
return this.sendHttpStream(message, onMessage, (res) => { return this.sendHttpStream(message, onMessage, (res) => {
onComplete?.({ message: res.content, conversation_id: res.conversation_id }); onComplete?.({ message: res.content, conversation_id: res.conversation_id });
}); });
@@ -711,4 +801,26 @@ export default {
// H5 无 WebSocket 长连接,无需操作 // H5 无 WebSocket 长连接,无需操作
}, },
// #endif // #endif
};
/**
* Set current AI Agent (default aikefu; link system passes agent_id).
* PR-7: 切换 agent 时, 如果跨 addon 则断开 WebSocket 重连 (路径变了)
* @param {string} agentId
*/
setAgentId(agentId) {
var oldAddon = resolveAddonName(currentAgentId);
setAgentId(agentId);
var newAddon = resolveAddonName(currentAgentId);
if (oldAddon !== newAddon) {
try { disconnectWebSocket(); } catch (e) { /* ignore */ }
}
},
/**
* Get current AI Agent.
* @returns {string}
*/
getAgentId() {
return getAgentId();
},
};

View File

@@ -4,6 +4,7 @@
*/ */
import IMAGE_CONFIG from './image-config.js'; import IMAGE_CONFIG from './image-config.js';
import Platform from './platform.js'; import Platform from './platform.js';
import PAGE_KEY_MAP from './util.js';
export class CustomerService { export class CustomerService {
constructor(vueInstance, externalConfig = null) { constructor(vueInstance, externalConfig = null) {
@@ -118,6 +119,16 @@ export class CustomerService {
/** /**
* 跳转到 AI智能客服页面(AiChat) * 跳转到 AI智能客服页面(AiChat)
*
* 注意:不在此处注入 __dyu=FloatBtn。
* 该标记原本是 diy-float-btn.vue 用来告诉 ai-chat "我是从浮动按钮来的,请隐藏底部 tabBar"。
* 此方法是 customer-service.js 的**通用 AI 客服入口**,被多处复用:
* - hover-nav(悬浮客服按钮)
* - 商品详情页 / 会员中心 / 个人中心 等的"联系客服"按钮
* - DIY 浮动按钮(diy-float-btn 内部已自行注入 __dyu=FloatBtn,这里再注入会重复)
* 若在此处统一注入,会让所有 AI 客服入口都隐藏底部 tabBar,语义错误且侵入性过强。
* hover-nav 需要隐藏 tabBar 的场景,由 hover-nav.vue#handleUnifiedKefuClick
* 自行注入 __dyu=FloatBtn(与 diy-float-btn.vue#onFloatBtnClick 一致)。
*/ */
openAiChatService() { openAiChatService() {
try { try {
@@ -127,7 +138,7 @@ export class CustomerService {
} }
// ✅ 修正路径:必须与 pages.json 中注册的路径一致 // ✅ 修正路径:必须与 pages.json 中注册的路径一致
const aiChatUrl = '/pages_tool/ai-chat/index'; const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
// ✅ 使用 navigateTo 保留返回栈(体验更好) // ✅ 使用 navigateTo 保留返回栈(体验更好)
uni.navigateTo({ uni.navigateTo({
@@ -551,6 +562,9 @@ export class CustomerService {
/** /**
* 打开 AI 智能助手 * 打开 AI 智能助手
*
* 注意:不在此处注入 __dyu=FloatBtn,理由同 openAiChatService()。
* 由调用方(hover-nav.vue 等)按需注入。
*/ */
openAIChat() { openAIChat() {
try { try {
@@ -558,7 +572,7 @@ export class CustomerService {
this.vm.setAiUnreadCount(0); this.vm.setAiUnreadCount(0);
} }
const aiChatUrl = '/pages_tool/ai-chat/index'; const aiChatUrl = PAGE_KEY_MAP.AI_CHAT_PAGE_URL;
uni.navigateTo({ uni.navigateTo({
url: aiChatUrl, url: aiChatUrl,

View File

@@ -139,6 +139,7 @@ export const CONTACT_PAGE_URL = '/pages_tool/contact/contact';
export const MEMBER_PAGE_URL = '/pages_tool/member/index'; export const MEMBER_PAGE_URL = '/pages_tool/member/index';
export const LOGIN_PAGE_URL = '/pages_tool/login/login'; 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-agent/index'; // AI 智能体入口页(智能客服是首个 agent)
// page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径) // page_key 到实际路径的映射表(统一管理所有页面的语义常量 → 实际路径)
// 前端组织页面(重命名路径、分包调整)时只需要修改此映射表 // 前端组织页面(重命名路径、分包调整)时只需要修改此映射表
@@ -1203,6 +1204,23 @@ export default {
* @param {Object} link * @param {Object} link
*/ */
diyRedirectTo(link) { diyRedirectTo(link) {
// ---------- AI Agent 通用链接(智能客服是首个 agent;新增 AI 销售/HR 等不用改这段) ----------
// 放在 stale-uniacid 与 resolveLinkUrl 之前:AI Agent 是纯内部路径,走后续 webview 分支会被错判。
if (link && (link.name === 'AI_AGENT' || link.kind === 'AI_AGENT')) {
const agentId = (link.page_params && link.page_params.agent_id) || 'agent_aikefu';
const title = link.title || 'AI 智能体';
// 透传来源标记:浮动按钮点击时 __dyu=FloatBtn,目标页据此隐藏底部 tabBar
// (diy-float-btn.vue#onFloatBtnClick 已写入 page_params.__dyu)
const dyuEntry = (link.page_params && link.page_params.__dyu) || '';
return this.redirectTo(AI_AGENT_PAGE_URL, {
agent_id: agentId,
title: encodeURIComponent(title),
enable_set_title: link.enable_set_title ? 1 : 0,
...(dyuEntry ? { __dyu: dyuEntry } : {}),
});
}
console.log(`-----> diyRedirectTo`, JSON.stringify(link)); console.log(`-----> diyRedirectTo`, JSON.stringify(link));
//if (link == null || Object.keys(link).length == 1) return; //if (link == null || Object.keys(link).length == 1) return;

2090
common/js/util.js.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
:class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }" :class="{ left_top: value.bottomPosition == 1, right_top: value.bottomPosition == 2, left_bottom: value.bottomPosition == 3, right_bottom: value.bottomPosition == 4 }"
:style="style"> :style="style">
<block v-for="(item, index) in value.list" :key="index"> <block v-for="(item, index) in value.list" :key="index">
<view class="button-box" @tap.stop="$util.diyRedirectTo(item.link)" <view class="button-box" @tap.stop="onFloatBtnClick(item)"
:style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }"> :style="{ width: value.imageSize + 'px', height: value.imageSize + 'px', fontSize: value.imageSize + 'px' }">
<image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit" <image v-if="!item.iconType || item.iconType == 'img'" :src="$util.img(item.imageUrl)" mode="aspectFit"
show-menu-by-longpress /> show-menu-by-longpress />
@@ -44,7 +44,44 @@ export default {
componentRefresh: function (nval) { } componentRefresh: function (nval) { }
}, },
components: {}, components: {},
methods: {}, methods: {
/**
* 浮动按钮点击处理:
* 在跳转前给 link 注入 __dyu=FloatBtn 来源标记,
* 让目标页面(尤其是 ai-chat)能识别"来自浮动按钮"从而隐藏底部 tabBar,
* 与"从底部导航栏点击进入时显示并高亮 tabBar"的场景并存。
*
* 为什么用 __dyu 这种 3 字符命名:
* - 双下划线 __ 前缀 + 3 位字母(dy-ui 框架) 是前端约定俗成的"系统内部保留字段"标志
* (如 __proto__, __esModule),业务方配置 link 时几乎不可能使用这种短命名 → 与业务参数零冲突
* - URL 中占用空间极小,不污染业务 query 字符串
* - 未来如需扩展其他组件的入口来源,可继续使用 __dyu 前缀(如 __dyu=Banner / __dyu=Nav)
*
* 实现要点:
* - 浅拷贝 item.link 避免污染原数据(可能被 v-for 复用)
* - 把标记写入 page_params,diyRedirectTo → redirectTo 会自动拼到 URL query,
* 目标页 onLoad(options) 可直接读取
* - 同时在 wap_url 末尾追加/合并 __dyu=FloatBtn 兜底:某些路径不走 page_params 拼接
*/
onFloatBtnClick(item) {
if (!item || !item.link) return;
const link = { ...item.link };
link.page_params = Object.assign({}, link.page_params || {}, {
__dyu: 'FloatBtn'
});
// 兜底:直接把标记拼到 wap_url query,防止 page_params 在某些分支未被透传
if (link.wap_url && typeof link.wap_url === 'string' && link.wap_url.indexOf('http') === -1) {
const sep = link.wap_url.indexOf('?') === -1 ? '?' : '&';
// 避免重复追加(精确匹配 __dyu= 而不是 __dyu,避免误伤可能的 __dyu_other)
if (link.wap_url.indexOf('__dyu=') === -1) {
link.wap_url = link.wap_url + sep + '__dyu=FloatBtn';
}
}
this.$util.diyRedirectTo(link);
},
},
computed: { computed: {
style() { style() {
let style = {}, let style = {},

View File

@@ -743,7 +743,53 @@ export default {
this.$langConfig.change(targetLang, url); this.$langConfig.change(targetLang, url);
}, },
/**
* 客服按钮点击的统一入口。
*
* 关键修复:智能客服(AI 客服)类型需要走 ai-chat 页面,
* 且必须在 URL 上注入 __dyu=FloatBtn 标记,
* 让 ai-chat/index.vue 的 onLoad(options) 识别"来自悬浮按钮/浮动按钮",
* commit setBottomNavHidden(true) 隐藏底部 tabBar。
*
* 为什么不在 customer-service.js#openAiChatService 统一注入:
* 该方法是通用 AI 客服入口,被多处复用(hover-nav / 商品详情页 / 会员中心 / DIY 浮动按钮等),
* 其中 DIY 浮动按钮(diy-float-btn.vue)已自行注入 __dyu=FloatBtn;其他入口(商品详情页等)
* 是否要隐藏 tabBar 是另一个独立的产品决策,不能在此一刀切。
* 因此由调用方按需注入:本方法(hover-nav)显式注入,diy-float-btn 显式注入,
* 其他入口保持原样。
*
* 与 diy-float-btn.vue#onFloatBtnClick 的行为完全一致,只是触发位置不同。
*/
handleUnifiedKefuClick() { handleUnifiedKefuClick() {
// 仅智能客服(aikefu)类型需要走 ai-chat 页面并隐藏 tabBar;
// 其他类型(企业微信 / 第三方 / 小程序原生客服等)由 customerService 内部
// 路由到对应渠道(navigateToMiniProgram / openCustomerServiceChat 等),
// 与底部 tabBar 无关,继续走原统一入口。
if (this.customerService && this.customerService.isAIKefu && this.customerService.isAIKefu()) {
// 清除未读数(与 openAiChatService 行为保持一致)
if (typeof this.setAiUnreadCount === 'function') {
this.setAiUnreadCount(0);
} else if (this.$store && typeof this.$store.commit === 'function') {
try { this.$store.commit('setAiUnreadCount', 0); } catch (e) { /* 兼容旧 store */ }
}
// 注入 __dyu=FloatBtn 标记,与 diy-float-btn.vue#onFloatBtnClick 语义一致
const aiChatUrl = `${this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT}?__dyu=FloatBtn`;
uni.navigateTo({
url: aiChatUrl,
fail: (err) => {
console.error('[hover-nav] 跳转 AI智能客服失败:', err);
// H5 兜底
// #ifdef H5
window.location.href = aiChatUrl;
// #endif
uni.showToast({ title: this.$lang('customerService.openFailed'), icon: 'none' });
}
});
return;
}
// 非智能客服类型:走原统一入口(企业微信 / 第三方 / 微信原生客服 等)
this.customerService.handleUnifiedKefuClick({ this.customerService.handleUnifiedKefuClick({
sendMessageTitle: '来自悬浮按钮的咨询', sendMessageTitle: '来自悬浮按钮的咨询',
sendMessagePath: '/pages/index/index' sendMessagePath: '/pages/index/index'

View File

@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: 'Failed to initialize navigation bar', logInitNavbarFailed: 'Failed to initialize navigation bar',
logChatComponentNotInit: 'Chat component not initialized', logChatComponentNotInit: 'Chat component not initialized',
logServicerConfigNotLoaded: '[AI Customer Service] servicerConfig not loaded, retrying after 200ms...' 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'}

View File

@@ -136,4 +136,8 @@ export const lang = {
logInitNavbarFailed: '初始化导航栏失败', logInitNavbarFailed: '初始化导航栏失败',
logChatComponentNotInit: '聊天组件未初始化', logChatComponentNotInit: '聊天组件未初始化',
logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...' logServicerConfigNotLoaded: '【AI客服】servicerConfig 未加载,200ms后重试...'
}
// ====== AI Agent 通用文案(智能客服是首个 agent) ======
aiAgentDefaultTitle: 'AI 智能体',
aiAgentOpenFailed: '打开 AI 智能体失败,请稍后再试',
aiAgentNotConfigured: '该 AI 智能体暂未启用'}

View File

@@ -1086,7 +1086,64 @@
{ {
"path": "ai-chat/index", "path": "ai-chat/index",
"style": { "style": {
"navigationBarTitleText": "AI智能客服" "navigationBarTitleText": "AI智能客服",
"componentPlaceholder": {
"diy-article": "view",
"diy-audio": "view",
"diy-bargain": "view",
"diy-bottom-nav": "view",
"diy-category": "view",
"diy-category-item": "view",
"diy-channel-list": "view",
"diy-channel-video": "view",
"diy-comp-extend": "view",
"diy-coupon": "view",
"diy-digit": "view",
"diy-fenxiao-goods-list": "view",
"diy-float-btn": "view",
"diy-follow-official-account": "view",
"diy-form": "view",
"diy-goods-brand": "view",
"diy-goods-list": "view",
"diy-goods-recommend": "view",
"diy-graphic-nav": "view",
"diy-group": "view",
"diy-groupbuy": "view",
"diy-horz-blank": "view",
"diy-horz-line": "view",
"diy-hot-area": "view",
"diy-icon": "view",
"diy-image-nav": "view",
"diy-img-ads": "view",
"diy-index-page": "view",
"diy-kefu": "view",
"diy-listmenu": "view",
"diy-live": "view",
"diy-many-goods-list": "view",
"diy-map": "view",
"diy-member-info": "view",
"diy-member-my-order": "view",
"diy-merch-list": "view",
"diy-notes": "view",
"diy-notice": "view",
"diy-payment-qrcode": "view",
"diy-picture": "view",
"diy-pinfan": "view",
"diy-pintuan": "view",
"diy-presale": "view",
"diy-quick-nav": "view",
"diy-recommend-code": "view",
"diy-rich-text": "view",
"diy-rubik-cube": "view",
"diy-search": "view",
"diy-seckill": "view",
"diy-store": "view",
"diy-store-label": "view",
"diy-tab": "view",
"diy-text": "view",
"diy-video": "view",
"diy-video-list": "view"
}
} }
}, },
{ {

View File

@@ -603,7 +603,7 @@ export default {
uni.setStorageSync('selectedProduct', productInfo); uni.setStorageSync('selectedProduct', productInfo);
uni.navigateTo({ uni.navigateTo({
url: '/pages_tool/ai-chat/index' url: this.$util.PAGE_KEY_MAP.PAGE_AI_CHAT
}); });
}, },

View File

@@ -13,7 +13,7 @@
<!-- #endif --> <!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 --> <!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content"> <view class="chat-content" :style="{ bottom: tabBarHeight }">
<ai-chat-message <ai-chat-message
ref="chat" ref="chat"
:initial-messages="initialMessages" :initial-messages="initialMessages"
@@ -68,6 +68,15 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 底部导航栏(仅在 tabBar 场景下显示):
通过 golbalConfig mixin 的 shouldShowTabBar 控制 v-show,
组件内部已绑定 data-component-name="diy-bottom-nav"。
- 后端把 ai-chat 配为底部导航栏项 → 用户点击该项进入 → tabBar 显示并高亮对应项
- 浮动按钮(diy-float-btn)进入时 onLoad commit setBottomNavHidden(true)
→ shouldShowTabBar=false → tabBar 隐藏
- 离开页面(onUnload)还原 bottomNavHidden → 不污染其他页面 -->
<diy-bottom-nav></diy-bottom-nav>
</view> </view>
</template> </template>
@@ -87,6 +96,9 @@ export default {
return { return {
initialMessages: [], initialMessages: [],
fallbackWelcome: '', fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 --- // --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -105,7 +117,15 @@ export default {
keyboardHeight: 0, keyboardHeight: 0,
// 事件处理器引用(用于清理) // 事件处理器引用(用于清理)
safeEventHandlers: new Map() safeEventHandlers: new Map(),
// 是否从浮动按钮进入
// 用来在运行时区分两种场景:
// 1) 后台把 ai-chat 配为底部导航栏 tabBar 项 → 用户点击该项进入 → 底部 tabBar 显示并高亮
// 2) 浮动按钮(diy-float-btn)的链接指向 ai-chat → 用户点击浮动按钮进入 → 底部 tabBar 隐藏
// 通过 onLoad(options).from 判定;同时会通过 $store.state.bottomNavHidden 临时隐藏 tabBar,
// 并在 onUnload 还原,避免影响其他页面。
isFromFloatBtn: false,
} }
}, },
@@ -127,6 +147,21 @@ export default {
async onLoad(options) { async onLoad(options) {
this.fallbackWelcome = this.$lang('ai-chat.index.fallbackWelcome'); 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(); // const hasAccess = this.checkAccessPermission();
// if (!hasAccess) { // if (!hasAccess) {
@@ -134,7 +169,27 @@ export default {
// return; // return;
// } // }
this.$langConfig.title(this.$lang('ai-chat.index.title')); this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
// ====== 区分底部导航栏/浮动按钮两种进入来源 ======
// diy-float-btn 跳转时会在 link 上注入 __dyu=FloatBtn
// (见 diy-float-btn.vue#onFloatBtnClick)
// 这种情况应当隐藏底部 tabBar(AI 客服页是全屏聊天体验,不应被 tabBar 占用屏幕)
// 而点击底部导航栏 tabBar 项进入时,通常不会携带 __dyu query,
// 此时 tabBar 正常显示并由 checkTabBarActive 步骤2精确命中高亮当前项。
// 用 __dyu 这种 3 字符 + 双下划线前缀的命名,几乎不可能与业务 page_params 冲突
// (业务方不会使用 __ 开头的短变量名)。
const dyuEntry = (options && options.__dyu) || '';
if (dyuEntry === 'FloatBtn') {
this.isFromFloatBtn = true;
// 临时隐藏底部导航栏;onUnload 会还原。
try {
this.$store.commit('setBottomNavHidden', true);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
} else {
this.isFromFloatBtn = false;
}
this.initChat(); this.initChat();
}, },
@@ -180,6 +235,15 @@ export default {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered')); console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup(); this.cleanup();
aiService.disconnectWebSocket(); aiService.disconnectWebSocket();
// 还原底部导航栏显示状态:如果 onLoad 时因为来自浮动按钮而临时隐藏,
// 必须在页面卸载时还原,否则返回到 tabBar 页时 tabBar 会保持隐藏状态。
if (this.isFromFloatBtn) {
try {
this.$store.commit('setBottomNavHidden', false);
} catch (e) { /* 兼容没有该 mutation 的旧 store */ }
this.isFromFloatBtn = false;
}
}, },
methods: { methods: {
@@ -647,7 +711,9 @@ export default {
/* #endif */ /* #endif */
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; /* bottom 由模板动态绑定到 tabBarHeight:
- 浮动按钮入口 / 其他隐藏 tabBar 场景:0rpx,占满到底部
- tabBar 项入口:tabBarHeight,避免被底部 tabBar 遮挡聊天内容 */
} }
/* 底部tabBar占位样式 */ /* 底部tabBar占位样式 */