Files
lucky_shop/pages_tool/ai-chat/index.vue
ZF sun 2d8846c64c feat: 新增多语言文案、优化导航及工具函数,修复部分业务逻辑
1. 新增多语言资源文件,覆盖文章、预约日期、会员信息、优惠券、表单、购物车、积分商城、分销、AI聊天等多个模块
2. 优化底部导航组件,使用多语言配置替代硬编码文案
3. 重构工具函数,新增从后端数据提取多语言文案的工具方法
4. 修复邀请好友页面的保存提示文案,替换为多语言版本
5. 修复商品SKU组件的提示文案,使用多语言配置
6. 重构AI聊天页面的帮助文档和回复模板,使用多语言配置
7. 优化空状态组件,支持多语言默认文案
8. 修复跳转逻辑,补全参数传递
9. 新增本地开发环境配置
2026-06-25 18:15:31 +08:00

783 lines
20 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('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('changeAvatar') }}</text>
<text class="setting-desc">{{ $lang('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('changeNickname') }}</text>
<text class="setting-desc">{{ $lang('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: '',
// --- 系统导航栏/状态栏相关 ---
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('fallbackWelcome');
// ✅ 修改:取消权限校验,允许所有客服类型访问此页面
// const hasAccess = this.checkAccessPermission();
// if (!hasAccess) {
// // 如果校验失败,不继续初始化
// return;
// }
this.$langConfig.title(this.$lang('title'));
this.initChat();
},
async onReady() {
await this.initNavigation();
},
onShow() {
console.log('[AI Chat Page] onShow 触发');
// 监听键盘高度变化(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('onKeyboardHeightChange 不可用:', 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('[AI Chat Page] onUnload 触发');
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('事件处理错误:', {
error: error.message,
eventType: event?.type,
component: this. $options.name
});
this.showError(this.$lang('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('初始化导航栏失败:', 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('menuClearChat'), this.$lang('menuExport'), this.$lang('menuSettings'), this.$lang('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('prompt'),
content: this.$lang('confirmClearChat'),
success: (res) => {
if (res.confirm) {
this. $refs.chat.clearMessages();
this. $refs.chat.addMessage({
id: Date.now(),
role: 'ai',
type: 'text',
content: this.$lang('chatCleared'),
timestamp: Date.now()
});
}
}
});
},
exportChat() {
uni.showToast({
title: this.$lang('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('helpContent'),
timestamp: Date.now()
};
this.$refs.chat.addMessage(helpMessage);
},
onMessageSent(message) {
},
onAIResponse(message) {
},
generateAIResponse(userMessage) {
const responses = [
this.$lang('aiResponseTemplate1'),
this.$lang('aiResponseTemplate2'),
this.$lang('aiResponseTemplate3'),
this.$lang('aiResponseTemplate4'),
this.$lang('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('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('thanksFeedback'), icon: 'success' });
break;
case 'dislike':
uni.showToast({ title: this.$lang('improveService'), icon: 'none' });
break;
}
},
onHistoryLoaded(messages) {
},
onFilePreview(message) {
uni.showToast({ title: this.$lang('openingFileName', { name: message.fileName }), icon: 'none' });
},
onAudioPlay(message) {
},
onAudioPause(message) {
},
onVideoPlay(message) {
},
onVideoPause(message) {
},
sendProductToAI(product) {
if (!this.$refs.chat) {
console.error('聊天组件未初始化')
return
}
const productContent = this.$lang('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('openLink'),
content: this.$lang('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('openingFileTitle', { title: message.title }), icon: 'none' });
},
onInputChange(value) {
},
// ✅ 新增:权限校验(核心修复)
checkAccessPermission() {
const servicerConfig = this. $store.state.servicerConfig;
// 如果配置未加载,延迟重试(最多3次,防白屏)
if (!servicerConfig) {
console.warn('【AI客服】servicerConfig 未加载,200ms后重试...');
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('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">
/* 引入图标字体 */
@import url('/common/css/iconfont.css');
/* 页面样式 */
.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>