refactor(ai-chat): 重构AI聊天国际化文件结构

- 将 ai-chat 语言文件从 ai/ 迁移至 ai-chat/ 目录
- 重构消息组件及多个子组件
This commit is contained in:
2026-05-18 15:36:13 +08:00
parent 5c56cfaaa7
commit 556ce9d132
12 changed files with 312 additions and 96 deletions

View File

@@ -0,0 +1,99 @@
export const lang = {
//title for each page
title: 'AI Customer Service',
// ====== Common ======
cancel: 'Cancel',
save: 'Save',
send: 'Send',
retry: 'Retry',
// ====== ai-chat-message ======
aiNickname: 'AI Assistant',
defaultNickname: 'Me',
audioMessage: 'Voice Message',
videoMessage: 'Video Message',
inputPlaceholder: 'Please enter your question...',
expandEditorTitle: 'Edit Content',
loadMoreHistory: 'Load more history',
loadingHistory: 'Loading history...',
aiThinking: 'AI is thinking',
receivingReply: 'Receiving reply',
aiThinkingStatus1: 'AI is thinking',
aiThinkingStatus2: 'AI is analyzing',
aiThinkingStatus3: 'AI is searching',
aiThinkingStatus4: 'AI is generating',
serviceUnavailable: 'Sorry, the AI service is temporarily unavailable. Please try again later.',
serviceTemporaryUnavailable: 'Sorry, the service is temporarily unavailable. Please try again later.',
noContentToCopy: 'No content to copy',
copySuccess: 'Copied',
copySelected: 'Copied selected content',
copyFail: 'Copy failed',
openingFile: 'Opening file...',
fileOpenFail: 'Failed to open file',
loadHistoryFail: 'Failed to load history',
about: '0B',
// ====== ai-chat-search ======
searchEntrance: 'Search',
searchPlaceholder: 'Search chat history',
searchClose: 'Done',
noMatchResult: 'No matching results',
searchAtTop: 'Already at top',
searchAtBottom: 'Already at bottom',
// ====== index.vue ======
settingsTitle: 'Settings',
fallbackWelcome: 'Hello! I am an AI customer service assistant, glad to serve you!\n\nI can help you with:\n• Product-related questions\n• Order issues\n• Technical support\n• Product recommendations\n\nPlease tell me how I can assist you?',
changeAvatar: 'Change Avatar',
changeAvatarDesc: 'Customize your chat avatar',
changeNickname: 'Change Nickname',
changeNicknameDesc: 'Customize your nickname',
clearChat: 'Clear Chat',
exportChat: 'Export Records',
help: 'Help',
confirmClearChat: 'Are you sure you want to clear the chat?',
chatCleared: 'Chat history has been cleared. How can I help you?',
featureDeveloping: 'Feature under development',
thanksFeedback: 'Thank you for your feedback!',
improveService: 'We will improve our service',
openLink: 'Open Link',
confirmOpenLink: 'Are you sure you want to open this link: ',
operationFailed: 'Operation failed, please try again',
prompt: 'Prompt',
serviceTypeNotSupported: 'Current service type does not support this page',
// ====== ai-chat-avatar-selector ======
avatarSelectorTitle: 'Change Avatar',
chooseFromAlbum: 'Choose from Album',
avatarChangeSuccess: 'Avatar changed successfully',
enableAlbumPermission: 'Please enable album permission',
// ====== ai-chat-emoji-panel ======
emojiTitle: 'Emoji',
// ====== ai-chat-nickname-editor ======
editNickname: 'Edit Nickname',
newNicknamePlaceholder: 'Please enter your new nickname',
nicknameTip: 'Nickname length 1-12 characters, visible only to you',
nicknameChangeSuccess: 'Nickname changed successfully',
// ====== ai-chat-tools-panel ======
moreFunctions: 'More Features',
image: 'Image',
file: 'File',
voice: 'Voice',
location: 'Location',
// ====== ai-chat-voice-panel ======
voiceInputTitle: 'Voice Input',
recordingTip: 'Recording, release to end',
startRecordTip: 'Tap to start recording',
voiceInputFailed: 'Voice input failed',
// ====== index.vue - Menu ======
menuClearChat: 'Clear Chat',
menuExport: 'Export Records',
menuSettings: 'Settings',
menuHelp: 'Help'
}

View File

@@ -1,4 +0,0 @@
export const lang = {
//title为每个页面的标题
title: 'AI Intelligent Customer Service'
}

View File

