Files
lucky_shop/pages_tool/ai-chat/index.vue
ZF sun f656be789d feat(ai-agent): 新增AI智能体通用支持框架
1.  新增多语言文案,适配AI智能体通用场景
2.  新增AI智能体页面路由常量与跳转逻辑
3.  改造AI聊天页面支持动态agent_id和页面标题
4.  升级AI服务层,支持多agent_id自动传递
2026-08-12 15:12:00 +08:00

800 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="wrapper">
<!--自定义导航头部 - 绝对定位在顶部,不随键盘上移 -->
<!-- #ifdef MP-WEIXIN -->
<view class="custom-navbar" ref="pageHeader" v-if="showCustomNavbar">
<view class="header-left">
<button class="back-btn" v-show="showBackButton" @click="goBack">
<text class="iconfont icon-back"></text>
</button>
<view v-show="!showMenuButton" class="placeholder"></view>
</view>
</view>
<!-- #endif -->
<!-- 聊天内容区域:从导航栏下方到容器底部 -->
<view class="chat-content">
<ai-chat-message
ref="chat"
:initial-messages="initialMessages"
:fallback-welcome="fallbackWelcome"
:user-avatar="userAvatar"
:ai-avatar="aiAvatar"
@message-sent="onMessageSent"
@ai-response="onAIResponse"
@action-click="onActionClick"
@history-loaded="onHistoryLoaded"
@file-preview="onFilePreview"
@audio-play="onAudioPlay"
@audio-pause="onAudioPause"
@video-play="onVideoPlay"
@video-pause="onVideoPause"
@link-open="onLinkOpen"
@product-view="onProductView"
@input-change="onInputChange" />
</view>
<!-- 设置面板 -->
<view v-if="showSettingsPanel" class="settings-popup">
<view class="settings-mask" @click="hideSettings"></view>
<view class="settings-panel">
<view class="settings-header">
<text class="settings-title">{{ $lang('ai-chat.index.settingsTitle') }}</text>
<view class="close-btn" @click="hideSettings">
<text>✕</text>
</view>
</view>
<view class="settings-content">
<view class="setting-item" @click="openAvatarSelector">
<view class="setting-icon">
<text class="iconfont icon-user">📷</text>
</view>
<view class="setting-info">
<text class="setting-label">{{ $lang('ai-chat.index.changeAvatar') }}</text>
<text class="setting-desc">{{ $lang('ai-chat.index.changeAvatarDesc') }}</text>
</view>
<text class="iconfont icon-right">›</text>
</view>
<view class="setting-item" @click="openNicknameEditor">
<view class="setting-icon">
<text class="iconfont icon-edit">✏️</text>
</view>
<view class="setting-info">
<text class="setting-label">{{ $lang('ai-chat.index.changeNickname') }}</text>
<text class="setting-desc">{{ $lang('ai-chat.index.changeNicknameDesc') }}</text>
</view>
<text class="iconfont icon-right">›</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
import navigationHelper from '@/common/js/navigation';
import { EventSafety } from '@/common/js/event-safety';
import aiChatMessage from './ai-chat-message.vue';
import IMAGE_CONFIG from '@/common/js/image-config.js';
import aiService from '@/common/js/ai-service.js';
export default {
components: {
aiChatMessage
},
data() {
return {
initialMessages: [],
fallbackWelcome: '',
// AI Agent 通用入参(链接系统透传;老客服入口不传也能跑,默认 agent_aikefu)
agentId: 'agent_aikefu',
pageTitle: '',
// --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
statusBarHeight: 0, // 状态栏高度,默认0
// --- 自定义导航栏 ---
showCustomNavbar: true, // 是否显示自定义导航栏
showBackButton: false, // 是否显示返回按钮
showMenuButton: true, // 是否显示设置菜单按钮
showSettingsPanel: false, // 是否显示设置面板
// --- 聊天内容区域 ---
scrollViewHeight: '0px',
// --- 键盘高度 ---
keyboardHeight: 0,
// 事件处理器引用(用于清理)
safeEventHandlers: new Map()
}
},
computed: {
...mapGetters([
'globalAIKefuConfig'
]),
/// ---- 关于AI客服的配置,支持远程配置 ----
userAvatar() {
return this.globalAIKefuConfig?.userAvatar || this.$util.img(IMAGE_CONFIG.userAvatar)
},
aiAvatar() {
return this.globalAIKefuConfig?.aiAvatar || this.$util.img(IMAGE_CONFIG.aiAvatar)
},
/// ---- others ----
},
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) {
// // 如果校验失败,不继续初始化
// return;
// }
this.$langConfig.title(this.pageTitle || this.$lang('ai-chat.index.title'));
this.initChat();
},
async onReady() {
await this.initNavigation();
},
onShow() {
console.log(this.$lang('ai-chat.index.logOnShowTriggered'));
// 监听键盘高度变化(H5 可能不支持,try-catch 保护)
try {
this._keyboardChangeHandler = (res) => {
this.keyboardHeight = res.height;
if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) {
this.$refs.chat.updateKeyboardHeight(res.height);
}
};
uni.onKeyboardHeightChange(this._keyboardChangeHandler);
} catch (e) {
console.warn(this.$lang('ai-chat.index.logKeyboardChangeUnavailable') + ':', e.message);
}
// 每次页面显示时尝试加载历史记录(H5可能组件尚未创建,用 nextTick 确保 ref 就绪)
this.$nextTick(() => {
if (this.$refs.chat && this.$refs.chat.loadHistoryIfNeeded) {
this.$refs.chat.loadHistoryIfNeeded();
}
});
},
onHide() {
// 取消监听键盘高度变化
if (this._keyboardChangeHandler) {
uni.offKeyboardHeightChange(this._keyboardChangeHandler);
this._keyboardChangeHandler = null;
}
this.keyboardHeight = 0;
if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) {
this.$refs.chat.updateKeyboardHeight(0);
}
},
onUnload() {
console.log(this.$lang('ai-chat.index.logOnUnloadTriggered'));
this.cleanup();
aiService.disconnectWebSocket();
},
methods: {
// ========== 安全事件处理 ==========
setupSafeEventListeners() {
const safeHandlers = {
serviceRequest: EventSafety.wrapEventHandler(
this.handleServiceRequest.bind(this),
{ onError: this.handleEventError.bind(this) }
),
navigationRequest: EventSafety.wrapEventHandler(
this.handleNavigationRequest.bind(this),
{ onError: this.handleEventError.bind(this) }
),
componentInteraction: EventSafety.wrapEventHandler(
this.handleComponentInteraction.bind(this),
{ onError: this.handleEventError.bind(this) }
)
}
this. $on('service.requestComponentInfo', safeHandlers.serviceRequest);
this. $on('navigation.requestInfo', safeHandlers.navigationRequest);
this. $on('component.interaction', safeHandlers.componentInteraction);
this.safeEventHandlers.set('serviceRequest', safeHandlers.serviceRequest);
this.safeEventHandlers.set('navigationRequest', safeHandlers.navigationRequest);
this.safeEventHandlers.set('componentInteraction', safeHandlers.componentInteraction);
},
setupNavigationEvents() {
uni.onWindowResize((res) => {
this.calculateScrollViewHeight();
});
},
// ========== 事件处理方法 ==========
async handleServiceRequest(event) {
if (event.matches('.service-component') ||
event.detail?.componentType === 'service') {
await this.processServiceRequest(event);
}
},
async handleNavigationRequest(event) {
this.emitNavigationInfo(event);
},
handleComponentInteraction(event) {
this.processComponentInteraction(event);
},
handleEventError(error, event) {
console.error(this.$lang('ai-chat.index.logEventHandleError') + ':', {
error: error.message,
eventType: event?.type,
component: this. $options.name
});
this.showError(this.$lang('ai-chat.index.operationFailed'));
},
// ========== 初始化页面 ==========
initChat() {
// 检查是否有从商品分类传递过来的商品信息
const selectedProduct = uni.getStorageSync('selectedProduct')
if (selectedProduct) {
// 清除存储的商品信息,避免重复发送
uni.removeStorageSync('selectedProduct')
// 延迟发送商品信息,确保聊天组件已经初始化
setTimeout(() => {
this.sendProductToAI(selectedProduct)
}, 500)
}
},
async initNavigation() {
try {
this.navBarHeight = await navigationHelper.getNavigationHeight(this, {forceRefresh: false});
this.statusBarHeight = navigationHelper.getStatusBarHeight();
this.calculateScrollViewHeight();
this.setupNavigationEvents();
} catch (error) {
console.error(this.$lang('ai-chat.index.logInitNavbarFailed') + ':', error);
this.setFallbackNavigationValues();
}
},
calculateScrollViewHeight() {
const safeArea = navigationHelper.getSafeAreaInsets();
const bottomInset = safeArea.bottom || 0;
const inputHeight = 120;
this.scrollViewHeight = `calc(100vh - $ {this.navBarHeight + this.statusBarHeight + inputHeight + bottomInset}px)`;
},
setFallbackNavigationValues() {
// #ifdef MP-WEIXIN
this.navBarHeight = 44;
this.statusBarHeight = 20;
// #endif
// #ifdef H5
this.navBarHeight = 44;
this.statusBarHeight = 0;
// #endif
// #ifdef APP-PLUS
this.navBarHeight = 88;
this.statusBarHeight = 44;
// #endif
},
cleanup() {
if (this._keyboardChangeHandler) {
uni.offKeyboardHeightChange(this._keyboardChangeHandler);
this._keyboardChangeHandler = null;
}
this.safeEventHandlers.forEach((handler, eventType) => {
this. $off(eventType, handler);
});
this.safeEventHandlers.clear();
},
showError(message) {
uni.showToast({
title: message,
icon: 'none',
duration: 2000
});
},
// 返回上一页
goBack() {
uni.navigateBack();
},
// 显示菜单
showMenu() {
uni.showActionSheet({
itemList: [this.$lang('ai-chat.index.menuClearChat'), this.$lang('ai-chat.index.menuExport'), this.$lang('ai-chat.index.menuSettings'), this.$lang('ai-chat.index.menuHelp')],
success: (res) => {
switch (res.tapIndex) {
case 0:
this.clearChat();
break;
case 1:
this.exportChat();
break;
case 2:
this.showSettings();
break;
case 3:
this.showHelp();
break;
}
}
});
},
clearChat() {
uni.showModal({
title: this.$lang('ai-chat.index.prompt'),
content: this.$lang('ai-chat.index.confirmClearChat'),
success: (res) => {
if (res.confirm) {
this. $refs.chat.clearMessages();
this. $refs.chat.addMessage({
id: Date.now(),
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.chatCleared'),
timestamp: Date.now()
});
}
}
});
},
exportChat() {
uni.showToast({
title: this.$lang('ai-chat.index.featureDeveloping'),
icon: 'none'
});
},
showSettings() {
this.showSettingsPanel = true;
},
hideSettings() {
this.showSettingsPanel = false;
},
openAvatarSelector() {
this.hideSettings();
this.$refs.chat.openAvatarSelector();
},
openNicknameEditor() {
this.hideSettings();
this.$refs.chat.openNicknameEditor();
},
showHelp() {
const helpMessage = {
id: Date.now(),
role: 'ai',
type: 'markdown',
content: this.$lang('ai-chat.index.helpContent'),
timestamp: Date.now()
};
this.$refs.chat.addMessage(helpMessage);
},
onMessageSent(message) {
},
onAIResponse(message) {
},
generateAIResponse(userMessage) {
const responses = [
this.$lang('ai-chat.index.aiResponseTemplate1'),
this.$lang('ai-chat.index.aiResponseTemplate2'),
this.$lang('ai-chat.index.aiResponseTemplate3'),
this.$lang('ai-chat.index.aiResponseTemplate4'),
this.$lang('ai-chat.index.aiResponseTemplate5'),
];
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
const aiMessage = {
id: Date.now(),
role: 'ai',
type: 'text',
content: `${randomResponse}\n\n${this.$lang('ai-chat.index.aiProcessingMessage', { question: userMessage.content })}`,
timestamp: Date.now(),
actions: [
{ type: 'like', count: 0 },
{ type: 'dislike', count: 0 }
]
};
this.$refs.chat.addMessage(aiMessage);
this.$emit('ai-response', aiMessage);
},
onActionClick({ action, message }) {
switch (action.type) {
case 'like':
uni.showToast({ title: this.$lang('ai-chat.index.thanksFeedback'), icon: 'success' });
break;
case 'dislike':
uni.showToast({ title: this.$lang('ai-chat.index.improveService'), icon: 'none' });
break;
}
},
onHistoryLoaded(messages) {
},
onFilePreview(message) {
uni.showToast({ title: this.$lang('ai-chat.index.openingFileName', { name: message.fileName }), icon: 'none' });
},
onAudioPlay(message) {
},
onAudioPause(message) {
},
onVideoPlay(message) {
},
onVideoPause(message) {
},
sendProductToAI(product) {
if (!this.$refs.chat) {
console.error(this.$lang('ai-chat.index.logChatComponentNotInit'))
return
}
const productContent = this.$lang('ai-chat.index.productInquiryTemplate', {
name: product.goods_name,
price: product.price,
id: product.goods_id
})
// 使用sendMessageWithContent方法发送商品信息
this.$refs.chat.sendMessageWithContent(productContent)
},
onLinkOpen(message) {
uni.showModal({
title: this.$lang('ai-chat.index.openLink'),
content: this.$lang('ai-chat.index.confirmOpenLinkText', { url: message.url }),
success: (res) => {
if (res.confirm) {
// #ifdef H5
window.open(message.url, '_blank');
// #endif
// #ifdef APP-PLUS
plus.runtime.openURL(message.url);
// #endif
}
}
});
},
onProductView(message) {
uni.showToast({ title: this.$lang('ai-chat.index.openingFileTitle', { title: message.title }), icon: 'none' });
},
onInputChange(value) {
},
// ✅ 新增:权限校验(核心修复)
checkAccessPermission() {
const servicerConfig = this. $store.state.servicerConfig;
// 如果配置未加载,延迟重试(最多3次,防白屏)
if (!servicerConfig) {
console.warn(this.$lang('ai-chat.index.logServicerConfigNotLoaded'));
setTimeout(() => {
this.checkAccessPermission();
}, 200);
return false;
}
let currentType = 'none';
// #ifdef MP-WEIXIN
currentType = servicerConfig?.weapp?.type || 'none';
// #endif
// #ifdef H5
currentType = servicerConfig?.h5?.type || 'none';
// #endif
if (currentType !== 'aikefu') {
uni.showToast({ title: this.$lang('ai-chat.index.serviceTypeNotSupported'), icon: 'none' });
setTimeout(() => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
} else {
// 回到首页(假设首页是 tab 页)
uni.switchTab({ url: '/pages/index/index' });
}
}, 1500);
return false;
}
return true;
}
}
}
</script>
<style lang="scss">
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
/* 页面样式 */
.wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
/* 自定义导航头部:固定在wrapper顶部,不随键盘上移 */
.custom-navbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 88rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 30rpx;
background-color: white; /* 白色 */
border-bottom: 1rpx solid #e5e5e5;
.header-left {
flex: 0 0 auto;
display: flex;
align-items: center;
.back-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #ffffff; /* 白色按钮 */
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: 32rpx;
color: #666;
}
}
.placeholder {
width: 120rpx;
height: 60rpx;
}
}
.header-center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
.header-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.header-subtitle {
font-size: 24rpx;
color: #999;
}
}
.header-right {
flex: 0 0 auto;
display: flex;
align-items: center;
.menu-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #ffffff; /* 白色按钮 */
display: flex;
align-items: center;
justify-content: center;
.iconfont {
font-size: 32rpx;
color: #666;
}
}
.placeholder {
width: 120rpx;
height: 60rpx;
}
}
}
/* 聊天内容区域:从导航栏下方到容器底部 */
.chat-content {
position: absolute;
/* #ifdef MP-WEIXIN */
top: 0;
/* #endif */
/* #ifndef MP-WEIXIN */
top: 44px;
/* #endif */
left: 0;
right: 0;
bottom: 0;
}
/* 底部tabBar占位样式 */
.page-bottom {
width: 100%;
background-color: transparent;
}
/* 设置面板 */
.settings-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
.settings-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(6rpx);
}
.settings-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
width: calc(100% - 80rpx);
max-width: 520rpx;
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
box-sizing: border-box;
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 16rpx;
.settings-title {
font-size: 34rpx;
font-weight: 600;
color: #333;
}
.close-btn {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background-color: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #999;
transition: all 0.2s ease;
&:hover {
background-color: #e9edf2;
}
}
}
.settings-content {
display: flex;
flex-direction: column;
gap: 16rpx;
.setting-item {
display: flex;
align-items: center;
padding: 24rpx;
border-radius: 16rpx;
background-color: #f8fafc;
border: 2rpx solid #f0f4f8;
transition: all 0.2s ease;
&:hover {
background-color: #e9edf2;
border-color: #d9e3ed;
}
.setting-icon {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background-color: #c4e0ff;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
flex-shrink: 0;
}
.setting-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
gap: 4rpx;
.setting-label {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
.setting-desc {
font-size: 22rpx;
color: #999;
}
}
.iconfont.icon-right {
font-size: 36rpx;
color: #ccc;
flex-shrink: 0;
}
}
}
}
}
</style>