Files
lucky_shop/pages_tool/ai-chat/ai-chat-message.vue
2026-09-11 09:15:12 +08:00

3924 lines
124 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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="ai-chat-container" :style="aiChatContainerStyle">
<!-- 搜索组件 -->
<ai-chat-search
ref="searchRef"
:messages="messages"
:show-label="false"
@scroll-to="onSearchScrollTo"
@active-change="onSearchActiveChange"
@result-change="onSearchResultChange"
@keyword-change="onSearchKeywordChange"
/>
<!-- 聊天消息列表 - scroll-into-view 用于多端滚动定位(兼容微信小程序) -->
<scroll-view
class="chat-messages"
scroll-y
:scroll-into-view="scrollIntoViewId"
:scroll-top="scrollTop"
:scroll-with-animation="true"
:style="chatMessagesStyle"
@scroll="onScroll">
<!-- 加载更多历史消息 -->
<view class="load-more" v-if="hasMoreHistory">
<ns-loading :down-text="loadingText" :is-rotate="true" />
</view>
<!-- 消息列表 -->
<view
v-for="(message, index) in messages.filter(msg => msg.type === 'loading' || (msg.content && msg.content.trim() !== ''))"
:key="getMessageKey(message, index)"
:id="getMessageKey(message, index)"
class="message-item"
:class="[message.role, { 'first-message': index === 0, 'search-current': showSearchBar && searchResultFilteredIndex === index, 'search-flash': showSearchBar && searchFlashAnimatingIndex === index }]">
<!-- 用户消息 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右) -->
<view v-if="message.role === 'user'" class="user-message">
<view class="message-content">
<view class="message-bubble"
@contextmenu.prevent="onMessageLongPress(message)"
@touchstart="onBubbleTouchStart($event, message)"
@touchmove="onBubbleTouchMove"
@touchend="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd">
<rich-text v-if="searchKeyword" :nodes="highlightContent(message.content)" class="message-rich-text" />
<text v-else class="message-text" selectable>{{ message.content }}</text>
</view>
</view>
<view class="avatar" @tap="showAvatarSelector = true">
<image :src="localUserAvatar" mode="aspectFill" show-menu-by-longpress/>
</view>
</view>
<!-- AI消息 -->
<view v-else class="ai-message">
<view class="avatar">
<!-- 智能客服头像 -->
<image
v-if="aiAvatarUrl && !avatarError"
:src="aiAvatarUrl"
mode="aspectFill"
@error="handleAvatarError"
show-menu-by-longpress/>
<text v-else class="avatar-emoji">🤖</text>
</view>
<view class="message-content">
<!-- AI昵称固定,不可修改 - 左移到气泡上方 -->
<view class="message-nickname ai-nickname">
<text class="ai-nickname-text">{{ $lang('ai-chat.index.aiNickname') }}</text>
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
</view>
<view class="message-bubble"
@contextmenu.prevent="onMessageLongPress(message)"
@touchstart="onBubbleTouchStart($event, message)"
@touchmove="onBubbleTouchMove"
@touchend="onBubbleTouchEnd"
@touchcancel="onBubbleTouchEnd">
<!-- 文本消息 -->
<view v-if="message.type === 'text'" class="text-content">
<rich-text :nodes="getAIDisplayContent(message.content)" />
</view>
<!-- Markdown文档 -->
<view v-else-if="message.type === 'markdown'" class="markdown-content">
<view class="markdown-header">
<text class="iconfont icon-markdown"></text>
<text class="markdown-title"></text>
</view>
<view class="markdown-body">
<rich-text :nodes="getAIDisplayContent(message.content)" />
</view>
</view>
<!-- 文件链接 -->
<view v-else-if="message.type === 'file'" class="file-content">
<view class="file-item" @click="previewFile(message)">
<view class="file-icon">
<text class="iconfont icon-file"></text>
</view>
<view class="file-info">
<text class="file-name">{{ message.fileName }}</text>
<text class="file-size">{{ formatFileSize(message.fileSize) }}</text>
</view>
<view class="file-action">
<text class="iconfont icon-download"></text>
</view>
</view>
</view>
<!-- 音频消息 -->
<view v-else-if="message.type === 'audio'" class="audio-content">
<view class="audio-player">
<view class="audio-info">
<text class="audio-title">{{ message.title || $lang('ai-chat.index.audioMessage') }}</text>
<text class="audio-duration">{{ formatDuration(message.duration) }}</text>
</view>
<view class="audio-controls">
<button
class="play-btn"
:class="{ playing: message.playing }"
@click="toggleAudio(message)">
<text class="iconfont" :class="message.playing ? 'icon-pause' : 'icon-play'"></text>
</button>
<slider
class="audio-slider"
:value="message.currentTime || 0"
:max="message.duration || 0"
@changing="onAudioSliderChange"
@change="onAudioSliderChangeEnd"
activeColor="#8a9fb8"
backgroundColor="#e8edf3"
block-size="12" />
</view>
</view>
</view>
<!-- 视频消息 -->
<view v-else-if="message.type === 'video'" class="video-content">
<view class="video-player">
<video
ref="videoRef"
:src="message.url"
:poster="message.cover"
:controls="true"
:autoplay="false"
class="video-element"
@loadedmetadata="onVideoLoaded(message, $event)"
@play="onVideoPlay(message)"
@pause="onVideoPause(message)">
</video>
<view class="video-info">
<text class="video-title">{{ message.title || $lang('ai-chat.index.videoMessage') }}</text>
<text class="video-duration">{{ formatDuration(message.duration) }}</text>
</view>
</view>
</view>
<!-- 跳转链接 -->
<view v-else-if="message.type === 'link'" class="link-content">
<view class="link-card" @click="openLink(message)">
<view class="link-image" v-if="message.image">
<image :src="message.image" mode="aspectFill" show-menu-by-longpress/>
</view>
<view class="link-info">
<text class="link-title">{{ message.title }}</text>
<text class="link-desc">{{ message.description }}</text>
<text class="link-url">{{ message.url }}</text>
</view>
</view>
</view>
<!-- 加载/处理中状态 -->
<view v-else-if="message.type === 'loading'" class="loading-content">
<view class="loading-typing">
<view class="typing-dots">
<text class="dot"></text>
<text class="dot"></text>
<text class="dot"></text>
</view>
<text class="loading-text">
<text class="loading-status">{{ message.loadingText || $lang('ai-chat.index.aiThinking') }}</text>
<text class="loading-ellipsis">
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
<text class="ellipsis-dot">.</text>
</text>
</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="message-actions" v-if="message.actions && message.actions.length > 0">
<button
v-for="action in message.actions"
:key="action.id"
class="action-btn"
:class="[`iconfont`, action.icon, action.type]"
@click="handleAction(action, message)">
{{ action.text }}
</button>
</view>
</view>
<!-- AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 更多 -->
<view class="ai-actions" v-if="message.role === 'ai' && message.type !== 'loading'">
<view class="ai-action-btn" @click="onAiActionCopy(message)">
<text class="iconfont icon-copy">⎘</text>
</view>
<view class="ai-action-btn" :class="{ active: message._liked }" @click="onAiActionLike(message)">
<text class="iconfont icon-thumbsup">👍</text>
</view>
<view class="ai-action-btn" :class="{ active: message._disliked }" @click="onAiActionDislike(message)">
<text class="iconfont icon-thumbsdown">👎</text>
</view>
<view class="ai-action-btn more-btn" @click="onAiActionMore(message)">
<text class="iconfont icon-more">···</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标) -->
<view class="input-area" :style="inputAreaStyle">
<view class="input-card">
<!-- 顶部输入行: 语音图标 + 输入区 + 加号 + 发送 -->
<view class="input-row-top">
<!-- 无文字时左侧显示语音图标,点击才打开语音面板 -->
<view v-if="!hasInputText" class="input-row-icon" @click="onVoiceClick">
<svg class="voice-icon-svg" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#1f1f1f" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 12 h6 l6 -4 v16 l-6 -4 h-6 a2 2 0 0 1 -2 -2 v-4 a2 2 0 0 1 2 -2 z"/>
<path d="M24 10 c2 3 2 9 0 12"/>
</svg>
</view>
<!-- 输入区: textarea 始终可点击输入,有文字时下方显示 Auto 选择器 -->
<view class="input-left-content" :class="{ 'with-voice-icon': !hasInputText }">
<textarea
v-model="inputText"
class="message-input"
placeholder="发消息或按住说话"
placeholder-class="message-input-placeholder"
:maxlength="500"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="false"
:cursor-spacing="150"
@confirm="sendMessage"
@input="onInput"
@linechange="onLineChange" />
<view v-if="hasInputText" class="model-selector" @click="onToggleModelSelector">
<!-- 圆圈 @ 图标,H5/小程序通用 -->
<view class="model-icon-circle">
<text class="model-icon-text">@</text>
</view>
<text class="model-name">{{ selectedModel }}</text>
<text class="model-arrow">▾</text>
</view>
</view>
<view class="input-right-actions">
<button class="top-plus-btn" @click="showMoreTools">
<text class="plus-icon">+</text>
</button>
<!-- 有文字时显示发送按钮 -->
<button v-if="hasInputText" class="top-send-btn" @click="sendMessage">
<!-- H5/小程序都兼容:旋转箭头模拟向上的纸飞机 -->
<text class="send-plane-text">➤</text>
</button>
</view>
</view>
<!-- 底部工具栏: 5 个图标按钮均匀分布 -->
<view class="input-toolbar">
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('task')">
<view class="toolbar-icon-svg">
<!-- 任务: 黑底对话气泡(微信式) + 三个白点 -->
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6 h22 a4 4 0 0 1 4 4 v12 a4 4 0 0 1 -4 4 h-15 l-7 5 v-5 h0 a4 4 0 0 1 -4 -4 v-12 a4 4 0 0 1 4 -4 z" fill="#1f1f1f"/>
<circle cx="11" cy="16" r="1.6" fill="#fff"/>
<circle cx="16" cy="16" r="1.6" fill="#fff"/>
<circle cx="21" cy="16" r="1.6" fill="#fff"/>
</svg>
</view>
<text class="toolbar-text">任务</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('automation')">
<view class="toolbar-icon-svg">
<!-- 自动化: 圆形 + 对勾 -->
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#1f1f1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="16" cy="16" r="11"/>
<path d="M11 16 l4 4 l7 -8"/>
</svg>
</view>
<text class="toolbar-text">自动化</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('library')">
<view class="toolbar-icon-svg">
<!-- 资料库: 打开的书(两页 + 书脊) -->
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#1f1f1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7 c5 -2 10 -2 13 1 c3 -3 8 -3 13 -1 v18 c-5 -2 -10 -2 -13 1 c-3 -3 -8 -3 -13 -1 z"/>
<path d="M16 8 v18"/>
</svg>
</view>
<text class="toolbar-text">资料库</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('expert')">
<view class="toolbar-icon-svg">
<!-- 专家: 圆形表盘 + 时针分针 -->
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#1f1f1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="16" cy="16" r="11"/>
<path d="M16 9 v7 l5 3"/>
</svg>
</view>
<text class="toolbar-text">专家</text>
</view>
<view class="toolbar-item" hover-class="toolbar-item--hover" @click="onToolbarItem('more')">
<view class="toolbar-icon-svg">
<!-- 更多: 2x2 网格方块(apps) -->
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#1f1f1f" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="4" width="9" height="9" rx="1.5"/>
<rect x="19" y="4" width="9" height="9" rx="1.5"/>
<rect x="4" y="19" width="9" height="9" rx="1.5"/>
<rect x="19" y="19" width="9" height="9" rx="1.5"/>
</svg>
</view>
<text class="toolbar-text">更多</text>
</view>
</view>
</view>
</view>
<!-- 更多工具面板 -->
<ai-chat-tools-panel
:visible.sync="showToolsPanel"
@image-selected="onImageSelected"
@file-selected="onFileSelected"
@voice-start="showVoicePanel"
@location-selected="onLocationSelected"
/>
<!-- Emoji面板 -->
<ai-chat-emoji-panel
:visible.sync="showEmojiPanel"
@emoji-selected="insertEmoji"
/>
<!-- 扩大编辑器弹窗 -->
<view v-if="showExpandEditor" class="expand-editor-popup">
<view class="expand-editor-mask" @click="closeExpandEditor"></view>
<view class="expand-editor-panel">
<view class="expand-editor-header">
<text>{{ $lang('ai-chat.index.expandEditorTitle') }}</text>
<button class="close-btn" @click="closeExpandEditor">
<text>×</text>
</button>
</view>
<textarea
v-model="expandInputText"
class="expand-editor-input"
:placeholder="$lang('ai-chat.index.inputPlaceholder')"
:maxlength="500"
:auto-height="true"
:show-confirm-bar="false"
:adjust-position="true"
:focus="true" />
<view class="expand-editor-footer">
<button class="expand-cancel-btn" @click="closeExpandEditor">{{ $lang('ai-chat.index.cancel') }}</button>
<button class="expand-send-btn" @click="sendFromExpandEditor" :disabled="!expandInputText.trim()">{{ $lang('ai-chat.index.send') }}</button>
</view>
</view>
</view>
<!-- 键盘垫高层:键盘弹出时平滑撑开空间将输入框上推 -->
<view class="keyboard-spacer" :style="{ height: keyboardHeight + 'px' }"></view>
<!-- 语音输入面板 -->
<ai-chat-voice-panel
:visible.sync="enableVoicePanelShow"
@close="onVoicePanelClose"
/>
<!-- 头像选择弹窗 -->
<ai-chat-avatar-selector
:visible.sync="showAvatarSelector"
:avatar="localUserAvatar"
@avatar-change="onAvatarChange"
/>
<!-- 昵称编辑弹窗 -->
<ai-chat-nickname-editor
:visible.sync="showNicknameEditor"
:nickname="localUserNickname"
@nickname-change="onNicknameChange"
/>
</view>
</template>
<script>
import aiService, { CONVERSATION_KEY } from '@/common/js/ai-service.js'
import IMAGE_CONFIG from '@/common/js/image-config.js'
import AiChatSearch from './components/ai-chat-search.vue'
import AiChatAvatarSelector from './components/ai-chat-avatar-selector.vue'
import AiChatNicknameEditor from './components/ai-chat-nickname-editor.vue'
import AiChatVoicePanel from './components/ai-chat-voice-panel.vue'
import AiChatToolsPanel from './components/ai-chat-tools-panel.vue'
import AiChatEmojiPanel from './components/ai-chat-emoji-panel.vue'
export default {
name: 'ai-chat-message',
props: {
// 初始消息列表
initialMessages: {
type: Array,
default: () => []
},
// 初始用户头像(外部传入,可选)
initUserAvatar: {
type: String,
default: ''
},
// 初始用户昵称(外部传入,可选)
initUserNickname: {
type: String,
default: ''
},
// 是否显示加载更多
showLoadMore: {
type: Boolean,
default: true
},
// 最大消息数量
maxMessages: {
type: Number,
default: 100
},
// 是否启用流式响应
enableStreaming: {
type: Boolean,
default: true
},
// 流式响应速度(字符/秒)
streamSpeed: {
type: Number,
default: 20
},
// API 获取欢迎语失败时的降级欢迎语
fallbackWelcome: {
type: String,
default: ''
}
},
components: {
AiChatSearch,
AiChatAvatarSelector,
AiChatNicknameEditor,
AiChatVoicePanel,
AiChatToolsPanel,
AiChatEmojiPanel
},
data(){
return {
messages: [],
inputText: '',
scrollTop: 0,
scrollIntoViewId: null,
hasMoreHistory: false,
loadingText: '',
voiceInputing: false,
audioContext: null,
currentAudio: null,
messageId: 0,
showToolsPanel: false,
enableVoicePanelShow: false,
showEmojiPanel: false,
// 扩大编辑器相关
showExpandEditor: false,
expandInputText: '',
inputLineCount: 0,
streamingMessage: null,
streamInterval: null,
streamContent: '',
isStreaming: false,
// 本地用户头像(优先使用缓存,无缓存则用默认图)
localUserAvatar: '',
showAvatarSelector: false, // 头像选择弹窗控制
localUserNickname: '', // 当前用户昵称
defaultNickname: '', // 默认昵称
showNicknameEditor: false, // 昵称编辑弹窗控制
// AI头像错误状态
avatarError: false,
// 当前选中的 AI 模型, 显示在输入框左侧(如 Auto)
selectedModel: 'Auto',
availableModels: ['Auto', 'GPT-4', 'Claude', '文心一言', '通义千问', 'Kimi'],
// AiChat API相关
currentConversationId: null,
isAIServiceAvailable: true,
aiServiceError: null,
isLoadingHistory: false,
shouldScrollToBottom: false,
scrollTimer: null,
lastScrollTop: 0,
historyOffset: 0,
isFetchingHistory: false,
keyboardHeight: 0,
// 手动长按检测相关(touch 计时器)
_longPressTimer: null,
_longPressMessage: null,
// 聊天记录搜索状态(由 AiChatSearch 组件事件同步)
showSearchBar: false,
searchKeyword: '',
searchResultFilteredIndex: -1,
searchFlashAnimatingIndex: -1, // 当前正在播放 flash 动画的消息索引
searchFlashTimer: null, // flash 动画定时器
// 用户发出消息时记录的开始时间戳, AI 回复完成时算 elapsed (已完成 XmXXs)
_aiRequestStart: 0
}
},
computed: {
aiAvatar() {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
defaultAvatar() {
return this.$util.img(IMAGE_CONFIG.userAvatar);
},
aiAvatarUrl() {
return this.$util.img(IMAGE_CONFIG.aiAvatar);
},
chatMessagesStyle() {
// 键盘弹出时增加底部padding
return {
'padding-bottom': this.keyboardHeight > 0 ? this.keyboardHeight + 'px' : '0px'
};
},
aiChatContainerStyle() {
// 键盘高度由末尾垫高 view 处理,容器无需额外样式
return {};
},
inputAreaStyle() {
// 输入框在 flex 流中自然定位,无需额外样式
return {};
},
canSendMessage() {
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
return hasContent || hasEmoji
},
hasInputText() {
return this.inputText && this.inputText.trim() !== ''
}
},
// 新增:组件销毁时清除所有定时器
beforeDestroy() {
if (this.scrollTimer) clearTimeout(this.scrollTimer);
if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer);
this.searchFlashTimer = null;
}
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer);
}
});
},
created() {
this.messages = [...this.initialMessages]
this.messageId = this.messages.length
// 初始化 i18n 字段(在 created 中同步赋值,避免 remote-only 首屏 data() 缓存空串)
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory')
this.defaultNickname = this.$lang('ai-chat.index.defaultNickname')
this.initAudioContext()
// 初始化用户头像(缓存优先)
this.initUserAvatarData()
// 初始化用户昵称(缓存优先)
this.initUserNicknameData()
this.currentConversationId = aiService.getConversationId();
console.log('[AI Chat] created, currentConversationId:', this.currentConversationId, 'messages.length:', this.messages.length);
// 异步获取服务配置(含欢迎语)
this._loadWelcomeMessage()
},
mounted() {
// 初始加载时滚底(仅一次)
this.$nextTick(() => {
this.scrollToBottom()
// 确保 H5 在组件挂载后也尝试加载历史(兜底)
this.loadHistoryIfNeeded()
})
},
watch: {
messages: {
handler(newVal, oldVal) {
// 仅当不是加载历史且不是流式响应时,执行滚底
if (!this.isLoadingHistory && !this.isStreaming && newVal.length > oldVal.length) {
this.$nextTick(() => {
this.scrollToBottom()
})
}
},
deep: true
}
},
methods: {
// ==================== 搜索组件事件处理 ====================
onSearchScrollTo(messageKey) {
this.scrollIntoViewId = ''
if (this.searchFlashTimer) {
clearTimeout(this.searchFlashTimer)
this.searchFlashTimer = null
}
this.searchFlashAnimatingIndex = -1
this.$nextTick(() => {
this.scrollIntoViewId = messageKey
// 延迟触发 flash 动画,让 scroll-into-view 滚动先完成
this.searchFlashTimer = setTimeout(() => {
if (this.searchResultFilteredIndex >= 0) {
this.searchFlashAnimatingIndex = this.searchResultFilteredIndex
this.searchFlashTimer = setTimeout(() => {
this.searchFlashAnimatingIndex = -1
this.searchFlashTimer = null
}, 1200)
}
}, 350)
})
},
onSearchActiveChange(isActive) {
this.showSearchBar = isActive
},
onSearchResultChange(filteredIndex) {
this.searchResultFilteredIndex = -1
this.$nextTick(() => {
this.searchResultFilteredIndex = filteredIndex
})
},
onSearchKeywordChange(keyword) {
this.searchKeyword = keyword
},
// ==================== 面板组件事件处理 ====================
showVoicePanel() {
this.enableVoicePanelShow = true
},
onVoicePanelClose() {
this.voiceInputing = false
},
onAvatarChange(newAvatar) {
this.localUserAvatar = newAvatar
this.$emit('avatar-changed', newAvatar)
},
onNicknameChange(newNickname) {
this.localUserNickname = newNickname
this.$emit('nickname-changed', newNickname)
},
onImageSelected(imageMessage) {
this.messages.push(imageMessage)
this.$emit('image-selected', imageMessage)
},
onFileSelected(fileMessage) {
this.messages.push(fileMessage)
this.$emit('file-selected', fileMessage)
},
onLocationSelected(locationMessage) {
this.messages.push(locationMessage)
this.$emit('location-selected', locationMessage)
},
// 插入 Emoji 到输入框
insertEmoji(emoji) {
this.inputText += emoji
},
/**
* 供父页面调用的加载历史方法
* 在 page onShow 中通过 ref 调用
*/
loadHistoryIfNeeded() {
const localConvId = this.getConversationIdFromLocal();
const shouldReload = this.currentConversationId !== localConvId;
console.log('[AI Chat] loadHistoryIfNeeded | localConvId:', localConvId, '| currentConvId:', this.currentConversationId, '| shouldReload:', shouldReload, '| messages.length:', this.messages.length, '| initialMessages.length:', this.initialMessages.length);
if (localConvId && shouldReload) {
console.log('[AI Chat] → 分支1: ID变化,加载历史');
this.currentConversationId = localConvId;
aiService.setConversationId(localConvId);
this.loadChatHistoryIfExist();
} else if (localConvId && !shouldReload && this.messages.length <= this.initialMessages.length) {
console.log('[AI Chat] → 分支2: ID不变但只有初始消息,从服务端恢复');
this.currentConversationId = localConvId;
this.loadChatHistoryIfExist();
} else if (!localConvId && !this.currentConversationId) {
console.log('[AI Chat] → 分支3: 全新会话');
this.historyOffset = this.messages.length;
} else {
console.log('[AI Chat] → 分支4: 跳过加载,保留当前消息, messages.length:', this.messages.length);
}
},
// 加载欢迎语:优先 API,API 失败或禁用时用父页面传入的 fallbackWelcome
async _loadWelcomeMessage() {
// 仅全新会话才需要欢迎语
if (this.currentConversationId) {
console.log('[AI Chat] 已有会话,跳过欢迎语')
return
}
let welcomeText = ''
let isMarkdown = false
let welcomeLoaded = false
// 1. 尝试从 API 获取远程欢迎语
try {
const config = await aiService.getServiceConfig()
console.log('[AI Chat] API getServiceConfig 返回:', JSON.stringify(config))
if (config) {
const welcomeInfo = config.welcome_info
console.log('[AI Chat] welcomeInfo:', JSON.stringify(welcomeInfo))
const remoteText = welcomeInfo?.welcome || ''
const isEnabled = welcomeInfo?.is_enabled
console.log('[AI Chat] remoteText:', remoteText, 'isEnabled:', isEnabled)
if (remoteText && isEnabled !== 0 && isEnabled !== false) {
welcomeText = remoteText
isMarkdown = true
welcomeLoaded = true
console.log('[AI Chat] 使用远程欢迎语:', welcomeText.substring(0, 50))
}
}
} catch (e) {
console.warn('[AI Chat] 获取服务配置失败:', e)
}
// 2. API 未返回有效欢迎语时,使用降级方案
if (!welcomeLoaded) {
welcomeText = this.fallbackWelcome
isMarkdown = false
if (welcomeText) {
console.log('[AI Chat] 使用降级欢迎语')
} else {
console.log('[AI Chat] 无可用欢迎语')
return
}
}
// 3. 插入欢迎语(再次检查是否仍是全新会话)
if (!this.currentConversationId && this.messages.length === 0) {
const welcomeMsg = {
id: ++this.messageId,
role: 'ai',
type: isMarkdown ? 'markdown' : 'text',
content: welcomeText,
timestamp: Date.now(),
actions: []
}
this.messages.push(welcomeMsg)
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', welcomeMsg)
}
},
// 生成消息的唯一 key(兼容非 H5 平台)
getMessageKey(message, index) {
const rawId = message.id || message.timestamp || index;
// 标准化 ID:转为字符串并替换非法字符(. 和其他非 HTML-safe 字符)
const id = String(rawId).replace(/[^a-zA-Z0-9_-]/g, '-');
return 'msg-' + id + '-' + index;
},
// 更新键盘高度
updateKeyboardHeight(height) {
this.keyboardHeight = height;
if (height > 0) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
// 【新增:会话 ID 本地缓存方法】
saveConversationIdToLocal(convId) {
if (convId) {
uni.setStorageSync(CONVERSATION_KEY, convId);
}
},
getConversationIdFromLocal() {
return uni.getStorageSync(CONVERSATION_KEY) || null;
},
clearConversationIdFromLocal() {
uni.removeStorageSync(CONVERSATION_KEY);
},
// 初始化音频上下文
initAudioContext() {
// #ifdef H5
if (window.AudioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
// #endif
},
onScroll(e) {
const currentScrollTop = e.detail.scrollTop;
this.lastScrollTop = currentScrollTop;
// 防抖处理:50ms内只执行一次,避免频繁触发
if (this.scrollTimer) clearTimeout(this.scrollTimer);
this.scrollTimer = setTimeout(() => {
// 滚动到顶部附近(<20px)、不在加载中、还有更多历史时,触发加载
if (currentScrollTop < 20 && !this.isLoadingHistory && this.hasMoreHistory) {
this.loadMoreHistory();
}
}, 50);
},
// 初始化用户头像(支持缓存持久化)
initUserAvatarData() {
// 1. 优先读取本地缓存的头像
const cachedAvatar = uni.getStorageSync('user_avatar')
if (cachedAvatar) {
this.localUserAvatar = cachedAvatar
}
// 2. 无缓存时,使用外部传入的初始头像
else if (this.initUserAvatar) {
this.localUserAvatar = this.initUserAvatar
// 缓存初始头像
uni.setStorageSync('user_avatar', this.initUserAvatar)
}
// 3. 最后使用默认头像
else {
this.localUserAvatar = this.defaultAvatar
}
},
// 新增:加载历史记录
async loadChatHistoryIfExist() {
const convId = aiService.getConversationId();
console.log('[AI Chat] loadChatHistoryIfExist 被调用, convId:', convId);
if (convId) {
try {
// 调用正确的方法,并传入会话ID
console.log('[AI Chat] 请求历史 API...');
const response = await aiService.getConversationHistory({
conversation_id: convId
});
console.log('[AI Chat] 历史 API 返回:', JSON.stringify(response).substring(0, 200));
if (!response.success || !Array.isArray(response.messages)) {
console.warn('[AI Chat] 历史记录为空或格式无效, success:', response.success, 'messages:', Array.isArray(response.messages));
return;
}
console.log('[AI Chat] 原始消息数:', response.messages.length, 'total:', response.total);
// 需要根据实际返回的 response.data.messages 来映射
this.messages = response.messages.map(msg => {
let content = '';
if (msg.role === 'user') {
content = msg.query || msg.content || (typeof msg.inputs === 'string' ? msg.inputs : JSON.stringify(msg.inputs || '')) || '';
} else {
content = msg.answer || msg.content || '';
}
// 只使用后端返回的真实时间,不使用假时间
const realTimestamp = msg.created_at || msg.create_time || msg.time;
return {
id: msg.message_id || (Date.now() + Math.random() * 10000),
role: msg.role === 'user' ? 'user' : 'ai',
type: 'text',
content: content,
timestamp: realTimestamp,
actions: []
};
}).filter(msg => msg.content.trim() !== ''); // 过滤掉内容为空的消息
console.log('[AI Chat] 过滤后消息数:', this.messages.length, '内容摘要:', this.messages.map(m => ({role: m.role, content: m.content.substring(0, 30)})));
// 根据服务端 total 判断是否还有更多历史可加载
const msgCount = this.messages.length;
this.hasMoreHistory = response.total > msgCount;
this.historyOffset = msgCount;
} catch (error) {
console.error('[AI Chat] 加载历史记录失败:', error.message, error.stack);
// 只需调用服务层的清除方法,它会处理本地存储
aiService.clearConversationId();
this.currentConversationId = null;
}
}
},
// 新增:初始化用户昵称(支持缓存持久化)
initUserNicknameData() {
// 1. 优先读取本地缓存的昵称
const cachedNickname = uni.getStorageSync('user_nickname')
if (cachedNickname) {
this.localUserNickname = cachedNickname
}
// 2. 无缓存时,使用外部传入的初始昵称
else if (this.initUserNickname) {
this.localUserNickname = this.initUserNickname
// 缓存初始昵称
uni.setStorageSync('user_nickname', this.initUserNickname)
}
// 3. 最后使用默认昵称
else {
this.localUserNickname = this.defaultNickname
}
},
// 发送消息
async sendMessage() {
// 确保有内容或表情
const hasContent = this.inputText && this.inputText.trim() !== ''
const hasEmoji = this.containsEmoji(this.inputText)
if (!hasContent && !hasEmoji) return
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: this.inputText,
timestamp: Date.now()
}
this.shouldScrollToBottom = true
this.messages.push(userMessage)
this.inputText = ''
// 显示AI正在处理 - 带状态轮换提示
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now(),
loadingText: this.$lang('ai-chat.index.aiThinking'),
_loadingIndex: 0
}
this.shouldScrollToBottom = true
this.messages.push(loadingMessage)
this.scrollToBottom()
// 启动处理状态文字轮换
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 先渲染 loading 动画,再发起请求
await this.$nextTick()
try {
// 直接发送消息(跳过健康检查,避免健康检查接口格式不匹配导致误判)
// 如果服务不可用,实际发送时会抛出异常,由 catch 块处理
const enhancedMessage = this.enhanceEmojiMessage(userMessage.content)
if (this.enableStreaming) {
await this.sendStreamMessage(enhancedMessage)
} else {
await this.sendNormalMessage(enhancedMessage)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.shouldScrollToBottom = true
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 发送普通消息
async sendNormalMessage(userMessage) {
const response = await aiService.sendMessage(userMessage, {
conversationId: this.currentConversationId,
stream: false
})
// 移除加载状态
this._removeLoadingMessages()
// 更新会话ID
if (response.conversationId) {
this.currentConversationId = response.conversationId;
aiService.setConversationId(response.conversationId);
this.saveConversationIdToLocal(response.conversationId);
}
const aiMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: response.content,
timestamp: Date.now(),
conversationId: response.conversationId,
messageId: response.messageId,
// 耗时(秒): 用户发消息到 AI 回复完成的耗时
elapsed: this._aiRequestStart > 0 ? (Date.now() - this._aiRequestStart) / 1000 : 0,
_finished: true,
actions: []
}
this.shouldScrollToBottom = true
this.messages.push(aiMessage)
this.$emit('ai-response', aiMessage)
},
// 发送流式消息(自动适配 H5 / 微信小程序)
async sendStreamMessage(userMessage) {
// 找到已有的 loading 消息,复用它在 DOM 中的位置,实现无缝过渡
const loadingIdx = this.messages.findIndex(msg => msg.type === 'loading')
if (loadingIdx === -1) {
// 安全回退:没有 loading 消息时创建新消息
const fallback = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: '',
timestamp: Date.now(),
isStreaming: true
}
this.isStreaming = true
this.messages.push(fallback)
this.scrollToBottom()
try {
// #ifdef H5
await aiService.sendHttpStream(userMessage,
(chunk) => { fallback.content += chunk; this.scrollToBottom() },
(result) => this._finishStream(fallback, result))
// #endif
// #ifdef MP-WEIXIN
await aiService.sendStreamMessage(userMessage,
(chunk) => { fallback.content += chunk; this.scrollToBottom() },
(result) => this._finishStream(fallback, result))
// #endif
} catch (error) {
this._handleStreamError(fallback)
}
return
}
this.isStreaming = true
const loadingMsg = this.messages[loadingIdx]
this._stopLoadingTextRotation(loadingMsg)
loadingMsg.loadingText = this.$lang('ai-chat.index.receivingReply')
let transformed = false
const transformOnFirstChunk = () => {
if (transformed) return this.messages[loadingIdx]
transformed = true
const msg = this.messages[loadingIdx]
msg.type = 'text'
msg.content = ''
this.$set(msg, 'isStreaming', true)
this.$set(msg, 'actions', [])
delete msg.loadingText
delete msg._loadingIndex
return msg
}
try {
// #ifdef H5
await aiService.sendHttpStream(
userMessage,
(chunk) => {
const msg = transformOnFirstChunk()
msg.content += chunk
this.scrollToBottom()
},
(completeResult) => {
const msg = this.messages[loadingIdx]
if (!this._finishStream(msg, completeResult)) return
}
)
// #endif
// #ifdef MP-WEIXIN
await aiService.sendStreamMessage(
userMessage,
(chunk) => {
const msg = transformOnFirstChunk()
msg.content += chunk
this.scrollToBottom()
},
(completeResult) => {
const msg = this.messages[loadingIdx]
if (!this._finishStream(msg, completeResult)) return
}
)
// #endif
} catch (error) {
console.error('流式请求失败:', error)
this.isStreaming = false
if (!transformed) {
// 在第一个 chunk 到达前出错 → 移除 loading 消息
this._removeLoadingMessages()
} else {
// 流传输中出错 → 移除空的流式消息
const msg = this.messages[loadingIdx]
if (msg && (!msg.content || msg.content.trim() === '')) {
this.messages.splice(loadingIdx, 1)
}
}
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
}
},
// 完成流式消息处理(共用逻辑)
_finishStream(msg, result) {
msg.isStreaming = false
this.isStreaming = false
if (!msg.content || msg.content.trim() === '') {
this._removeLoadingMessages()
return false
}
if (result) {
const convId = typeof result === 'string' ? '' : (result.conversation_id || '')
if (convId) {
this.currentConversationId = convId
aiService.setConversationId(convId)
this.saveConversationIdToLocal(convId)
}
}
msg.actions = []
// 计算并写入耗时 (秒), 用于状态行 "已完成 XmXXs"
if (this._aiRequestStart > 0) {
msg.elapsed = (Date.now() - this._aiRequestStart) / 1000
msg._finished = true
}
this.$nextTick(() => this.scrollToBottom())
this.$emit('ai-response', msg)
return true
},
// 流式请求出错处理
_handleStreamError(msg) {
console.error('流式请求失败')
this.isStreaming = false
this.messages = this.messages.filter(m => m.id !== msg.id)
const errorMsg = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceTemporaryUnavailable'),
timestamp: Date.now(),
actions: [{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }]
}
this.messages.push(errorMsg)
this.$emit('ai-response', errorMsg)
},
// 加载状态文字轮换 - 让AI看起来在处理不同阶段
_startLoadingTextRotation(loadingMsg) {
const statusTexts = [
this.$lang('ai-chat.index.aiThinkingStatus1'),
this.$lang('ai-chat.index.aiThinkingStatus2'),
this.$lang('ai-chat.index.aiThinkingStatus3'),
this.$lang('ai-chat.index.aiThinkingStatus4')
]
loadingMsg._rotationTimer = setInterval(() => {
loadingMsg._loadingIndex = (loadingMsg._loadingIndex + 1) % statusTexts.length
loadingMsg.loadingText = statusTexts[loadingMsg._loadingIndex]
}, 2000)
},
// 停止加载状态文字轮换
_stopLoadingTextRotation(loadingMsg) {
if (loadingMsg && loadingMsg._rotationTimer) {
clearInterval(loadingMsg._rotationTimer)
loadingMsg._rotationTimer = null
}
},
// 移除加载消息并清理定时器
_removeLoadingMessages() {
const loadingMsgs = this.messages.filter(msg => msg.type === 'loading');
loadingMsgs.forEach(msg => this._stopLoadingTextRotation(msg));
this.messages = this.messages.filter(msg => msg.type !== 'loading');
},
generateAIResponse(userMessage) {
const responses = {
'你好': this.$lang('ai-chat.index.autoReplyGreeting'),
'价格': this.$lang('ai-chat.index.autoReplyPrice'),
'发货': this.$lang('ai-chat.index.autoReplyShipping'),
'退货': this.$lang('ai-chat.index.autoReplyReturn'),
'客服': this.$lang('ai-chat.index.autoReplyService')
};
for (let key in responses) {
if (userMessage.includes(key)) {
return responses[key];
}
}
return this.$lang('ai-chat.index.autoReplyDefault', { question: userMessage });
},
// 增强版Markdown解析(无第三方依赖)
parseMarkdown(content) {
if (!content) return ''
// 兜底: 后端流式响应有时会把每个字用 \n 拼接 ("你\n好\n!"), 导致最终渲染一字一行.
// 检测"每行 1 个字符"的比例, 若超过 70% 则认为是退化格式, 先合并成连续字符串再走 markdown.
try {
const rawLines = String(content).split('\n')
if (rawLines.length >= 5) {
const singleCharLines = rawLines.filter(l => l.trim().length === 1).length
if (singleCharLines / rawLines.length > 0.7) {
content = rawLines.join('').replace(/\s+/g, '')
}
}
} catch (e) { /* 忽略, 走原始 content */ }
// 辅助:内联格式处理
const parseInline = (text) => {
return text
// 行内图片
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;border-radius:8rpx;margin:8rpx 0;display:block;" />')
// 链接
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:#4a90d9;text-decoration:none;">$1</a>')
// 行内代码
.replace(/`([^`]+)`/g, '<code style="background:#f0f4f8;padding:2rpx 10rpx;border-radius:6rpx;font-size:24rpx;font-family:monospace;color:#d63384;">$1</code>')
// 粗体(优先匹配**)
.replace(/\*\*(.+?)\*\*/g, '<strong style="font-weight:700;">$1</strong>')
// 斜体
.replace(/\*(.+?)\*/g, '<em style="font-style:italic;">$1</em>')
// 删除线
.replace(/~~(.+?)~~/g, '<del style="text-decoration:line-through;color:#999;">$1</del>')
// 手机号
.replace(/\b(1[3-9]\d{9})\b/g, '<a href="tel:$1" style="color:#4a90d9;text-decoration:none;">$1</a>')
// 400电话
.replace(/\b(400[\d-]{7,10})\b/g, '<a href="tel:$1" style="color:#4a90d9;text-decoration:none;">$1</a>')
}
// 1. 转义HTML特殊字符
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 2. 提取代码块(保护其内容不被后续处理污染)
const codeBlocks = []
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => {
const idx = codeBlocks.length
const safe = code.replace(/</g, '&lt;').replace(/>/g, '&gt;')
codeBlocks.push(
`<pre style="background:#f5f5f5;padding:24rpx;border-radius:8rpx;overflow-x:auto;font-size:24rpx;line-height:1.6;margin:16rpx 0;white-space:pre-wrap;">` +
`<code style="background:transparent;padding:0;font-family:monospace;color:#333;font-size:24rpx;">${safe}</code></pre>`
)
return `%%CODEBLOCK_${idx}%%`
})
// 3. 按行处理,用 <br> 连接单换行,双换行分割段落块
const lines = html.split('\n')
const out = []
let inList = false
let buf = [] // 普通文本行缓冲区
let inTable = false
let tableRows = [] // 表格行缓冲区
const closeList = () => {
if (inList === 'ul') { inList = false; return '</ul>' }
if (inList === 'ol') { inList = false; return '</ol>' }
return ''
}
const flushBuf = () => {
if (buf.length === 0) return
// 去掉首尾的空串,避免产生尾随 <br>
while (buf.length > 0 && buf[0] === '') buf.shift()
while (buf.length > 0 && buf[buf.length - 1] === '') buf.pop()
if (buf.length === 0) return
const content = buf.map(l => parseInline(l)).join('<br>')
out.push(`<div style="line-height:1.8;font-size:26rpx;">${content}</div>`)
buf = []
}
const flushTable = () => {
if (!inTable || tableRows.length === 0) return
const thead = tableRows[0].map(c =>
`<th style="padding:8rpx 12rpx;border:1rpx solid #ddd;font-weight:600;background:#f5f5f5;text-align:left;font-size:24rpx;">${parseInline(c)}</th>`
).join('')
const tbody = tableRows.slice(1).map(row =>
'<tr>' + row.map(c =>
`<td style="padding:8rpx 12rpx;border:1rpx solid #ddd;line-height:1.6;font-size:24rpx;">${parseInline(c)}</td>`
).join('') + '</tr>'
).join('')
out.push(
`<table style="border-collapse:collapse;margin:8rpx 0;width:100%;font-size:24rpx;">` +
`<thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`
)
tableRows = []
inTable = false
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
// 代码块占位符
if (/^%%CODEBLOCK_\d+%%$/.test(trimmed)) {
flushBuf(); flushTable(); out.push(closeList())
out.push(line)
continue
}
// 表格行(以 | 开头)
if (trimmed.charAt(0) === '|') {
flushBuf(); out.push(closeList())
// 去除首尾空 cell
const rawCells = trimmed.split('|').filter((c, j, a) => j > 0 && j < a.length - 1)
if (rawCells.length === 0) continue
// 跳过分隔行 | --- | :--- | :---: | ---: |
const isSep = rawCells.every(c => /^[\s:\-|]+$/.test(c.trim()))
if (isSep) continue
if (!inTable) { inTable = true; tableRows = [] }
tableRows.push(rawCells.map(c => c.trim()))
continue
}
// 非表格行 → 刷新已积累的表格
if (inTable) flushTable()
// 标题
const hMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (hMatch) {
flushBuf(); out.push(closeList())
const lv = hMatch[1].length
const size = ['', '40rpx', '36rpx', '32rpx', '28rpx', '26rpx', '24rpx'][lv]
const wgt = ['', '700', '700', '600', '600', '500', '500'][lv]
const mg = ['', '20rpx 0 10rpx', '16rpx 0 8rpx', '14rpx 0 6rpx', '12rpx 0 6rpx', '10rpx 0 4rpx', '8rpx 0 4rpx'][lv]
out.push(`<h${lv} style="font-size:${size};font-weight:${wgt};margin:${mg};line-height:1.5;">${parseInline(hMatch[2])}</h${lv}>`)
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
flushBuf(); out.push(closeList())
out.push('<hr style="border:none;border-top:2rpx solid #e8e8e8;margin:20rpx 0;">')
continue
}
// 引用
const bqMatch = line.match(/^>\s?(.+)$/)
if (bqMatch) {
flushBuf(); out.push(closeList())
out.push(`<blockquote style="border-left:4rpx solid #4a90d9;padding:10rpx 20rpx;margin:8rpx 0;background:#f8f9fa;color:#666;line-height:1.8;border-radius:0 6rpx 6rpx 0;">${parseInline(bqMatch[1])}</blockquote>`)
continue
}
// 无序列表
const ulMatch = line.match(/^[-*+]\s+(.+)$/)
if (ulMatch) {
flushBuf()
if (!inList) { out.push('<ul style="padding-left:36rpx;margin:6rpx 0;">'); inList = 'ul' }
out.push(`<li style="margin:3rpx 0;line-height:1.8;">${parseInline(ulMatch[1])}</li>`)
continue
}
// 有序列表
const olMatch = line.match(/^\d+\.\s+(.+)$/)
if (olMatch) {
flushBuf()
if (!inList) { out.push('<ol style="padding-left:36rpx;margin:6rpx 0;">'); inList = 'ol' }
out.push(`<li style="margin:3rpx 0;line-height:1.8;">${parseInline(olMatch[1])}</li>`)
continue
}
// 空行 → 加入缓冲区作段落分隔(<br><br>),同时关闭列表
if (trimmed === '') {
// 关闭列表(标准Markdown行为)
if (inList) {
flushBuf()
out.push(closeList())
}
// 避免连续空行推入多个空串
if (buf.length > 0 && buf[buf.length - 1] !== '') {
buf.push('')
}
continue
}
// 普通文本行 => 加入缓冲区
buf.push(line)
}
// 扫尾
flushBuf(); flushTable(); out.push(closeList())
html = out.join('')
// 4. 还原代码块
html = html.replace(/%%CODEBLOCK_(\d+)%%/g, (m, idx) => codeBlocks[parseInt(idx)])
return html
},
// 格式化时间(人性化显示)
formatTime(timestamp) {
if (!timestamp) return '';
let date;
if (typeof timestamp === 'string') {
const parts = timestamp.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/);
if (parts) {
const [, year, month, day, hour, minute, second] = parts;
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second));
date.setHours(date.getHours() + 8);
} else {
date = new Date(timestamp);
}
} else {
let timeValue = timestamp;
if (typeof timeValue === 'number' && timeValue < 10000000000) {
timeValue = timeValue * 1000;
}
date = new Date(timeValue);
}
if (isNaN(date.getTime())) return '';
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const beforeYesterday = new Date(today.getTime() - 2 * 24 * 60 * 60 * 1000);
const msgDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const timeStr = `${hours}:${minutes}`;
if (msgDate.getTime() === today.getTime()) {
return timeStr;
}
if (msgDate.getTime() === yesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatYesterday')} ${timeStr}`;
}
if (msgDate.getTime() === beforeYesterday.getTime()) {
return `${this.$lang('ai-chat.index.timeFormatBeforeYesterday')} ${timeStr}`;
}
const isCurrentYear = date.getFullYear() === now.getFullYear();
const monthStr = (date.getMonth() + 1).toString();
const dayStr = date.getDate().toString();
if (isCurrentYear) {
return `${monthStr}/${dayStr} ${timeStr}`;
}
const yearStr = date.getFullYear().toString();
return `${yearStr}/${monthStr}/${dayStr} ${timeStr}`;
},
// 格式化文件大小
formatFileSize(bytes) {
if (!bytes) return this.$lang('ai-chat.index.about')
const k = 1024
const sizes = [this.$lang('ai-chat.index.about'), 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i]
},
// 格式化时长
formatDuration(seconds) {
if (!seconds) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return mins.toString().padStart(2, '0') + ':' + secs.toString().padStart(2, '0')
},
// 格式化 AI 处理耗时 (秒 -> "XmXXs" 风格, 0 时显示 "<1s")
formatElapsed(seconds) {
if (seconds === undefined || seconds === null || isNaN(seconds)) return '1s'
seconds = Math.max(0, Math.floor(seconds))
if (seconds < 1) return '1s'
const m = Math.floor(seconds / 60)
const s = seconds % 60
if (m === 0) return s + 's'
return m + 'm' + (s < 10 ? '0' + s : s) + 's'
},
// 滚动到底部 - 统一使用 scroll-into-view
scrollToBottom() {
if (this.messages.length === 0) return
// 取真实最后一条消息(包括空内容的流式消息)
const lastMsg = this.messages[this.messages.length - 1]
const lastIndex = this.messages.length - 1
const key = lastMsg.id || lastMsg.timestamp || lastIndex
// 先清空再设置,确保 scroll-into-view 重新触发
this.scrollIntoViewId = ''
this.$nextTick(() => {
this.scrollIntoViewId = 'msg-' + key + '-' + lastIndex
})
},
// 加载更多历史消息
async loadMoreHistory() {
if (!this.currentConversationId) {
this.currentConversationId = this.getConversationIdFromLocal();
aiService.setConversationId(this.currentConversationId);
}
// 防止重复加载
if (!this.hasMoreHistory || !this.currentConversationId || this.isLoadingHistory) {
return;
}
this.isFetchingHistory = true;
this.isLoadingHistory = true;
this.loadingText = this.$lang('ai-chat.index.loadingHistory');
try {
const response = await aiService.getConversationHistory({
conversation_id: this.currentConversationId,
limit: 20,
offset: this.historyOffset
});
if (response.success && Array.isArray(response.messages) && response.messages.length > 0) {
const historyMessages = response.messages.map(msg => {
const realTimestamp = msg.create_time || msg.created_at || msg.time;
return {
id: msg.id || `history-${Date.now()}-${Math.random().toString(36).slice(2)}`,
role: msg.role === 'user' ? 'user' : 'ai',
type: 'text',
content: msg.content,
timestamp: realTimestamp
};
});
const newMsgCount = historyMessages.length;
this.messages = [...historyMessages, ...this.messages];
this.historyOffset += newMsgCount;
// 根据服务端 total 判断是否还有更多
if (response.total !== undefined) {
this.hasMoreHistory = this.historyOffset < response.total;
}
await this.$nextTick();
const query = uni.createSelectorQuery().in(this);
const heightPromises = [];
for (let i = 0; i < newMsgCount; i++) {
heightPromises.push(new Promise(resolve => {
query.select(`.message-item:nth-child(${i + 1})`).boundingClientRect(rect => {
resolve(rect ? rect.height + 32 : 0);
}).exec();
}));
}
const heights = await Promise.all(heightPromises);
const totalNewHeight = heights.reduce((sum, h) => sum + h, 0);
this.scrollTop = totalNewHeight;
} else {
// 无更多历史
this.hasMoreHistory = false;
}
} catch (error) {
console.error('加载历史失败:', error);
uni.showToast({ title: this.$lang('ai-chat.index.loadHistoryFail'), icon: 'none' });
this.hasMoreHistory = false;
} finally {
this.isLoadingHistory = false;
this.isFetchingHistory = false;
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory');
}
},
// 显示更多工具
showMoreTools() {
this.showToolsPanel = true
},
// 预览文件
previewFile(message) {
uni.showLoading({ title: this.$lang('ai-chat.index.openingFile') })
// #ifdef H5
window.open(message.url, '_blank')
// #endif
// #ifdef APP-PLUS
plus.runtime.openFile(message.url, {
error: (e) => {
uni.showToast({
title: this.$lang('ai-chat.index.fileOpenFail'),
icon: 'none'
})
}
})
// #endif
uni.hideLoading()
this.$emit('file-preview', message)
},
// 切换音频播放
toggleAudio(message) {
if (message.playing) {
this.pauseAudio(message)
} else {
this.playAudio(message)
}
},
// 播放音频
playAudio(message) {
if (this.currentAudio && this.currentAudio !== message) {
this.pauseAudio(this.currentAudio)
}
// 模拟音频播放
message.playing = true
message.currentTime = message.currentTime || 0
this.currentAudio = message
this.$emit('audio-play', message)
},
// 暂停音频
pauseAudio(message) {
message.playing = false
this.$emit('audio-pause', message)
},
// 音频滑块变化
onAudioSliderChange(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
}
},
onAudioSliderChangeEnd(e) {
if (this.currentAudio) {
this.currentAudio.currentTime = e.detail.value
this.$emit('audio-seek', this.currentAudio)
}
},
// 视频加载完成事件
onVideoLoaded(message, event) {
this.$nextTick(() => {
let width = 0;
let height = 0;
// H5端:通过video元素获取真实视频宽高
// #ifdef H5
const video = this.$refs.videoRef;
if (video) {
width = video.videoWidth || 0;
height = video.videoHeight || 0;
}
// #endif
// 微信小程序端:通过事件对象获取视频宽高
// #ifdef MP-WEIXIN
if (event && event.detail) {
width = event.detail.width || 0;
height = event.detail.height || 0;
}
// #endif
if (width && height) {
// 判断视频方向:宽度 >= 高度为横屏,否则为竖屏
const videoRatio = width >= height ? 'landscape' : 'portrait';
// 动态设置视频样式
// 这里可以添加逻辑来动态调整视频播放器的样式
// 例如:message.videoRatio = videoRatio;
// 然后在模板中根据message.videoRatio调整样式
}
});
},
// 视频播放
onVideoPlay(message) {
this.$emit('video-play', message)
},
// 视频暂停
onVideoPause(message) {
this.$emit('video-pause', message)
},
// 打开链接
openLink(message) {
// #ifdef H5
window.open(message.url, '_blank')
// #endif
// #ifdef APP-PLUS
plus.runtime.openURL(message.url)
// #endif
this.$emit('link-open', message)
},
// 处理操作
handleAction(action, message) {
// 处理重试操作
if (action.type === 'retry') {
this.retryMessage(message)
return
}
this.$emit('action-click', { action, message })
},
// ====== AI 消息下方操作栏 (复制 / 点赞 / 不喜欢 / 朗读 / 更多) ======
// 复制 AI 回复
onAiActionCopy(message) {
this.copyMessage(message)
},
// 点赞 (toggle, 与不喜欢互斥)
onAiActionLike(message) {
try {
this.$set(message, '_liked', !message._liked)
if (message._liked) this.$set(message, '_disliked', false)
uni.showToast({ title: message._liked ? '已点赞' : '已取消点赞', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 不喜欢 (toggle, 与点赞互斥)
onAiActionDislike(message) {
try {
this.$set(message, '_disliked', !message._disliked)
if (message._disliked) this.$set(message, '_liked', false)
uni.showToast({ title: message._disliked ? '已反馈' : '已取消反馈', icon: 'none', duration: 1200 })
} catch (e) { /* noop */ }
},
// 朗读 / 停止朗读 (用 uni.createInnerAudioContext 简单播放, 占位实现)
onAiActionSpeak(message) {
try {
const text = this._stripMarkdown(message && (message.content || ''))
if (!text) {
uni.showToast({ title: '暂无可朗读内容', icon: 'none' })
return
}
if (this._ttsPlaying && this._ttsCtx) {
// 暂停/停止
try { this._ttsCtx.stop() } catch (e) {}
this._ttsPlaying = false
if (this._ttsMsg === message) this.$set(message, '_playing', false)
return
}
// #ifdef MP-WEIXIN
// 微信小程序优先用 wx.createInnerAudioContext + 走 TTS, 这里给个最小占位
try {
this._ttsCtx = uni.createInnerAudioContext()
this._ttsCtx.onError(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
uni.showToast({ title: '朗读失败', icon: 'none' })
})
// 真实项目应接入 TTS 服务 (微信同声传译插件 / 第三方 API)
// 此处仅占位, 让 _playing 状态可切换
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
} catch (e) { /* noop */ }
// #endif
// 非微信环境: 仅切换状态, 不实际朗读
// #ifndef MP-WEIXIN
this._ttsPlaying = true
this._ttsMsg = message
this.$set(message, '_playing', true)
setTimeout(() => {
this._ttsPlaying = false
this.$set(message, '_playing', false)
}, 1500)
// #endif
} catch (e) { /* noop */ }
},
// 更多菜单: 只保留"复制" (uni.showActionSheet 自带底部"取消"按钮, 无需额外添加)
onAiActionMore(message) {
try {
uni.showActionSheet({
itemList: ['复制'],
success: (res) => {
if (res.tapIndex === 0) {
this.copyMessage(message)
}
},
fail: () => { /* 用户取消 */ }
})
} catch (e) { /* noop */ }
},
// 去掉 markdown 标记, 用于 TTS 朗读
_stripMarkdown(text) {
if (!text) return ''
return String(text)
.replace(/```[\s\S]*?```/g, '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/~~(.+?)~~/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/<[^>]+>/g, '')
.replace(/[ \t]+/g, ' ')
.trim()
},
// 长按消息→直接复制
onMessageLongPress(message) {
// loading 消息不允许操作
if (message.type === 'loading') return
// #ifdef MP-WEIXIN
// 微信小程序:震动反馈提升手感(小米等 Android 设备也可用)
try {
wx.vibrateShort({ type: 'medium' })
} catch (e) { /* 不支持震动的设备忽略 */ }
// #endif
this.copyMessage(message)
},
// 手动长按检测(touch 事件方案,兼容所有平台)
// 在 H5 desktop 上用 @contextmenu 触发,
// 在 H5 mobile / 小米微信小程序等场景靠 touch 计时器兜底
onBubbleTouchStart(event, message) {
// 单指长按才触发,忽略多点触控
if (event.touches && event.touches.length > 1) return
this._longPressMessage = message
this._longPressTimer = setTimeout(() => {
this._longPressTimer = null
this.onMessageLongPress(message)
}, 500) // 和系统长按接近的 500ms
},
onBubbleTouchMove() {
// 手指移动(滚动列表)→ 取消长按
if (this._longPressTimer) {
clearTimeout(this._longPressTimer)
this._longPressTimer = null
}
this._longPressMessage = null
},
onBubbleTouchEnd() {
// 手指提前松开 → 取消长按
if (this._longPressTimer) {
clearTimeout(this._longPressTimer)
this._longPressTimer = null
}
this._longPressMessage = null
},
// 复制消息内容到剪贴板(兼容 H5 / 微信小程序)
copyMessage(message) {
let copyText = message.content || ''
if (!copyText && message.type === 'text') {
copyText = message.content || ''
}
if (!copyText) {
uni.showToast({ title: this.$lang('ai-chat.index.noContentToCopy'), icon: 'none' })
return
}
// #ifdef H5
// H5:检测是否有局部文本被选中,有则复制选区文本,无则复制全文
const selection = window.getSelection()
const selectedText = selection && selection.toString().trim()
const finalText = selectedText || copyText
// H5 优先使用 Clipboard API,降级使用 document.execCommand
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(finalText).then(() => {
uni.showToast({ title: selectedText ? this.$lang('ai-chat.index.copySelected') : this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
}).catch(() => {
this._fallbackCopyH5(finalText)
})
} else {
this._fallbackCopyH5(finalText)
}
} catch (e) {
this._fallbackCopyH5(finalText)
}
// #endif
// #ifdef MP-WEIXIN
// 微信小程序专用(含小米等 Android 兼容)
console.log('[AI Chat] 开始复制, 内容长度:', copyText.length, '内容前50字:', copyText.substring(0, 50))
uni.setClipboardData({
data: copyText,
success: () => {
console.log('[AI Chat] 复制成功')
uni.showToast({ title: this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
},
fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
console.error('[AI Chat] 复制失败 errMsg:', err.errMsg || err.message || '无错误消息')
uni.showToast({ title: this.$lang('ai-chat.index.copyFailWithReason', { reason: err.errMsg || this.$lang('ai-chat.index.serviceUnavailable') }), icon: 'none', duration: 3000 })
}
})
// #endif
},
// H5 降级复制方案(document.execCommand + textarea)
_fallbackCopyH5(text) {
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
textarea.style.top = '-9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const success = document.execCommand('copy')
document.body.removeChild(textarea)
if (success) {
uni.showToast({ title: this.$lang('ai-chat.index.copySuccess'), icon: 'success' })
} else {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
} catch (e) {
uni.showToast({ title: this.$lang('ai-chat.index.copyFail'), icon: 'none' })
}
},
// ========================================
// 聊天记录搜索
// ========================================
// 用户消息关键词高亮(仅当消息内容包含关键词时)
highlightContent(content) {
if (!this.searchKeyword || !content) {
return content
}
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return content
}
return this._wrapHighlight(content, this.searchKeyword)
},
// AI 消息渲染(含关键词高亮)
getAIDisplayContent(content) {
if (!this.searchKeyword || !content) {
return this.parseMarkdown(content)
}
// 仅当内容包含关键词时才执行高亮,避免非匹配消息的额外处理
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return this.parseMarkdown(content)
}
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
return this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
},
// 在纯文本中高亮关键词(用于用户消息)
_wrapHighlight(text, keyword) {
if (!keyword || !text) return text
const parts = text.split(new RegExp('(' + this._escapeRegExp(keyword) + ')', 'gi'))
return parts.map(part => {
if (part.toLowerCase() === keyword.toLowerCase()) {
return `<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">${part}</span>`
}
return part
}).join('')
},
// 在 HTML 中高亮关键词(用于 AI 消息的 markdown 结果)
_wrapHighlightInHtml(html, keyword) {
if (!keyword || !html) return html
// 只高亮文本节点,不破坏 HTML 标签
const escaped = this._escapeRegExp(keyword)
// 匹配 <xxx> 之外的文本
return html.replace(/(>|^)([^<]+)(?=<|$)/g, (match, prefix, text) => {
if (!text) return match
const highlighted = text.replace(
new RegExp('(' + escaped + ')', 'gi'),
'<span style="background:#ffe066;color:#333;padding:0 2rpx;border-radius:4rpx;">$1</span>'
)
return prefix + highlighted
})
},
// 转义正则特殊字符
_escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
},
// 重试消息
async retryMessage(message) {
// 找到对应的用户消息
const userMessageIndex = this.messages.findIndex(msg =>
msg.role === 'user' && msg.id < message.id
)
if (userMessageIndex !== -1) {
const userMessage = this.messages[userMessageIndex]
// 移除错误消息
this.messages = this.messages.filter(msg => msg.id !== message.id)
// 重新发送消息
await this.sendMessageWithContent(userMessage.content)
}
},
// 使用指定内容发送消息
async sendMessageWithContent(content) {
// 记录用户发出消息的时间戳, AI 回复完成时算 elapsed
this._aiRequestStart = Date.now()
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: content,
timestamp: Date.now()
}
this.messages.push(userMessage)
// 显示AI正在输入
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now(),
loadingText: this.$lang('ai-chat.index.aiThinking'),
_loadingIndex: 0
}
this.messages.push(loadingMessage)
this._startLoadingTextRotation(loadingMessage)
// 等待 Vue 渲染 loading 动画
await this.$nextTick()
try {
// 使用AI服务获取回复
if (this.enableStreaming) {
// 流式响应
await this.sendStreamMessage(userMessage.content)
} else {
// 普通响应
await this.sendNormalMessage(userMessage.content)
}
} catch (error) {
console.error('AI服务调用失败:', error)
// 移除加载状态
this._removeLoadingMessages()
// 显示错误消息
const errorMessage = {
id: ++this.messageId,
role: 'ai',
type: 'text',
content: this.$lang('ai-chat.index.serviceUnavailable'),
timestamp: Date.now(),
actions: [
{ id: 1, text: this.$lang('ai-chat.index.retry'), type: 'retry' }
]
}
this.messages.push(errorMessage)
this.$emit('ai-response', errorMessage)
}
// 触发消息发送事件
this.$emit('message-sent', userMessage)
},
// 输入事件
onInput(e) {
this.$emit('input-change', e.detail.value)
},
// 输入框行数变化事件
onLineChange(e) {
this.inputLineCount = e.detail.lineCount || 0
},
// 切换Emoji面板
toggleEmojiPanel() {
this.showEmojiPanel = !this.showEmojiPanel
this.showToolsPanel = false
},
// 快捷指令占位提示点击: 在输入框插入 @ 或 / 触发后续指令面板 (此处仅展示交互, 后续按需扩展)
onQuickTipClick(type) {
try {
if (type === 'quote') {
this.inputText = (this.inputText || '') + '@'
} else if (type === 'skill') {
this.inputText = (this.inputText || '') + '/'
}
} catch (e) { /* noop */ }
},
// 底部 5 图标工具栏点击: 任务 / 专家 / 资料库 / 自动化 / 更多
// - task / expert / library / automation: 触发 toolbar-click 事件(占位)
// - more: 复用 showMoreTools 触发更多功能弹窗
onToolbarItem(type) {
try {
switch (type) {
case 'task':
case 'expert':
case 'library':
case 'automation':
this.$emit('toolbar-click', { type })
break
case 'more':
this.showMoreTools()
break
}
} catch (e) { /* noop */ }
},
// 默认权限下拉点击: 占位交互, 后续接入权限弹窗
onTogglePermission() {
try {
uni.showToast({ title: '权限设置待接入', icon: 'none' })
} catch (e) { /* noop */ }
},
// 语音按钮点击: 复用 showVoicePanel 流程 (若需要触发工具面板里的语音入口)
onVoiceClick() {
try {
this.showVoicePanel && this.showVoicePanel()
} catch (e) {
try { this.showToolsPanel = true } catch (e2) { /* noop */ }
}
},
// 切换模型选择器: 在 availableModels 列表中循环选择
onToggleModelSelector() {
const list = this.availableModels || ['Auto']
const idx = list.indexOf(this.selectedModel)
const nextIdx = (idx + 1) % list.length
this.selectedModel = list[nextIdx]
},
// 打开扩大编辑器
openExpandEditor() {
this.expandInputText = this.inputText
this.showExpandEditor = true
this.showEmojiPanel = false
this.showToolsPanel = false
},
// 关闭扩大编辑器
closeExpandEditor() {
this.showExpandEditor = false
this.expandInputText = ''
},
// 从扩大编辑器发送消息
async sendFromExpandEditor() {
if (!this.expandInputText.trim()) {
return
}
this.inputText = this.expandInputText
this.closeExpandEditor()
await this.sendMessage()
},
// 处理头像加载失败
handleAvatarError() {
this.avatarError = true
},
// 清空聊天记录
clearMessages() {
// 清理所有加载状态定时器
this.messages.forEach(msg => {
if (msg._rotationTimer) {
clearInterval(msg._rotationTimer)
msg._rotationTimer = null
}
})
// 清空消息数组
this.messages = []
// 重置消息 ID
this.messageId = 0
// 清除会话 ID
this.currentConversationId = null
aiService.clearConversationId()
this.clearConversationIdFromLocal()
// 重置历史偏移量和加载状态
this.historyOffset = 0
this.hasMoreHistory = false
// 触发清空事件
this.$emit('messages-cleared')
},
// 检测字符串中是否包含表情
containsEmoji(str) {
const emojiReg = /[\u{1f300}-\u{1f9ff}\u{1f1e0}-\u{1f1ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{200d}\u{2640}-\u{2642}\u{2300}-\u{23ff}\u{2b50}-\u{2bff}]/gu
return emojiReg.test(str)
},
// 根据表情生成适当的提示文本,帮助 AI 更好地理解
enhanceEmojiMessage(content) {
// 常见表情的含义映射
const emojiResponses = {
'😀': '[开心]',
'😂': '[大笑]',
'😊': '[微笑]',
'❤️': '[爱心]',
'👍': '[点赞]',
'😭': '[哭泣]',
'🙏': '[感谢/拜托]',
'😍': '[喜欢]',
'🎉': '[庆祝]',
'🤔': '[思考]',
'😅': '[尴尬]',
'😁': '[开心]',
'🥰': '[喜爱]',
'😘': '[飞吻]',
'😏': '[坏笑]',
'🥳': '[庆祝]',
'😔': '[难过]',
'😢': '[哭泣]',
'😤': '[生气]',
'😴': '[困]',
'🤗': '[拥抱]',
'🙂': '[微笑]',
'😃': '[开心]',
'😄': '[大笑]',
'😆': '[开心]',
'😇': '[天使]',
'🤩': '[惊喜]',
'😋': '[好吃]',
'😛': '[调皮]',
'😜': '[调皮]',
'🤪': '[搞怪]',
'😝': '[开心]',
'🤑': '[发财]',
'🤭': '[害羞]',
'🤫': '[嘘]',
'🤐': '[闭嘴]',
'🤨': '[疑惑]',
'😐': '[无语]',
'😑': '[无语]',
'😶': '[沉默]',
'😒': '[不屑]',
'🙄': '[白眼]',
'😬': '[尴尬]',
'🤥': '[说谎]',
'😌': '[安心]',
'😪': '[困]',
'🤤': '[流口水]',
'😷': '[生病]',
'🤒': '[发烧]',
'🤕': '[受伤]',
'🤢': '[恶心]',
'🤮': '[呕吐]',
'🤧': '[感冒]',
'🥵': '[热]',
'🥶': '[冷]',
'🥴': '[头晕]',
'😵': '[晕]',
'🤯': '[爆炸]',
'🤠': '[酷]',
'😎': '[酷]',
'🤓': '[书呆子]',
'🧐': '[疑惑]',
'😕': '[困惑]',
'😟': '[担心]',
'🙁': '[难过]',
'😮': '[惊讶]',
'😯': '[惊讶]',
'😲': '[震惊]',
'😳': '[脸红]',
'🥺': '[恳求]',
'😦': '[惊讶]',
'😧': '[惊讶]',
'😨': '[害怕]',
'😰': '[紧张]',
'😥': '[失望]',
'😱': '[恐惧]',
'😖': '[困扰]',
'😣': '[痛苦]',
'😞': '[难过]',
'😓': '[汗]',
'😩': '[累]',
'😫': '[累]',
'🥱': '[困]',
'😡': '[愤怒]',
'😠': '[生气]',
'🤬': '[咒骂]',
'😈': '[恶魔]',
'👿': '[生气]',
'💀': '[骷髅]',
'💩': '[便便]',
'👻': '[鬼]',
'👽': '[外星人]',
'🤖': '[机器人]',
'🎃': '[万圣节]',
'😺': '[开心猫]',
'😸': '[微笑猫]',
'😹': '[大笑猫]',
'😻': '[花痴猫]',
'😼': '[得意猫]',
'😽': '[亲亲猫]',
'🙀': '[惊讶猫]',
'😿': '[哭泣猫]',
'😾': '[生气猫]',
'👋': '[挥手]',
'🤚': '[举手]',
'🖐': '[手掌]',
'✋': '[手掌]',
'🖖': '[V]',
'👌': '[OK]',
'🤏': '[捏]',
'✌️': '[耶]',
'🤞': '[好运]',
'🤟': '[爱你]',
'🤘': '[摇滚]',
'🤙': '[打电话]',
'👈': '[左边]',
'👉': '[右边]',
'👆': '[上边]',
'🖕': '[竖中指]',
'👇': '[下边]',
'☝': '[向上]',
'👎': '[鄙视]',
'✊': '[拳头]',
'👊': '[拳头]',
'🤛': '[左拳]',
'🤜': '[右拳]',
'👏': '[鼓掌]',
'🙌': '[举手庆祝]',
'👐': '[张开手]',
'🤲': '[摊手]',
'🤝': '[握手]',
'✍️': '[写字]',
'💅': '[美甲]',
'🤳': '[自拍]',
'💪': '[肌肉]',
'💗': '[爱心]',
'💓': '[心跳]',
'💕': '[两颗心]',
'💖': '[闪亮心]',
'💝': '[礼物心]',
'💞': '[旋转心]',
'💘': '[丘比特]',
'💌': '[情书]',
'💟': '[心形]',
'💛': '[黄心]',
'💙': '[蓝心]',
'💜': '[紫心]',
'🖤': '[黑心]',
'💔': '[心碎]',
'💢': '[愤怒]',
'💣': '[炸弹]',
'💥': '[爆炸]',
'💫': '[头晕]',
'💦': '[汗]',
'💨': '[风]',
'👁️': '[眼睛]',
'👀': '[看]',
'👅': '[舌头]',
'👄': '[嘴巴]',
'👂': '[耳朵]',
'👃': '[鼻子]'
}
// 如果是纯表情消息,添加一些提示
if (this.containsEmoji(content) && !content.match(/[\u4e00-\u9fa5a-zA-Z]/)) {
let enhancedMessage = content
// 为表情添加描述
for (let emoji in emojiResponses) {
if (content.includes(emoji)) {
enhancedMessage += ` (${emojiResponses[emoji]})`
}
}
return enhancedMessage
}
return content
}
}
}
</script>
<style lang="scss" scoped>
/* 引入图标字体: 由 main.wxss 全局提供, 不再重复 import */
@import './styles/ai-chat-global.scss';
/* 页面样式 */
.ai-chat-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
/* WorkBuddy IDE 风格: 纯白底, 无气泡, 文字直接渲染其上 */
background: #ffffff;
color: #1f1f1f;
overflow: hidden;
box-sizing: border-box;
.chat-messages {
flex: 1;
padding: 16rpx 24rpx;
overflow-y: auto;
box-sizing: border-box;
min-height: 0; /* 重要:防止flex元素溢出 */
background: transparent;
/* 微信小程序专属:聊天消息区域增加底部内边距,确保键盘弹出时最后一条消息可见 */
/* 由 chatMessagesStyle 动态控制 */
.load-more {
text-align: center;
padding: 16rpx 0;
color: #999;
}
.message-item {
margin-bottom: 28rpx;
scroll-margin-top: 300rpx;
scroll-margin-bottom: 200rpx;
// 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (WorkBuddy 风格, 无气泡 box-shadow)
&.search-current {
position: relative;
z-index: 1;
border-radius: 12rpx;
background: rgba(7, 193, 96, 0.08);
.user-message .message-bubble,
.ai-message .message-bubble {
background: transparent !important;
transition: background 0.3s ease;
}
}
// 搜索定位 - Flash 脉冲动画(导航到新结果时触发)
&.search-flash {
animation: searchPulse 1.2s ease-out;
}
&.first-message {
margin-top: 16rpx;
}
.user-message, .ai-message {
display: flex;
align-items: flex-start;
.avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
/* 白底风格: 取消外圈与阴影 */
box-shadow: none;
background-color: transparent;
border: none;
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
&:hover {
transform: scale(1.03);
}
image {
width: 100%;
height: 100%;
object-fit: cover;
}
text {
font-size: 32rpx;
line-height: 1;
}
.avatar-fallback {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
background-color: transparent;
}
}
.message-content {
flex: 1;
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
min-width: 0;
width: 0; /* 添加这个属性防止flex元素溢出 */
.message-bubble {
/* 去掉所有气泡视觉: 背景透明, 无边框, 无阴影, 无内边距, 无小三角 */
padding: 0 !important;
max-width: 100%;
word-break: break-word;
box-shadow: none !important;
position: relative;
border: none !important;
background: transparent !important;
&::before {
display: none !important;
content: none !important;
}
.message-text {
font-size: 30rpx;
line-height: 1.6;
position: relative;
z-index: 1;
letter-spacing: 0.3rpx;
color: #1f1f1f;
}
}
.message-time {
font-size: 22rpx;
/* 白底风格: 浅灰色时间戳 */
color: #999;
margin-top: 8rpx;
opacity: 1;
letter-spacing: 0.5rpx;
}
/* 各种消息类型样式 */
.markdown-content {
.markdown-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
.icon-markdown {
color: $color-primary;
margin-right: 12rpx;
font-size: 28rpx;
}
.markdown-title {
font-weight: 600;
color: $color-text;
font-size: 28rpx;
}
}
.markdown-body {
line-height: 1.8;
font-size: 26rpx;
color: $color-text;
strong {
font-weight: 600;
color: $color-text;
}
em {
font-style: italic;
color: $color-text-light;
}
code {
background-color: #f0f4f8;
padding: 4rpx 8rpx;
border-radius: $radius-sm;
font-family: monospace;
font-size: 24rpx;
color: $color-primary;
}
a {
color: $color-primary;
text-decoration: underline;
text-underline-offset: 4rpx;
transition: color 0.2s ease;
&:hover {
color: #5a7cb0;
}
}
}
}
.file-content {
.file-item {
display: flex;
align-items: center;
padding: 24rpx;
background-color: $bg-card;
border-radius: $radius-md;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
background-color: #fafbfc;
box-shadow: $shadow-md;
}
.file-icon {
margin-right: 24rpx;
.icon-file {
font-size: 52rpx;
color: $color-primary;
}
}
.file-info {
flex: 1;
.file-name {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.file-size {
font-size: 24rpx;
color: $color-text-light;
}
}
.file-action {
.icon-download {
font-size: 38rpx;
color: $color-primary;
transition: transform 0.2s ease;
}
&:hover .icon-download {
transform: scale(1.1);
}
}
}
}
.audio-content {
.audio-player {
.audio-info {
margin-bottom: 16rpx;
.audio-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.audio-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
.audio-controls {
display: flex;
align-items: center;
.play-btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background-color: $color-primary;
color: white;
margin-right: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.3);
transition: all 0.2s ease;
&.playing {
background-color: $color-primary-light;
box-shadow: 0 2rpx 8rpx rgba(138, 159, 184, 0.2);
}
&:hover {
transform: scale(1.05);
}
}
.audio-slider {
flex: 1;
}
}
}
}
.video-content {
.video-player {
.video-element {
width: 100%;
height: 420rpx;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-md;
}
.video-info {
margin-top: 16rpx;
.video-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.video-duration {
font-size: 24rpx;
color: $color-text-light;
}
}
}
}
.link-content {
.link-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.link-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
border-radius: $radius-md 0 0 $radius-md;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.link-info {
flex: 1;
padding: 24rpx;
.link-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
}
.link-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 6rpx;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.link-url {
display: block;
font-size: 22rpx;
color: $color-primary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.product-content {
.product-card {
display: flex;
background-color: $bg-card;
border-radius: $radius-md;
overflow: hidden;
border: 1rpx solid #f0f4f8;
transition: all 0.3s ease;
&:hover {
box-shadow: $shadow-md;
transform: translateY(-2rpx);
}
.product-image {
width: 130rpx;
height: 130rpx;
flex-shrink: 0;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.product-info {
flex: 1;
padding: 24rpx;
.product-title {
display: block;
font-weight: 600;
margin-bottom: 6rpx;
font-size: 26rpx;
color: $color-text;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-price {
display: block;
font-size: 28rpx;
font-weight: 600;
margin-bottom: 6rpx;
color: $color-user;
}
.product-desc {
display: block;
font-size: 24rpx;
color: $color-text-light;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
}
.loading-content {
display: flex;
align-items: center;
padding: 32rpx 0;
.loading-typing {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12rpx;
.typing-dots {
display: flex;
align-items: center;
.dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background-color: $color-primary;
margin: 0 6rpx;
animation: dotPulse 1.5s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
.loading-text {
color: $color-text-light;
font-size: 24rpx;
display: flex;
align-items: center;
.loading-status {
color: $color-primary;
font-weight: 500;
transition: opacity 0.3s ease;
}
.loading-ellipsis {
display: inline-flex;
margin-left: 4rpx;
.ellipsis-dot {
animation: ellipsisBlink 1.4s infinite ease-in-out;
opacity: 0;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
}
}
}
.message-actions {
margin-top: 24rpx;
display: flex;
gap: 16rpx;
justify-content: flex-start;
flex-wrap: nowrap;
width: 100%;
.action-btn {
border-radius: $radius-lg;
font-size: 22rpx;
background-color: $bg-card;
padding: 10rpx 20rpx;
display: flex;
align-items: center;
gap: 6rpx;
transition: all 0.2s ease;
border: 1rpx solid #f0f4f8;
color: $color-text;
white-space: nowrap;
&.like {
color: $color-user;
border-color: #e8e0f2;
&:hover {
background-color: #f9f6fc;
transform: translateY(-2rpx);
}
}
&.dislike {
color: $color-text-light;
border-color: #e9edf2;
&:hover {
background-color: #f8fafc;
transform: translateY(-2rpx);
}
}
}
}
}
}
/* ========== 关键修复:拆分 .message-nickname 样式 ========== */
.message-nickname {
font-size: 24rpx;
color: $color-text-light;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
display: flex;
align-items: baseline;
gap: 12rpx;
line-height: 1;
.message-time {
font-size: 24rpx;
color: #999;
line-height: 1;
top:-15rpx;
}
}
/* 用户昵称:右对齐 */
.user-message .message-nickname {
text-align: right;
justify-content: flex-end;
display: flex;
align-items: baseline;
}
/* AI 昵称:使用 .ai-nickname 类控制 */
.ai-nickname {
text-align: left;
margin-left: 0;
padding-left: 0;
align-self: flex-start;
margin-bottom: 4rpx;
justify-content: flex-start;
display: flex;
align-items: center;
width: 100%;
position: relative;
gap: 12rpx;
.ai-nickname-text {
position: relative;
z-index: 1;
}
.message-time {
/* 时间显示在 AI 智能昵称的右面 */
position: static;
margin-top: 0;
margin-left: 0;
}
}
/* 用户消息特有样式 (WorkBuddy 风格: 右对齐 + 浅灰气泡 + 头像在右)
整体不再贴左, 头像显示且位于气泡右侧 */
.user-message {
flex-direction: row;
justify-content: flex-end;
.avatar {
/* 隐藏用户头像: 消息保持右对齐气泡, 但不再显示头像 */
display: none !important;
}
.message-content {
margin-left: 0;
margin-right: 0;
text-align: right;
max-width: calc(100% - 64rpx);
display: flex;
flex-direction: column;
align-items: flex-end;
/* 昵称 + 时间戳行, 右对齐 */
.message-nickname {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 6rpx;
font-size: 22rpx;
color: #999;
.message-time {
margin-right: 8rpx;
margin-top: 0;
}
.nickname-text {
color: #666;
}
}
/* 气泡: 浅灰背景 + 圆角, 内容在气泡内左对齐(中文阅读体验更好) */
.message-bubble {
background: #f5f5f5 !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 20rpx !important;
box-shadow: none !important;
padding: 20rpx 30rpx !important; // 加大内边距,让气泡更"大"
display: inline-block;
word-break: break-word;
white-space: pre-wrap;
line-height: 1.7;
text-align: left;
max-width: 100%;
.message-text {
font-size: 20rpx !important; // 还原为用户消息原字号
}
&::before {
display: none !important;
content: none !important;
}
}
}
}
/* AI消息特有样式 (微信风格: 头像 + 名称 + 时间同行, 下方内容)
与你截图样式一致 */
.ai-message {
.message-content {
/* 确保昵称在内容上方的布局 */
display: flex;
flex-direction: column;
align-items: flex-start;
.message-nickname.ai-nickname {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 4rpx;
.ai-nickname-text {
font-size: 24rpx;
font-weight: 500;
color: #666;
line-height: 1;
}
.message-time {
margin-top: 0;
font-size: 22rpx;
color: #999;
line-height: 1;
}
}
.message-bubble {
background: transparent !important;
color: #1f1f1f !important;
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 0 !important;
display: block;
/* 容器不够宽时正常换行, 不主动按字断开 */
overflow-wrap: break-word;
word-wrap: break-word;
/* pre-line: 保留后端 \n 的段落分隔, 但合并多个连续空白, 配合 parseMarkdown normalize 避免每字一行 */
white-space: pre-line;
line-height: 1.6;
&::before {
display: none !important;
content: none !important;
}
}
/* AI 消息下方操作栏: 复制 / 点赞 / 不喜欢 / 朗读 / 更多 */
.ai-actions {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 8rpx;
flex-wrap: nowrap;
.ai-action-btn {
min-width: 56rpx;
height: 56rpx;
padding: 0 14rpx;
border-radius: 28rpx;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease, color 0.2s ease;
.iconfont {
font-size: 30rpx;
color: #666;
line-height: 1;
}
&.more-btn .iconfont {
font-size: 32rpx;
letter-spacing: -2rpx;
}
&.active {
background: #e6f0ff;
.iconfont {
color: #1f6cff;
}
}
&:active {
background: #ebebeb;
}
}
}
}
}
}
}
/* 键盘垫高层:高度变化时平滑过渡 */
.keyboard-spacer {
flex-shrink: 0;
transition: height 0.25s ease;
}
/* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */
.input-area {
background: transparent;
padding: 12rpx 20rpx 16rpx;
flex-shrink: 0;
box-sizing: border-box;
.input-card {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
box-shadow: none;
box-sizing: border-box;
}
/* 顶部输入横条: 胶囊形,左侧输入区 + 右侧操作按钮 */
.input-row-top {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border-radius: 999rpx;
padding: 18rpx 14rpx 18rpx 32rpx;
min-height: 108rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
/* 无文字时左侧语音图标,点击打开语音面板 */
.input-row-icon {
flex-shrink: 0;
width: 52rpx;
height: 52rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
.voice-icon-svg {
width: 36rpx;
height: 36rpx;
display: block;
}
}
/* 输入区: textarea 在上,模型选择器在下 */
.input-left-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 72rpx;
padding-right: 12rpx;
&.with-voice-icon {
margin-left: 0;
}
.message-input {
width: 100%;
min-height: 44rpx;
max-height: 240rpx;
background: transparent !important;
border: none !important;
padding: 0 0 6rpx 0;
font-size: 34rpx;
line-height: 1.5;
color: #1f1f1f;
box-sizing: border-box;
}
.message-input-placeholder {
color: #999 !important;
font-size: 32rpx !important;
}
.model-selector {
align-self: flex-start;
display: flex;
align-items: center;
gap: 6rpx;
height: 36rpx;
padding: 0;
margin-top: 4rpx;
background: transparent;
.model-icon-circle {
width: 28rpx;
height: 28rpx;
border-radius: 50%;
border: 2rpx solid #666666;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
.model-icon-text {
font-size: 18rpx;
color: #666666;
line-height: 1;
margin-top: 2rpx;
}
}
.model-name {
font-size: 26rpx;
color: #666666;
font-weight: 400;
line-height: 1;
}
.model-arrow {
font-size: 22rpx;
color: #999999;
line-height: 1;
margin-top: 2rpx;
}
}
}
/* 右侧操作按钮: + 号 和 发送 */
.input-right-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8rpx;
}
.top-plus-btn {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
.plus-icon {
font-size: 52rpx;
color: #1f1f1f;
font-weight: 300;
line-height: 1;
}
&::after {
border: none;
}
}
.top-send-btn {
flex-shrink: 0;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
background: #1f1f1f;
border: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
.send-plane-text {
color: #ffffff;
font-size: 34rpx;
line-height: 1;
transform: rotate(-90deg); // 箭头向上,呈向上纸飞机
display: inline-block;
margin-left: -2rpx; // 视觉居中
margin-top: -2rpx;
}
&::after {
border: none;
}
}
}
/* 底部工具栏: 5 个图标按钮均匀分布 */
.input-toolbar {
display: flex;
align-items: center;
justify-content: space-around;
margin-top: 24rpx;
padding: 4rpx 0;
.toolbar-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 4rpx 0;
&.toolbar-item--hover,
&:active {
opacity: 0.55;
}
.toolbar-icon {
font-size: 44rpx;
line-height: 1;
color: #1f1f1f;
}
.toolbar-icon-svg {
width: 44rpx;
height: 44rpx;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 100%;
height: 100%;
display: block;
}
}
.toolbar-text {
font-size: 22rpx;
color: #1f1f1f;
line-height: 1;
}
}
}
}
}
/* 动画定义 */
@keyframes dotPulse {
0%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@keyframes ellipsisBlink {
0%, 100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes wave {
0%, 100% {
transform: scaleY(0.5);
opacity: 0.7;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}
/* 搜索定位脉冲动画 */
@keyframes searchPulse {
0% {
transform: translateY(0);
}
15% {
transform: translateY(-12rpx);
}
30% {
transform: translateY(4rpx);
}
45% {
transform: translateY(-4rpx);
}
60% {
transform: translateY(0);
}
0%, 60%, 100% {
filter: brightness(1);
}
15%, 45% {
filter: brightness(1.15);
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 200%;
}
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 滚动条样式优化 */
::-webkit-scrollbar {
width: 8rpx;
height: 8rpx;
}
::-webkit-scrollbar-track {
background: $bg-main;
border-radius: 4rpx;
}
::-webkit-scrollbar-thumb {
background: $color-primary-light;
border-radius: 4rpx;
opacity: 0.7;
&:hover {
background: $color-primary;
opacity: 1;
}
}
/* 商品选择弹窗样式 */
.product-selector-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
.product-selector-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.product-selector-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 800rpx;
max-height: 80vh;
background-color: #fff;
border-radius: 20rpx;
overflow: hidden;
.product-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
border-bottom: 1rpx solid #e8edf3;
text {
font-size: 32rpx;
font-weight: bold;
color: #4e5969;
}
.close-btn {
padding: 0;
background: none;
.iconfont {
font-size: 32rpx;
color: #8492a6;
}
}
}
.product-selector-content {
display: flex;
height: 60vh;
.category-list {
width: 200rpx;
border-right: 1rpx solid #e8edf3;
.category-item {
padding: 20rpx;
text-align: center;
border-bottom: 1rpx solid #e8edf3;
&.active {
background-color: #f5f7fa;
font-weight: bold;
color: #6c8ebf;
}
}
}
.product-list {
flex: 1;
padding: 20rpx;
.loading {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.empty {
text-align: center;
padding: 40rpx;
color: #8492a6;
}
.product-item {
display: flex;
padding: 15rpx;
border-bottom: 1rpx solid #e8edf3;
.product-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
margin-right: 15rpx;
}
.product-info {
flex: 1;
.product-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 10rpx;
color: #4e5969;
}
.product-price {
color: #f56c6c;
font-weight: bold;
}
}
}
}
}
}
}
/* 响应式适配 */
@media (max-width: 750rpx) {
.ai-chat-container {
.chat-messages {
padding: 16rpx 24rpx;
.message-item {
margin-bottom: 24rpx;
.user-message, .ai-message {
.avatar {
width: 40rpx;
height: 40rpx;
}
.message-content {
margin-left: 16rpx;
max-width: calc(100% - 56rpx);
.message-bubble {
/* 移动端也保持无气泡样式, 文字直接渲染在深色背景上 */
padding: 0 !important;
.message-text {
font-size: 28rpx;
}
}
}
}
}
}
.input-area {
padding: 10rpx 16rpx 12rpx;
.input-card {
padding: 12rpx 14rpx 10rpx;
border-radius: 18rpx;
}
.input-row-top {
min-height: 64rpx;
.message-input {
min-height: 52rpx;
font-size: 28rpx;
}
.message-input-placeholder {
font-size: 28rpx !important;
}
}
.input-toolbar {
min-height: 56rpx;
.send-btn {
width: 56rpx;
height: 56rpx;
.send-icon {
font-size: 26rpx;
}
}
}
}
}
}
/* 扩大按钮 */
.expand-btn {
width: 72rpx;
height: 72rpx;
border-radius: 12rpx;
margin-left: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: none;
background-color: #f2f2f2;
padding: 0;
left:60rpx;
.iconfont {
font-size: 36rpx;
line-height: 1;
}
}
/* 扩大编辑器弹窗 */
.expand-editor-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
flex-direction: column;
.expand-editor-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
}
.expand-editor-panel {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: 0;
margin-top: 100rpx;
.expand-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
text {
font-size: 32rpx;
font-weight: 500;
color: #333;
}
.close-btn {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
border: none;
padding: 0;
text {
font-size: 48rpx;
color: #999;
line-height: 1;
}
}
}
.expand-editor-input {
flex: 1;
min-height: 300rpx;
max-height: none;
padding: 32rpx;
font-size: 30rpx;
line-height: 1.6;
background-color: #fff;
border: none;
color: #333;
}
.expand-editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-top: 1rpx solid #e5e5e5;
background-color: #f9f9f9;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
.expand-cancel-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #f2f2f2;
color: #666;
font-size: 28rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.expand-send-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx;
background-color: #07c160;
color: #fff;
font-size: 28rpx;
font-weight: 500;
border: none;
display: flex;
align-items: center;
justify-content: center;
&[disabled] {
background-color: #ccc;
opacity: 0.7;
}
}
}
}
// H5 下允许 rich-text 内容文本选择(鼠标拖选复制)
.text-content,
.markdown-body {
user-select: text;
-webkit-user-select: text;
}
// 用户消息 rich-text 高亮(搜索时替代 <text>)
.message-rich-text {
font-size: 28rpx;
line-height: 1.7;
letter-spacing: 0.8rpx;
}
}
</style>