@@ -0,0 +1,99 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能客服',
// ====== 通用 ======
cancel: '取消',
save: '保存',
send: '发送',
retry: '重试',
// ====== ai-chat-message ======
aiNickname: '智能助手',
defaultNickname: '我',
audioMessage: '语音消息',
videoMessage: '视频消息',
inputPlaceholder: '请输入您的问题...',
expandEditorTitle: '编辑内容',
loadMoreHistory: '加载更多历史消息',
loadingHistory: '加载历史消息中...',
aiThinking: 'AI正在思考',
receivingReply: '正在接收回复',
aiThinkingStatus1: 'AI正在思考',
aiThinkingStatus2: 'AI正在分析',
aiThinkingStatus3: 'AI正在检索',
aiThinkingStatus4: 'AI正在生成',
serviceUnavailable: '抱歉,AI服务暂时不可用,请稍后重试。',
serviceTemporaryUnavailable: '抱歉,服务暂时不可用,请稍后重试。',
noContentToCopy: '无可复制的内容',
copySuccess: '已复制',
copySelected: '已复制选中内容',
copyFail: '复制失败',
openingFile: '打开文件中...',
fileOpenFail: '文件打开失败',
loadHistoryFail: '加载历史失败',
about: '0B',
// ====== ai-chat-search ======
searchEntrance: '搜索',
searchPlaceholder: '搜索聊天记录',
searchClose: '完成',
noMatchResult: '未找到匹配结果',
searchAtTop: '已到顶部',
searchAtBottom: '已到底部',
// ====== index.vue ======
settingsTitle: '设置',
fallbackWelcome: '您好!我是AI智能客服,很高兴为您服务!\n\n我可以帮您:\n• 解答产品相关问题\n• 处理订单问题\n• 提供技术支持\n• 推荐相关商品\n\n请告诉我您需要什么帮助?',
changeAvatar: '更换头像',
changeAvatarDesc: '自定义您的聊天头像',
changeNickname: '更换昵称',
changeNicknameDesc: '自定义您的昵称',
clearChat: '清空聊天',
exportChat: '导出记录',
help: '帮助',
confirmClearChat: '确定要清空聊天记录吗?',
chatCleared: '聊天记录已清空,有什么可以帮助您的吗?',
featureDeveloping: '功能开发中',
thanksFeedback: '感谢您的反馈!',
improveService: '我们会改进服务',
openLink: '打开链接',
confirmOpenLink: '确定要打开链接:',
operationFailed: '操作失败,请重试',
prompt: '提示',
serviceTypeNotSupported: '当前客服类型不支持此页面',
// ====== ai-chat-avatar-selector ======
avatarSelectorTitle: '更换头像',
chooseFromAlbum: '从相册选择',
avatarChangeSuccess: '头像更换成功',
enableAlbumPermission: '请开启相册权限',
// ====== ai-chat-emoji-panel ======
emojiTitle: '表情',
// ====== ai-chat-nickname-editor ======
editNickname: '修改昵称',
newNicknamePlaceholder: '请输入您的新昵称',
nicknameTip: '昵称长度限制1-12个字符,仅自己可见',
nicknameChangeSuccess: '昵称修改成功',
// ====== ai-chat-tools-panel ======
moreFunctions: '更多功能',
image: '图片',
file: '文件',
voice: '语音',
location: '位置',
// ====== ai-chat-voice-panel ======
voiceInputTitle: '语音输入',
recordingTip: '正在录音,松开结束',
startRecordTip: '点击开始录音',
voiceInputFailed: '语音输入失败',
// ====== index.vue - 菜单 ======
menuClearChat: '清空聊天',
menuExport: '导出记录',
menuSettings: '设置',
menuHelp: '帮助'
}

View File

@@ -1,4 +0,0 @@
export const lang = {
//title为每个页面的标题
title: 'AI智能客服'
}

View File

