3924 lines
124 KiB
Vue
3924 lines
124 KiB
Vue
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
|
||
// 2. 提取代码块(保护其内容不被后续处理污染)
|
||
const codeBlocks = []
|
||
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (m, lang, code) => {
|
||
const idx = codeBlocks.length
|
||
const safe = code.replace(/</g, '<').replace(/>/g, '>')
|
||
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>
|