@@ -4,6 +4,7 @@
<ai-chat-search <ai-chat-search
ref="searchRef" ref="searchRef"
:messages="messages" :messages="messages"
:show-label="false"
@scroll-to="onSearchScrollTo" @scroll-to="onSearchScrollTo"
@active-change="onSearchActiveChange" @active-change="onSearchActiveChange"
@result-change="onSearchResultChange" @result-change="onSearchResultChange"
@@ -73,7 +74,7 @@
<view class="message-content"> <view class="message-content">
<!-- AI昵称固定,不可修改 - 左移到气泡上方 --> <!-- AI昵称固定,不可修改 - 左移到气泡上方 -->
<view class="message-nickname ai-nickname"> <view class="message-nickname ai-nickname">
<text class="ai-nickname-text">智能助手</text> <text class="ai-nickname-text">{{ $lang('aiNickname') }}</text>
<text class="message-time">{{ formatTime(message.timestamp) }}</text> <text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view> </view>
<view class="message-bubble" <view class="message-bubble"
@@ -118,7 +119,7 @@
<view v-else-if="message.type === 'audio'" class="audio-content"> <view v-else-if="message.type === 'audio'" class="audio-content">
<view class="audio-player"> <view class="audio-player">
<view class="audio-info"> <view class="audio-info">
<text class="audio-title">{{ message.title || '语音消息' }}</text> <text class="audio-title">{{ message.title || $lang('audioMessage') }}</text>
<text class="audio-duration">{{ formatDuration(message.duration) }}</text> <text class="audio-duration">{{ formatDuration(message.duration) }}</text>
</view> </view>
<view class="audio-controls"> <view class="audio-controls">
@@ -156,7 +157,7 @@
@pause="onVideoPause(message)"> @pause="onVideoPause(message)">
</video> </video>
<view class="video-info"> <view class="video-info">
<text class="video-title">{{ message.title || '视频消息' }}</text> <text class="video-title">{{ message.title || $lang('videoMessage') }}</text>
<text class="video-duration">{{ formatDuration(message.duration) }}</text> <text class="video-duration">{{ formatDuration(message.duration) }}</text>
</view> </view>
</view> </view>
@@ -186,7 +187,7 @@
<text class="dot"></text> <text class="dot"></text>
</view> </view>
<text class="loading-text"> <text class="loading-text">
<text class="loading-status">{{ message.loadingText || 'AI正在思考' }}</text> <text class="loading-status">{{ message.loadingText || $lang('aiThinking') }}</text>
<text class="loading-ellipsis"> <text class="loading-ellipsis">
<text class="ellipsis-dot">.</text> <text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text> <text class="ellipsis-dot">.</text>
@@ -231,7 +232,7 @@
<textarea <textarea
v-model="inputText" v-model="inputText"
class="message-input" class="message-input"
placeholder="请输入您的问题..." :placeholder="$lang('inputPlaceholder')"
:maxlength="500" :maxlength="500"
:auto-height="true" :auto-height="true"
:show-confirm-bar="false" :show-confirm-bar="false"
@@ -257,7 +258,7 @@
:class="{ disabled: !canSendMessage }" :class="{ disabled: !canSendMessage }"
@click="sendMessage" @click="sendMessage"
:disabled="!canSendMessage"> :disabled="!canSendMessage">
<text>发送</text> <text>{{ $lang('send') }}</text>
</button> </button>
</view> </view>
</view> </view>
@@ -282,7 +283,7 @@
<view class="expand-editor-mask" @click="closeExpandEditor"></view> <view class="expand-editor-mask" @click="closeExpandEditor"></view>
<view class="expand-editor-panel"> <view class="expand-editor-panel">
<view class="expand-editor-header"> <view class="expand-editor-header">
<text>编辑内容</text> <text>{{ $lang('expandEditorTitle') }}</text>
<button class="close-btn" @click="closeExpandEditor"> <button class="close-btn" @click="closeExpandEditor">
<text>×</text> <text>×</text>
</button> </button>
@@ -290,15 +291,15 @@
<textarea <textarea
v-model="expandInputText" v-model="expandInputText"
class="expand-editor-input" class="expand-editor-input"
placeholder="请输入您的问题..." :placeholder="$lang('inputPlaceholder')"
:maxlength="500" :maxlength="500"
:auto-height="true" :auto-height="true"
:show-confirm-bar="false" :show-confirm-bar="false"
:adjust-position="true" :adjust-position="true"
:focus="true" /> :focus="true" />
<view class="expand-editor-footer"> <view class="expand-editor-footer">
<button class="expand-cancel-btn" @click="closeExpandEditor">取消</button> <button class="expand-cancel-btn" @click="closeExpandEditor">{{ $lang('cancel') }}</button>
<button class="expand-send-btn" @click="sendFromExpandEditor" :disabled="!expandInputText.trim()">发送</button> <button class="expand-send-btn" @click="sendFromExpandEditor" :disabled="!expandInputText.trim()">{{ $lang('send') }}</button>
</view> </view>
</view> </view>
</view> </view>
@@ -396,7 +397,7 @@ export default {
scrollTop: 0, scrollTop: 0,
scrollIntoViewId: null, scrollIntoViewId: null,
hasMoreHistory: false, hasMoreHistory: false,
loadingText: '加载更多历史消息', loadingText: this.$lang('loadMoreHistory'),
voiceInputing: false, voiceInputing: false,
audioContext: null, audioContext: null,
currentAudio: null, currentAudio: null,
@@ -416,7 +417,7 @@ export default {
localUserAvatar: '', localUserAvatar: '',
showAvatarSelector: false, // 头像选择弹窗控制 showAvatarSelector: false, // 头像选择弹窗控制
localUserNickname: '', // 当前用户昵称 localUserNickname: '', // 当前用户昵称
defaultNickname: '我', // 默认昵称 defaultNickname: this.$lang('defaultNickname'), // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制 showNicknameEditor: false, // 昵称编辑弹窗控制
// AI头像错误状态 // AI头像错误状态
avatarError: false, avatarError: false,
@@ -823,12 +824,12 @@ async loadChatHistoryIfExist() {
type: 'loading', type: 'loading',
content: '', content: '',
timestamp: Date.now(), timestamp: Date.now(),
loadingText: 'AI正在思考', loadingText: this.$lang('aiThinking'),
_loadingIndex: 0 _loadingIndex: 0
} }
this.shouldScrollToBottom = true this.shouldScrollToBottom = true
this.messages.push(loadingMessage) this.messages.push(loadingMessage)
// 启动处理状态文字轮换 // 启动处理状态文字轮换
this._startLoadingTextRotation(loadingMessage) this._startLoadingTextRotation(loadingMessage)
@@ -856,10 +857,10 @@ async loadChatHistoryIfExist() {
id: ++this.messageId, id: ++this.messageId,
role: 'ai', role: 'ai',
type: 'text', type: 'text',
content: '抱歉,AI服务暂时不可用,请稍后重试。', content: this.$lang('serviceUnavailable'),
timestamp: Date.now(), timestamp: Date.now(),
actions: [ actions: [
{ id: 1, text: '重试', type: 'retry' } { id: 1, text: this.$lang('retry'), type: 'retry' }
] ]
} }
this.shouldScrollToBottom = true this.shouldScrollToBottom = true
@@ -940,7 +941,7 @@ async sendStreamMessage(userMessage) {
this.isStreaming = true this.isStreaming = true
const loadingMsg = this.messages[loadingIdx] const loadingMsg = this.messages[loadingIdx]
this._stopLoadingTextRotation(loadingMsg) this._stopLoadingTextRotation(loadingMsg)
loadingMsg.loadingText = '正在接收回复' loadingMsg.loadingText = this.$lang('receivingReply')
let transformed = false let transformed = false
@@ -1006,15 +1007,16 @@ async sendStreamMessage(userMessage) {
id: ++this.messageId, id: ++this.messageId,
role: 'ai', role: 'ai',
type: 'text', type: 'text',
content: '抱歉,服务暂时不可用,请稍后重试。', content: this.$lang('serviceTemporaryUnavailable'),
timestamp: Date.now(), timestamp: Date.now(),
actions: [{ id: 1, text: '重试', type: 'retry' }] actions: [{ id: 1, text: this.$lang('retry'), type: 'retry' }]
} }
this.messages.push(errorMsg) this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg) this.$emit('ai-response', errorMsg)
} }
}, },
// 完成流式消息处理(共用逻辑) // 完成流式消息处理(共用逻辑)
_finishStream(msg, result) { _finishStream(msg, result) {
msg.isStreaming = false msg.isStreaming = false
@@ -1051,9 +1053,9 @@ _handleStreamError(msg) {
id: ++this.messageId, id: ++this.messageId,
role: 'ai', role: 'ai',
type: 'text', type: 'text',
content: '抱歉,服务暂时不可用,请稍后重试。', content: this.$lang('serviceTemporaryUnavailable'),
timestamp: Date.now(), timestamp: Date.now(),
actions: [{ id: 1, text: '重试', type: 'retry' }] actions: [{ id: 1, text: this.$lang('retry'), type: 'retry' }]
} }
this.messages.push(errorMsg) this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg) this.$emit('ai-response', errorMsg)
@@ -1339,9 +1341,9 @@ _handleStreamError(msg) {
// 格式化文件大小 // 格式化文件大小
formatFileSize(bytes) { formatFileSize(bytes) {
if (!bytes) return '0B' if (!bytes) return this.$lang('about')
const k = 1024 const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB'] const sizes = [this.$lang('about'), 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i]
}, },
@@ -1429,7 +1431,7 @@ _handleStreamError(msg) {
} }
} catch (error) { } catch (error) {
console.error('加载历史失败:', error); console.error('加载历史失败:', error);
uni.showToast({ title: '加载历史失败', icon: 'none' }); uni.showToast({ title: this.$lang('loadHistoryFail'), icon: 'none' });
this.hasMoreHistory = false; this.hasMoreHistory = false;
} finally { } finally {
this.isLoadingHistory = false; this.isLoadingHistory = false;
@@ -1444,7 +1446,7 @@ _handleStreamError(msg) {
// 预览文件 // 预览文件
previewFile(message) { previewFile(message) {
uni.showLoading({ title: '打开文件中...' }) uni.showLoading({ title: this.$lang('openingFile') })
// #ifdef H5 // #ifdef H5
window.open(message.url, '_blank') window.open(message.url, '_blank')
@@ -1454,7 +1456,7 @@ _handleStreamError(msg) {
plus.runtime.openFile(message.url, { plus.runtime.openFile(message.url, {
error: (e) => { error: (e) => {
uni.showToast({ uni.showToast({
title: '文件打开失败', title: this.$lang('fileOpenFail'),
icon: 'none' icon: 'none'
}) })
} }
@@ -1628,7 +1630,7 @@ _handleStreamError(msg) {
copyText = message.content || '' copyText = message.content || ''
} }
if (!copyText) { if (!copyText) {
uni.showToast({ title: '无可复制的内容', icon: 'none' }) uni.showToast({ title: this.$lang('noContentToCopy'), icon: 'none' })
return return
} }
@@ -1661,12 +1663,12 @@ _handleStreamError(msg) {
data: copyText, data: copyText,
success: () => { success: () => {
console.log('[AI Chat] 复制成功') console.log('[AI Chat] 复制成功')
uni.showToast({ title: '已复制', icon: 'success' }) uni.showToast({ title: this.$lang('copySuccess'), icon: 'success' })
}, },
fail: (err) => { fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err)) console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
console.error('[AI Chat] 复制失败 errMsg:', err.errMsg || err.message || '无错误消息') console.error('[AI Chat] 复制失败 errMsg:', err.errMsg || err.message || '无错误消息')
uni.showToast({ title: '复制失败: ' + (err.errMsg || '未知错误'), icon: 'none', duration: 3000 }) uni.showToast({ title: this.$lang('copyFail') + ': ' + (err.errMsg || this.$lang('serviceUnavailable')), icon: 'none', duration: 3000 })
} }
}) })
// #endif // #endif
@@ -1687,12 +1689,12 @@ _handleStreamError(msg) {
const success = document.execCommand('copy') const success = document.execCommand('copy')
document.body.removeChild(textarea) document.body.removeChild(textarea)
if (success) { if (success) {
uni.showToast({ title: '已复制', icon: 'success' }) uni.showToast({ title: this.$lang('copySuccess'), icon: 'success' })
} else { } else {
uni.showToast({ title: '复制失败', icon: 'none' }) uni.showToast({ title: this.$lang('copyFail'), icon: 'none' })
} }
} catch (e) { } catch (e) {
uni.showToast({ title: '复制失败', icon: 'none' }) uni.showToast({ title: this.$lang('copyFail'), icon: 'none' })
} }
}, },
@@ -1795,10 +1797,10 @@ _handleStreamError(msg) {
type: 'loading', type: 'loading',
content: '', content: '',
timestamp: Date.now(), timestamp: Date.now(),
loadingText: 'AI正在思考', loadingText: this.$lang('aiThinking'),
_loadingIndex: 0 _loadingIndex: 0
} }
this.messages.push(loadingMessage) this.messages.push(loadingMessage)
this._startLoadingTextRotation(loadingMessage) this._startLoadingTextRotation(loadingMessage)
@@ -1826,10 +1828,10 @@ _handleStreamError(msg) {
id: ++this.messageId, id: ++this.messageId,
role: 'ai', role: 'ai',
type: 'text', type: 'text',
content: '抱歉,AI服务暂时不可用,请稍后重试。', content: this.$lang('serviceUnavailable'),
timestamp: Date.now(), timestamp: Date.now(),
actions: [ actions: [
{ id: 1, text: '重试', type: 'retry' } { id: 1, text: this.$lang('retry'), type: 'retry' }
] ]
} }

View File

@@ -3,7 +3,7 @@
<view class="avatar-selector-mask" @click="close"></view> <view class="avatar-selector-mask" @click="close"></view>
<view class="avatar-selector-panel"> <view class="avatar-selector-panel">
<view class="avatar-selector-header"> <view class="avatar-selector-header">
<text>更换头像</text> <text>{{ $lang('avatarSelectorTitle') }}</text>
<button class="close-btn" @click="close"> <button class="close-btn" @click="close">
<text class="iconfont icon-close"></text> <text class="iconfont icon-close"></text>
</button> </button>
@@ -15,10 +15,10 @@
<view class="avatar-selector-actions"> <view class="avatar-selector-actions">
<button class="avatar-select-action" @click="chooseAvatarFromAlbum"> <button class="avatar-select-action" @click="chooseAvatarFromAlbum">
<text class="iconfont icon-album"></text> <text class="iconfont icon-album"></text>
<text>从相册选择</text> <text>{{ $lang('chooseFromAlbum') }}</text>
</button> </button>
<button class="avatar-select-action cancel" @click="close"> <button class="avatar-select-action cancel" @click="close">
<text>取消</text> <text>{{ $lang('cancel') }}</text>
</button> </button>
</view> </view>
</view> </view>
@@ -80,7 +80,7 @@ export default {
reader.readAsDataURL(file) reader.readAsDataURL(file)
this.$emit('avatar-change', tempFilePath) this.$emit('avatar-change', tempFilePath)
this.close() this.close()
uni.showToast({ title: '头像更换成功', icon: 'success', duration: 1500 }) uni.showToast({ title: this.$lang('avatarChangeSuccess'), icon: 'success', duration: 1500 })
} else { } else {
this.close() this.close()
} }
@@ -99,11 +99,11 @@ export default {
uni.setStorageSync('user_avatar', tempFilePath) uni.setStorageSync('user_avatar', tempFilePath)
this.$emit('avatar-change', tempFilePath) this.$emit('avatar-change', tempFilePath)
this.close() this.close()
uni.showToast({ title: '头像更换成功', icon: 'success', duration: 1500 }) uni.showToast({ title: this.$lang('avatarChangeSuccess'), icon: 'success', duration: 1500 })
}, },
fail: (error) => { fail: (error) => {
if (error.errMsg && error.errMsg.includes('deny')) { if (error.errMsg && error.errMsg.includes('deny')) {
uni.showToast({ title: '请开启相册权限', icon: 'none', duration: 2000 }) uni.showToast({ title: this.$lang('enableAlbumPermission'), icon: 'none', duration: 2000 })
} }
this.close() this.close()
} }

View File

@@ -3,7 +3,7 @@
<view class="emoji-mask" @click="hideEmojiPanel"></view> <view class="emoji-mask" @click="hideEmojiPanel"></view>
<view class="emoji-panel"> <view class="emoji-panel">
<view class="emoji-header"> <view class="emoji-header">
<text>表情</text> <text>{{ $lang('emojiTitle') }}</text>
<button class="close-btn" @click="hideEmojiPanel"> <button class="close-btn" @click="hideEmojiPanel">
<text>×</text> <text>×</text>
</button> </button>

View File

@@ -3,7 +3,7 @@
<view class="nickname-editor-mask" @click="close"></view> <view class="nickname-editor-mask" @click="close"></view>
<view class="nickname-editor-panel"> <view class="nickname-editor-panel">
<view class="nickname-editor-header"> <view class="nickname-editor-header">
<text>修改昵称</text> <text>{{ $lang('editNickname') }}</text>
<button class="close-btn" @click="close"> <button class="close-btn" @click="close">
<text class="iconfont icon-close"></text> <text class="iconfont icon-close"></text>
</button> </button>
@@ -13,21 +13,21 @@
<input <input
v-model="tempNickname" v-model="tempNickname"
class="nickname-input" class="nickname-input"
placeholder="请输入您的新昵称" :placeholder="$lang('newNicknamePlaceholder')"
:maxlength="12" :maxlength="12"
type="text" /> type="text" />
</view> </view>
<view class="nickname-tip">昵称长度限制1-12个字符,仅自己可见</view> <view class="nickname-tip">{{ $lang('nicknameTip') }}</view>
<view class="nickname-editor-actions"> <view class="nickname-editor-actions">
<button class="nickname-action-btn cancel" @click="close"> <button class="nickname-action-btn cancel" @click="close">
<text>取消</text> <text>{{ $lang('cancel') }}</text>
</button> </button>
<button <button
class="nickname-action-btn confirm" class="nickname-action-btn confirm"
:class="{ disabled: !tempNickname.trim() }" :class="{ disabled: !tempNickname.trim() }"
:disabled="!tempNickname.trim()" :disabled="!tempNickname.trim()"
@click="saveNickname"> @click="saveNickname">
<text>保存</text> <text>{{ $lang('save') }}</text>
</button> </button>
</view> </view>
</view> </view>
@@ -87,7 +87,7 @@ export default {
uni.setStorageSync('user_nickname', newNickname) uni.setStorageSync('user_nickname', newNickname)
this.$emit('nickname-change', newNickname) this.$emit('nickname-change', newNickname)
this.close() this.close()
uni.showToast({ title: '昵称修改成功', icon: 'success', duration: 1500 }) uni.showToast({ title: this.$lang('nicknameChangeSuccess'), icon: 'success', duration: 1500 })
} }
} }
} }

View File

@@ -1,9 +1,9 @@
<template> <template>
<view class="ai-chat-search"> <view class="ai-chat-search">
<!-- 搜索入口按钮(浮动在消息区右上角) --> <!-- 搜索入口按钮(浮动在消息区右上角) -->
<view class="search-entrance" v-if="!showSearchBar" @click="toggleSearch"> <view class="search-entrance" :class="{ 'icon-only': !showLabel }" v-if="!showSearchBar" @click="toggleSearch">
<text class="iconfont icon-sousuo search-entrance-icon"></text> <text class="iconfont icon-sousuo search-entrance-icon"></text>
<text class="search-entrance-label">搜索</text> <text class="search-entrance-label" v-if="showLabel">{{ $lang('searchEntrance') }}</text>
</view> </view>
<!-- 搜索浮条 --> <!-- 搜索浮条 -->
@@ -14,7 +14,7 @@
<input <input
class="search-bar-input" class="search-bar-input"
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索聊天记录" :placeholder="$lang('searchPlaceholder')"
:focus="true" :focus="true"
@input="onSearchInput" @input="onSearchInput"
@confirm="doSearch" /> @confirm="doSearch" />
@@ -27,7 +27,7 @@
<text class="search-bar-nav" :class="{ disabled: searchResultIndex >= searchResults.length - 1 }" @click="scrollToNextResult">▼</text> <text class="search-bar-nav" :class="{ disabled: searchResultIndex >= searchResults.length - 1 }" @click="scrollToNextResult">▼</text>
</view> </view>
</view> </view>
<text class="search-bar-close" @click="closeSearch">完成</text> <text class="search-bar-close" @click="closeSearch">{{ $lang('searchClose') }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -39,6 +39,7 @@
* *
* Props: * Props:
* messages: Array - 原始消息列表 * messages: Array - 原始消息列表
* showLabel: Boolean - (默认 true) 是否显示"搜索"文字;设为 false 则只显示搜索图标
* *
* Events: * Events:
* scroll-to: (messageKey: string) => void - 请求父组件滚动到指定消息 * scroll-to: (messageKey: string) => void - 请求父组件滚动到指定消息
@@ -53,6 +54,11 @@ export default {
messages: { messages: {
type: Array, type: Array,
default: () => [] default: () => []
},
// 是否显示文字标签;设为 false 则只显示搜索图标
showLabel: {
type: Boolean,
default: true
} }
}, },
@@ -66,6 +72,7 @@ export default {
}, },
computed: { computed: {
// 过滤后的消息列表(与 v-for 保持一致) // 过滤后的消息列表(与 v-for 保持一致)
filteredMessages() { filteredMessages() {
return this.messages.filter( return this.messages.filter(
@@ -150,7 +157,7 @@ export default {
this._emitScrollTo(filteredIdx) this._emitScrollTo(filteredIdx)
}) })
} else { } else {
uni.showToast({ title: '未找到匹配结果', icon: 'none' }) uni.showToast({ title: this.$lang('noMatchResult'), icon: 'none' })
} }
}, },
@@ -174,7 +181,7 @@ export default {
if (idx >= 0) { if (idx >= 0) {
this.scrollToSearchResult(idx) this.scrollToSearchResult(idx)
} else { } else {
uni.showToast({ title: '已到顶部', icon: 'none' }) uni.showToast({ title: this.$lang('searchAtTop'), icon: 'none' })
} }
}, },
@@ -183,7 +190,7 @@ export default {
if (idx < this.searchResults.length) { if (idx < this.searchResults.length) {
this.scrollToSearchResult(idx) this.scrollToSearchResult(idx)
} else { } else {
uni.showToast({ title: '已到底部', icon: 'none' }) uni.showToast({ title: this.$lang('searchAtBottom'), icon: 'none' })
} }
}, },
@@ -251,7 +258,7 @@ export default {
// 搜索入口按钮 // 搜索入口按钮
.search-entrance { .search-entrance {
position: absolute; position: absolute;
top: 88rpx; top: 44rpx;
right: 16rpx; right: 16rpx;
z-index: 10; z-index: 10;
display: flex; display: flex;
@@ -277,6 +284,15 @@ export default {
&:active { &:active {
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
} }
// 仅显示图标模式(圆形小按钮)
&.icon-only {
width: 56rpx;
height: 56rpx;
padding: 0;
justify-content: center;
border-radius: 50%;
}
} }
// 搜索浮条 // 搜索浮条

View File

@@ -3,7 +3,7 @@
<view class="tools-mask" @click="hideToolsPanel"></view> <view class="tools-mask" @click="hideToolsPanel"></view>
<view class="tools-panel"> <view class="tools-panel">
<view class="tools-header"> <view class="tools-header">
<text>更多功能</text> <text>{{ $lang('moreFunctions') }}</text>
<button class="close-btn" @click="hideToolsPanel"> <button class="close-btn" @click="hideToolsPanel">
<text class="iconfont icon-close"></text> <text class="iconfont icon-close"></text>
</button> </button>
@@ -13,25 +13,25 @@
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-image"></text> <text class="iconfont icon-image"></text>
</view> </view>
<text class="tool-text">图片</text> <text class="tool-text">{{ $lang('image') }}</text>
</view> </view>
<view class="tool-item" @click="selectFile"> <view class="tool-item" @click="selectFile">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-file"></text> <text class="iconfont icon-file"></text>
</view> </view>
<text class="tool-text">文件</text> <text class="tool-text">{{ $lang('file') }}</text>
</view> </view>
<view class="tool-item" @click="startRecord"> <view class="tool-item" @click="startRecord">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-voice"></text> <text class="iconfont icon-voice"></text>
</view> </view>
<text class="tool-text">语音</text> <text class="tool-text">{{ $lang('voice') }}</text>
</view> </view>
<view class="tool-item" @click="selectLocation"> <view class="tool-item" @click="selectLocation">
<view class="tool-icon"> <view class="tool-icon">
<text class="iconfont icon-location"></text> <text class="iconfont icon-location"></text>
</view> </view>
<text class="tool-text">位置</text> <text class="tool-text">{{ $lang('location') }}</text>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -3,7 +3,7 @@
<view class="voice-mask" @click="hideVoicePanel"></view> <view class="voice-mask" @click="hideVoicePanel"></view>
<view class="voice-input-panel"> <view class="voice-input-panel">
<view class="voice-header"> <view class="voice-header">
<text>语音输入</text> <text>{{ $lang('voiceInputTitle') }}</text>
<button class="close-btn" @click="hideVoicePanel"> <button class="close-btn" @click="hideVoicePanel">
<text class="iconfont icon-close"></text> <text class="iconfont icon-close"></text>
</button> </button>
@@ -16,7 +16,7 @@
class="wave-bar" class="wave-bar"
:style="{ animationDelay: (i * 0.1) + 's' }"></view> :style="{ animationDelay: (i * 0.1) + 's' }"></view>
</view> </view>
<text class="voice-tip">{{ voiceInputing ? '正在录音,松开结束' : '点击开始录音' }}</text> <text class="voice-tip">{{ voiceInputing ? $lang('recordingTip') : $lang('startRecordTip') }}</text>
</view> </view>
<view class="voice-actions"> <view class="voice-actions">
<button class="voice-btn" @click="toggleVoiceInput"> <button class="voice-btn" @click="toggleVoiceInput">
@@ -86,7 +86,7 @@ export default {
// 录音中,可扩展实时转文字 // 录音中,可扩展实时转文字
}) })
.catch(error => { .catch(error => {
uni.showToast({ title: '语音输入失败', icon: 'none' }) uni.showToast({ title: this.$lang('voiceInputFailed'), icon: 'none' })
this.voiceInputing = false this.voiceInputing = false
}) })
} }

View File

@@ -39,7 +39,7 @@
<view class="settings-mask" @click="hideSettings"></view> <view class="settings-mask" @click="hideSettings"></view>
<view class="settings-panel"> <view class="settings-panel">
<view class="settings-header"> <view class="settings-header">
<text class="settings-title">设置</text> <text class="settings-title">{{ $lang('settingsTitle') }}</text>
<view class="close-btn" @click="hideSettings"> <view class="close-btn" @click="hideSettings">
<text>✕</text> <text>✕</text>
</view> </view>
@@ -50,8 +50,8 @@
<text class="iconfont icon-user">📷</text> <text class="iconfont icon-user">📷</text>
</view> </view>
<view class="setting-info"> <view class="setting-info">
<text class="setting-label">更换头像</text> <text class="setting-label">{{ $lang('changeAvatar') }}</text>
<text class="setting-desc">自定义您的聊天头像</text> <text class="setting-desc">{{ $lang('changeAvatarDesc') }}</text>
</view> </view>
<text class="iconfont icon-right">›</text> <text class="iconfont icon-right">›</text>
</view> </view>
@@ -60,8 +60,8 @@
<text class="iconfont icon-edit">✏️</text> <text class="iconfont icon-edit">✏️</text>
</view> </view>
<view class="setting-info"> <view class="setting-info">
<text class="setting-label">更换昵称</text> <text class="setting-label">{{ $lang('changeNickname') }}</text>
<text class="setting-desc">自定义您的昵称</text> <text class="setting-desc">{{ $lang('changeNicknameDesc') }}</text>
</view> </view>
<text class="iconfont icon-right">›</text> <text class="iconfont icon-right">›</text>
</view> </view>
@@ -86,7 +86,7 @@ export default {
data() { data() {
return { return {
initialMessages: [], initialMessages: [],
fallbackWelcome: '您好!我是AI智能客服,很高兴为您服务!\n\n我可以帮您:\n• 解答产品相关问题\n• 处理订单问题\n• 提供技术支持\n• 推荐相关商品\n\n请告诉我您需要什么帮助?', fallbackWelcome: this.$lang('fallbackWelcome'),
// --- 系统导航栏/状态栏相关 --- // --- 系统导航栏/状态栏相关 ---
navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px navBarHeight: 44, // uni-page-head 系统导航栏,默认高度为44px
@@ -132,7 +132,7 @@ export default {
// return; // return;
// } // }
this. $langConfig.title('AI智能客服'); this.$langConfig.title(this.$lang('title'));
this.initChat(); this.initChat();
}, },
@@ -144,12 +144,13 @@ export default {
console.log('[AI Chat Page] onShow 触发'); console.log('[AI Chat Page] onShow 触发');
// 监听键盘高度变化(H5 可能不支持,try-catch 保护) // 监听键盘高度变化(H5 可能不支持,try-catch 保护)
try { try {
uni.onKeyboardHeightChange((res) => { this._keyboardChangeHandler = (res) => {
this.keyboardHeight = res.height; this.keyboardHeight = res.height;
if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) { if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) {
this.$refs.chat.updateKeyboardHeight(res.height); this.$refs.chat.updateKeyboardHeight(res.height);
} }
}); };
uni.onKeyboardHeightChange(this._keyboardChangeHandler);
} catch (e) { } catch (e) {
console.warn('onKeyboardHeightChange 不可用:', e.message); console.warn('onKeyboardHeightChange 不可用:', e.message);
} }
@@ -163,7 +164,10 @@ export default {
onHide() { onHide() {
// 取消监听键盘高度变化 // 取消监听键盘高度变化
uni.offKeyboardHeightChange(); if (this._keyboardChangeHandler) {
uni.offKeyboardHeightChange(this._keyboardChangeHandler);
this._keyboardChangeHandler = null;
}
this.keyboardHeight = 0; this.keyboardHeight = 0;
if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) { if (this.$refs.chat && this.$refs.chat.updateKeyboardHeight) {
this.$refs.chat.updateKeyboardHeight(0); this.$refs.chat.updateKeyboardHeight(0);
@@ -233,7 +237,7 @@ export default {
component: this. $options.name component: this. $options.name
}); });
this.showError('操作失败,请重试'); this.showError(this.$lang('operationFailed'));
}, },
// ========== 初始化页面 ========== // ========== 初始化页面 ==========
@@ -287,6 +291,10 @@ export default {
}, },
cleanup() { cleanup() {
if (this._keyboardChangeHandler) {
uni.offKeyboardHeightChange(this._keyboardChangeHandler);
this._keyboardChangeHandler = null;
}
this.safeEventHandlers.forEach((handler, eventType) => { this.safeEventHandlers.forEach((handler, eventType) => {
this. $off(eventType, handler); this. $off(eventType, handler);
}); });
@@ -309,7 +317,7 @@ export default {
// 显示菜单 // 显示菜单
showMenu() { showMenu() {
uni.showActionSheet({ uni.showActionSheet({
itemList: ['清空聊天', '导出记录', '设置', '帮助'], itemList: [this.$lang('menuClearChat'), this.$lang('menuExport'), this.$lang('menuSettings'), this.$lang('menuHelp')],
success: (res) => { success: (res) => {
switch (res.tapIndex) { switch (res.tapIndex) {
case 0: case 0:
@@ -331,8 +339,8 @@ export default {
clearChat() { clearChat() {
uni.showModal({ uni.showModal({
title: '提示', title: this.$lang('prompt'),
content: '确定要清空聊天记录吗?', content: this.$lang('confirmClearChat'),
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
this. $refs.chat.clearMessages(); this. $refs.chat.clearMessages();
@@ -340,7 +348,7 @@ export default {
id: Date.now(), id: Date.now(),
role: 'ai', role: 'ai',
type: 'text', type: 'text',
content: '聊天记录已清空,有什么可以帮助您的吗?', content: this.$lang('chatCleared'),
timestamp: Date.now() timestamp: Date.now()
}); });
} }
@@ -350,7 +358,7 @@ export default {
exportChat() { exportChat() {
uni.showToast({ uni.showToast({
title: '功能开发中', title: this.$lang('featureDeveloping'),
icon: 'none' icon: 'none'
}); });
}, },
@@ -439,10 +447,10 @@ export default {
onActionClick({ action, message }) { onActionClick({ action, message }) {
switch (action.type) { switch (action.type) {
case 'like': case 'like':
uni.showToast({ title: '感谢您的反馈!', icon: 'success' }); uni.showToast({ title: this.$lang('thanksFeedback'), icon: 'success' });
break; break;
case 'dislike': case 'dislike':
uni.showToast({ title: '我们会改进服务', icon: 'none' }); uni.showToast({ title: this.$lang('improveService'), icon: 'none' });
break; break;
} }
}, },
@@ -451,7 +459,7 @@ export default {
}, },
onFilePreview(message) { onFilePreview(message) {
uni.showToast({ title: '打开文件: ' + message.fileName, icon: 'none' }); uni.showToast({ title: this.$lang('openingFile') + ' ' + message.fileName, icon: 'none' });
}, },
onAudioPlay(message) { onAudioPlay(message) {
@@ -480,8 +488,8 @@ export default {
onLinkOpen(message) { onLinkOpen(message) {
uni.showModal({ uni.showModal({
title: '打开链接', title: this.$lang('openLink'),
content: `确定要打开链接: $ {message.url} 吗?`, content: this.$lang('confirmOpenLink') + message.url + '?',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
// #ifdef H5 // #ifdef H5
@@ -496,7 +504,7 @@ export default {
}, },
onProductView(message) { onProductView(message) {
uni.showToast({ title: '查看商品: ' + message.title, icon: 'none' }); uni.showToast({ title: this.$lang('openingFile') + ': ' + message.title, icon: 'none' });
}, },
onInputChange(value) { onInputChange(value) {
@@ -524,7 +532,7 @@ export default {
// #endif // #endif
if (currentType !== 'aikefu') { if (currentType !== 'aikefu') {
uni.showToast({ title: '当前客服类型不支持此页面', icon: 'none' }); uni.showToast({ title: this.$lang('serviceTypeNotSupported'), icon: 'none' });
setTimeout(() => { setTimeout(() => {
const pages = getCurrentPages(); const pages = getCurrentPages();
if (pages.length > 1) { if (pages.length > 1) {