3952 lines
113 KiB
Vue
3952 lines
113 KiB
Vue
<template>
|
||
<view class="ai-chat-container">
|
||
<!-- 聊天消息列表 - 统一使用 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.content && msg.content.trim() !== '')"
|
||
:key="getMessageKey(message, index)"
|
||
:id="getMessageKey(message, index)"
|
||
class="message-item"
|
||
:class="[message.role, { 'first-message': index === 0 }]">
|
||
|
||
<!-- 用户消息 -->
|
||
<view v-if="message.role === 'user'" class="user-message">
|
||
<view class="avatar" @tap="openAvatarSelector">
|
||
<!-- 绑定本地用户头像,支持实时更新 -->
|
||
<image :src="localUserAvatar" mode="aspectFill" show-menu-by-longpress/>
|
||
</view>
|
||
<view class="message-content">
|
||
<!-- 新增:显示用户昵称 -->
|
||
<view class="message-nickname" v-if="localUserNickname">
|
||
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
|
||
<text>{{ localUserNickname }}</text>
|
||
</view>
|
||
<!-- 如果没有昵称,只显示时间 -->
|
||
<view class="message-nickname" v-else>
|
||
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
|
||
</view>
|
||
<view class="message-bubble">
|
||
<text class="message-text">{{ message.content }}</text>
|
||
</view>
|
||
</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">智能助手</text>
|
||
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
|
||
</view>
|
||
<view class="message-bubble">
|
||
<!-- 文本消息 -->
|
||
<view v-if="message.type === 'text'" class="text-content">
|
||
<rich-text :nodes="parseMarkdown(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="parseMarkdown(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 || '语音消息' }}</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 || '视频消息' }}</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-dots">
|
||
<text class="dot"></text>
|
||
<text class="dot"></text>
|
||
<text class="dot"></text>
|
||
</view>
|
||
<text class="loading-text">AI正在思考中...</text>
|
||
</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>
|
||
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
|
||
<!-- 输入区域 -->
|
||
<view class="input-area" :style="inputAreaStyle" >
|
||
<view class="input-tools">
|
||
<button class="tool-btn" @click="showMoreTools" style="display: none;">
|
||
<text class="iconfont icon-plus">...</text>
|
||
</button>
|
||
<button class="tool-btn" @click="toggleVoiceInput" style="display: none;">
|
||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'">🎤</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view class="input-container">
|
||
<textarea
|
||
v-model="inputText"
|
||
class="message-input"
|
||
placeholder="请输入您的问题..."
|
||
:maxlength="500"
|
||
:auto-height="true"
|
||
:show-confirm-bar="false"
|
||
:adjust-position="true"
|
||
:cursor-spacing="150"
|
||
@confirm="sendMessage"
|
||
@input="onInput"
|
||
@linechange="onLineChange" />
|
||
|
||
<!-- 扩大按钮 -->
|
||
<button v-if="inputLineCount > 3" class="expand-btn" @click="openExpandEditor">
|
||
<text class="iconfont icon-expand">[ ]</text>
|
||
</button>
|
||
|
||
<!-- Emoji按钮 -->
|
||
<button class="emoji-btn" @click="toggleEmojiPanel">
|
||
<text class="iconfont icon-smile">😊</text>
|
||
</button>
|
||
|
||
<!-- 发送按钮 -->
|
||
<button
|
||
class="send-btn"
|
||
:class="{ disabled: !canSendMessage }"
|
||
@click="sendMessage"
|
||
:disabled="!canSendMessage">
|
||
<text>发送</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 更多工具面板 -->
|
||
<view v-if="showToolsPanel" class="tools-popup">
|
||
<view class="tools-mask" @click="hideToolsPanel"></view>
|
||
<view class="tools-panel">
|
||
<view class="tools-header">
|
||
<text>更多功能</text>
|
||
<button class="close-btn" @click="hideToolsPanel">
|
||
<text class="iconfont icon-close"></text>
|
||
</button>
|
||
</view>
|
||
<view class="tools-grid">
|
||
<view class="tool-item" @click="selectImage">
|
||
<view class="tool-icon">
|
||
<text class="iconfont icon-image"></text>
|
||
</view>
|
||
<text class="tool-text">图片</text>
|
||
</view>
|
||
<view class="tool-item" @click="selectFile">
|
||
<view class="tool-icon">
|
||
<text class="iconfont icon-file"></text>
|
||
</view>
|
||
<text class="tool-text">文件</text>
|
||
</view>
|
||
<view class="tool-item" @click="startRecord">
|
||
<view class="tool-icon">
|
||
<text class="iconfont icon-voice"></text>
|
||
</view>
|
||
<text class="tool-text">语音</text>
|
||
</view>
|
||
<view class="tool-item" @click="selectLocation">
|
||
<view class="tool-icon">
|
||
<text class="iconfont icon-location"></text>
|
||
</view>
|
||
<text class="tool-text">位置</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Emoji面板 -->
|
||
<view v-if="showEmojiPanel" class="emoji-popup">
|
||
<view class="emoji-mask" @click="hideEmojiPanel"></view>
|
||
<view class="emoji-panel">
|
||
<view class="emoji-header">
|
||
<text>表情</text>
|
||
<button class="close-btn" @click="hideEmojiPanel">
|
||
<text>×</text>
|
||
</button>
|
||
</view>
|
||
<view class="emoji-grid">
|
||
<view
|
||
v-for="(emoji, index) in emojiList"
|
||
:key="index"
|
||
class="emoji-item"
|
||
@click="insertEmoji(emoji)">
|
||
<text class="emoji-text">{{ emoji }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 扩大编辑器弹窗 -->
|
||
<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>编辑内容</text>
|
||
<button class="close-btn" @click="closeExpandEditor">
|
||
<text>×</text>
|
||
</button>
|
||
</view>
|
||
<textarea
|
||
v-model="expandInputText"
|
||
class="expand-editor-input"
|
||
placeholder="请输入您的问题..."
|
||
: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">取消</button>
|
||
<button class="expand-send-btn" @click="sendFromExpandEditor" :disabled="!expandInputText.trim()">发送</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 语音输入面板 -->
|
||
<view v-if="enalbeVoicePanelShow" class="voice-popup">
|
||
<view class="voice-mask" @click="hideVoicePanel"></view>
|
||
<view class="voice-input-panel">
|
||
<view class="voice-header">
|
||
<text>语音输入</text>
|
||
<button class="close-btn" @click="hideVoicePanel">
|
||
<text class="iconfont icon-close"></text>
|
||
</button>
|
||
</view>
|
||
<view class="voice-content">
|
||
<view class="voice-wave" :class="{ recording: voiceInputing }">
|
||
<view
|
||
v-for="i in 8"
|
||
:key="i"
|
||
class="wave-bar"
|
||
:style="{ animationDelay: (i * 0.1) + 's' }"></view>
|
||
</view>
|
||
<text class="voice-tip">{{ voiceInputing ? '正在录音,松开结束' : '点击开始录音' }}</text>
|
||
</view>
|
||
<view class="voice-actions">
|
||
<button class="voice-btn" @click="toggleVoiceInput">
|
||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'"></text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 头像选择弹窗 -->
|
||
<view v-if="showAvatarSelector" class="avatar-selector-popup" style="pointer-events: auto;">
|
||
<view class="avatar-selector-mask" @click="closeAvatarSelector"></view>
|
||
<view class="avatar-selector-panel">
|
||
<view class="avatar-selector-header">
|
||
<text>更换头像</text>
|
||
<button class="close-btn" @click="closeAvatarSelector">
|
||
<text class="iconfont icon-close"></text>
|
||
</button>
|
||
</view>
|
||
<view class="avatar-selector-content">
|
||
<!-- 预览当前头像 -->
|
||
<view class="current-avatar-preview">
|
||
<image :src="localUserAvatar" mode="aspectFill" show-menu-by-longpress/>
|
||
</view>
|
||
<view class="avatar-selector-actions">
|
||
<button class="avatar-select-action" @click="chooseAvatarFromAlbum">
|
||
<text class="iconfont icon-album"></text>
|
||
<text>从相册选择</text>
|
||
</button>
|
||
<button class="avatar-select-action cancel" @click="closeAvatarSelector">
|
||
<text>取消</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 新增:昵称编辑弹窗 -->
|
||
<view v-if="showNicknameEditor" class="nickname-editor-popup">
|
||
<view class="nickname-editor-mask" @click="closeNicknameEditor"></view>
|
||
<view class="nickname-editor-panel">
|
||
<view class="nickname-editor-header">
|
||
<text>修改昵称</text>
|
||
<button class="close-btn" @click="closeNicknameEditor">
|
||
<text class="iconfont icon-close"></text>
|
||
</button>
|
||
</view>
|
||
<view class="nickname-editor-content">
|
||
<view class="nickname-input-container">
|
||
<input
|
||
v-model="tempNickname"
|
||
class="nickname-input"
|
||
placeholder="请输入您的新昵称"
|
||
:maxlength="12"
|
||
type="text" />
|
||
</view>
|
||
<view class="nickname-tip">昵称长度限制1-12个字符,仅自己可见</view>
|
||
<view class="nickname-editor-actions">
|
||
<button class="nickname-action-btn cancel" @click="closeNicknameEditor">
|
||
<text>取消</text>
|
||
</button>
|
||
<button
|
||
class="nickname-action-btn confirm"
|
||
:class="{ disabled: !tempNickname.trim() }"
|
||
:disabled="!tempNickname.trim()"
|
||
@click="saveNickname">
|
||
<text>保存</text>
|
||
</button>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
<script>
|
||
import aiService from '@/common/js/ai-service.js'
|
||
import IMAGE_CONFIG from '@/common/js/image-config.js'
|
||
|
||
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
|
||
}
|
||
},
|
||
data(){
|
||
return {
|
||
messages: [],
|
||
inputText: '',
|
||
scrollTop: 0,
|
||
scrollIntoViewId: null,
|
||
hasMoreHistory: false,
|
||
loadingText: '加载更多历史消息',
|
||
voiceInputing: false,
|
||
audioContext: null,
|
||
currentAudio: null,
|
||
messageId: 0,
|
||
showToolsPanel: false,
|
||
enalbeVoicePanelShow: false,
|
||
showEmojiPanel: false,
|
||
// 扩大编辑器相关
|
||
showExpandEditor: false,
|
||
expandInputText: '',
|
||
inputLineCount: 0,
|
||
emojiList: ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','🙃','😉','😊','😇','🥰','😍','🤩','😘','😗','😚','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','🤐','🤨','😐','😑','😶','😏','😒','🙄','😬','🤥','😌','😔','😪','🤤','😴','😷','🤒','🤕','🤢','🤮','🤧','🥵','🥶','🥴','😵','🤯','🤠','🥳','😎','🤓','🧐','😕','😟','🙁','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','😠','🤬','😈','👿','💀','💩','👻','👽','🤖','🎃','😺','😸','😹','😻','😼','😽','🙀','😿','😾','👋','🤚','🖐','✋','🖖','👌','🤏','✌','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','✍','💅','🤳','💪','💗','💓','💕','💖','💝','💞','💘','💌','💟','💛','💙','💜','🖤','💔','💢','💣','💥','💫','💦','💨','👁','👀','👅','👄','👂','👃','👶','👦','👧','👨','👩','👴','👵','👱','👲','👳','👴','👵','👶','👼','👸','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','🧞','🧟','🦄','🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐤','🐣','🐥','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋','🐌','🐞','🐜','🦟','🦗','🕷','🦂','🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐅','🐆','🦓','🦍','🦧','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🐐','🐓','🦃','🦚','🦜','🦢','🦩','🕊','🐕','🐩','🐈','🐓','🐇','🐁','🐀','🐿','🦔','🐾','🐉','🐲'],
|
||
streamingMessage: null,
|
||
streamInterval: null,
|
||
streamContent: '',
|
||
isStreaming: false,
|
||
// 本地用户头像(优先使用缓存,无缓存则用默认图)
|
||
localUserAvatar: '',
|
||
showAvatarSelector: false, // 头像选择弹窗控制
|
||
localUserNickname: '', // 当前用户昵称
|
||
defaultNickname: '我', // 默认昵称
|
||
showNicknameEditor: false, // 昵称编辑弹窗控制
|
||
tempNickname: '', // 昵称编辑临时值
|
||
// AI头像错误状态
|
||
avatarError: false,
|
||
|
||
// Dify API相关
|
||
currentConversationId: null,
|
||
isAIServiceAvailable: true,
|
||
aiServiceError: null,
|
||
isLoadingHistory: false,
|
||
shouldScrollToBottom: false,
|
||
scrollTimer: null,
|
||
lastScrollTop: 0,
|
||
historyOffset: 0,
|
||
isFetchingHistory: false,
|
||
keyboardHeight: 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
|
||
// 确保最后一条消息在键盘上方可见
|
||
const extraPadding = 200; // 额外的padding(px)
|
||
const totalPadding = this.keyboardHeight > 0 ? this.keyboardHeight + extraPadding : 150; // 默认150px
|
||
return {
|
||
paddingBottom: totalPadding + 'px'
|
||
};
|
||
},
|
||
inputAreaStyle() {
|
||
return {
|
||
bottom: this.keyboardHeight > 0 ? this.keyboardHeight + 'px' : '0px'
|
||
};
|
||
},
|
||
canSendMessage() {
|
||
const hasContent = this.inputText && this.inputText.trim() !== ''
|
||
const hasEmoji = this.containsEmoji(this.inputText)
|
||
return hasContent || hasEmoji
|
||
}
|
||
},
|
||
onShow() {
|
||
// 如果不是 AI 客服,立即退出并跳转
|
||
if (customerServiceType !== 'ai') {
|
||
uni.showToast({ title: '当前客服类型不支持此页面', icon: 'none' });
|
||
setTimeout(() => {
|
||
uni.navigateBack({ delta: 1 });
|
||
}, 1000);
|
||
return;
|
||
}
|
||
// 只在初次渲染或会话ID发生变化时才加载历史
|
||
const localConvId = this.getConversationIdFromLocal();
|
||
const shouldReload = this.currentConversationId !== localConvId;
|
||
|
||
if (localConvId && shouldReload) {
|
||
// 切换到已有的历史会话 → 重新加载历史
|
||
this.currentConversationId = localConvId;
|
||
aiService.setConversationId(localConvId);
|
||
this.loadChatHistoryIfExist();
|
||
// 注意: historyOffset 和 hasMoreHistory 在 loadChatHistoryIfExist() 内部设置
|
||
} else if (!localConvId && !this.currentConversationId) {
|
||
// 全新会话,初始化偏移量
|
||
this.historyOffset = this.messages.length;
|
||
}
|
||
// 会话未变化且已有消息 → 跳过加载,保留当前消息
|
||
},
|
||
// 新增:组件销毁时清除防抖定时器
|
||
beforeDestroy() {
|
||
if (this.scrollTimer) clearTimeout(this.scrollTimer);
|
||
},
|
||
created() {
|
||
this.messages = [...this.initialMessages]
|
||
this.messageId = this.messages.length
|
||
this.initAudioContext()
|
||
// 初始化用户头像(缓存优先)
|
||
this.initUserAvatarData()
|
||
// 初始化用户昵称(缓存优先)
|
||
this.initUserNicknameData()
|
||
this.currentConversationId = aiService.getConversationId();
|
||
},
|
||
mounted() {
|
||
// 初始加载时滚底(仅一次)
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom()
|
||
})
|
||
},
|
||
watch: {
|
||
messages: {
|
||
handler(newVal, oldVal) {
|
||
// 仅当不是加载历史且不是流式响应时,执行滚底
|
||
if (!this.isLoadingHistory && !this.isStreaming && newVal.length > oldVal.length) {
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom()
|
||
})
|
||
}
|
||
},
|
||
deep: true
|
||
}
|
||
},
|
||
methods: {
|
||
// 生成消息的唯一 key(兼容非 H5 平台)
|
||
getMessageKey(message, index) {
|
||
const id = message.id || message.timestamp || index;
|
||
return 'msg-' + id + '-' + index;
|
||
},
|
||
|
||
// 更新键盘高度
|
||
updateKeyboardHeight(height) {
|
||
this.keyboardHeight = height;
|
||
if (height > 0) {
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
}
|
||
},
|
||
// 【新增:会话 ID 本地缓存方法】
|
||
saveConversationIdToLocal(convId) {
|
||
if (convId) {
|
||
uni.setStorageSync('dify_conversation_id', convId);
|
||
}
|
||
},
|
||
getConversationIdFromLocal() {
|
||
return uni.getStorageSync('dify_conversation_id') || null;
|
||
},
|
||
clearConversationIdFromLocal() {
|
||
uni.removeStorageSync('dify_conversation_id');
|
||
},
|
||
// 初始化音频上下文
|
||
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();
|
||
if (convId) {
|
||
try {
|
||
// 调用正确的方法,并传入会话ID
|
||
const response = await aiService.getConversationHistory({
|
||
conversation_id: convId
|
||
});
|
||
|
||
if (!response.success || !Array.isArray(response.messages)) {
|
||
console.warn('历史记录为空或格式无效');
|
||
return;
|
||
}
|
||
// 需要根据实际返回的 response.data.messages 来映射
|
||
this.messages = response.messages.map(msg => {
|
||
let content = '';
|
||
if (msg.role === 'user') {
|
||
content = msg.query || (typeof msg.inputs === 'string' ? msg.inputs : JSON.stringify(msg.inputs || '')) || '';
|
||
} else {
|
||
content = msg.answer || '';
|
||
}
|
||
// 只使用后端返回的真实时间,不使用假时间
|
||
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',
|
||
content: content,
|
||
timestamp: realTimestamp,
|
||
actions: msg.role !== 'user' ? [
|
||
{ id: 1, text: '有帮助', type: 'like' },
|
||
{ id: 2, text: '没帮助', type: 'dislike' }
|
||
] : []
|
||
};
|
||
}).filter(msg => msg.content.trim() !== ''); // 过滤掉内容为空的消息
|
||
// 根据服务端 total 判断是否还有更多历史可加载
|
||
const msgCount = this.messages.length;
|
||
this.hasMoreHistory = response.total > msgCount;
|
||
this.historyOffset = msgCount;
|
||
} catch (error) {
|
||
console.error('加载历史记录失败:', error);
|
||
// 只需调用服务层的清除方法,它会处理本地存储
|
||
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
|
||
}
|
||
},
|
||
|
||
// 打开头像选择弹窗
|
||
openAvatarSelector() {
|
||
this.showAvatarSelector = true
|
||
},
|
||
|
||
// 关闭头像选择弹窗
|
||
closeAvatarSelector() {
|
||
this.showAvatarSelector = false
|
||
},
|
||
|
||
// 从相册选择头像
|
||
chooseAvatarFromAlbum() {
|
||
this.selectAvatar()
|
||
},
|
||
|
||
// 统一头像选择方法 - 仅从相册选择
|
||
selectAvatar() {
|
||
|
||
// #ifdef H5
|
||
// H5环境下的处理 - 仅从相册选择
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = 'image/*'
|
||
input.style.display = 'none'
|
||
|
||
// 监听文件选择
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (file) {
|
||
// 创建临时URL
|
||
const tempFilePath = URL.createObjectURL(file)
|
||
|
||
// 1. 本地预览更新
|
||
this.localUserAvatar = tempFilePath
|
||
|
||
// 2. 持久化缓存(支持页面刷新后保留)
|
||
const reader = new FileReader()
|
||
reader.onload = (e) => {
|
||
const base64 = e.target.result
|
||
uni.setStorageSync('user_avatar', base64)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
|
||
// 3. 通知父组件(可选,用于同步到服务器等场景)
|
||
this.$emit('avatar-changed', tempFilePath)
|
||
|
||
// 4. 关闭弹窗
|
||
this.closeAvatarSelector()
|
||
|
||
// 5. 提示成功
|
||
uni.showToast({
|
||
title: '头像更换成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
} else {
|
||
// 用户取消选择
|
||
this.closeAvatarSelector()
|
||
}
|
||
}
|
||
|
||
// 触发点击
|
||
input.click()
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// 微信小程序环境下,使用uni.chooseImage - 仅从相册选择
|
||
uni.chooseImage({
|
||
count: 1, // 仅选择1张
|
||
sizeType: ['compressed'], // 压缩图片
|
||
sourceType: ['album'], // 仅从相册选择
|
||
success: (res) => {
|
||
const tempFilePath = res.tempFilePaths[0]
|
||
|
||
// 1. 本地预览更新
|
||
this.localUserAvatar = tempFilePath
|
||
|
||
// 2. 持久化缓存(支持页面刷新后保留)
|
||
uni.setStorageSync('user_avatar', tempFilePath)
|
||
|
||
// 3. 通知父组件(可选,用于同步到服务器等场景)
|
||
this.$emit('avatar-changed', tempFilePath)
|
||
|
||
// 4. 关闭弹窗
|
||
this.closeAvatarSelector()
|
||
|
||
// 5. 提示成功
|
||
uni.showToast({
|
||
title: '头像更换成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
},
|
||
fail: (error) => {
|
||
console.error('选择头像失败:', error)
|
||
// 权限拒绝处理
|
||
if (error.errMsg && error.errMsg.includes('deny')) {
|
||
uni.showToast({
|
||
title: '请开启相册权限',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
} else if (error.errMsg && error.errMsg.includes('cancel')) {
|
||
// 用户取消选择,不显示错误提示
|
||
} else {
|
||
uni.showToast({
|
||
title: '头像选择失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
// 关闭弹窗
|
||
this.closeAvatarSelector()
|
||
}
|
||
})
|
||
// #endif
|
||
},
|
||
|
||
// 新增:打开昵称编辑弹窗
|
||
openNicknameEditor() {
|
||
// 初始化临时昵称值为当前昵称
|
||
this.tempNickname = this.localUserNickname
|
||
this.showNicknameEditor = true
|
||
},
|
||
|
||
// 新增:关闭昵称编辑弹窗
|
||
closeNicknameEditor() {
|
||
this.showNicknameEditor = false
|
||
// 清空临时值
|
||
this.tempNickname = ''
|
||
},
|
||
|
||
// 新增:保存用户昵称
|
||
saveNickname() {
|
||
if (!this.tempNickname.trim()) return
|
||
|
||
// 1. 更新本地昵称
|
||
this.localUserNickname = this.tempNickname.trim()
|
||
|
||
// 2. 持久化缓存(支持页面刷新后保留)
|
||
uni.setStorageSync('user_nickname', this.localUserNickname)
|
||
|
||
// 3. 通知父组件(可选,用于同步到服务器等场景)
|
||
this.$emit('nickname-changed', this.localUserNickname)
|
||
|
||
// 4. 关闭弹窗
|
||
this.closeNicknameEditor()
|
||
|
||
// 5. 提示成功
|
||
uni.showToast({
|
||
title: '昵称修改成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
})
|
||
},
|
||
|
||
// 发送消息
|
||
async sendMessage() {
|
||
// 确保有内容或表情
|
||
const hasContent = this.inputText && this.inputText.trim() !== ''
|
||
const hasEmoji = this.containsEmoji(this.inputText)
|
||
|
||
if (!hasContent && !hasEmoji) return
|
||
|
||
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()
|
||
}
|
||
this.shouldScrollToBottom = true
|
||
this.messages.push(loadingMessage)
|
||
|
||
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.messages = this.messages.filter(msg => msg.type !== 'loading')
|
||
|
||
// 显示错误消息
|
||
const errorMessage = {
|
||
id: ++this.messageId,
|
||
role: 'ai',
|
||
type: 'text',
|
||
content: '抱歉,AI服务暂时不可用,请稍后重试。',
|
||
timestamp: Date.now(),
|
||
actions: [
|
||
{ id: 1, text: '重试', 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.messages = this.messages.filter(msg => msg.type !== 'loading')
|
||
|
||
// 更新会话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,
|
||
actions: [
|
||
{ id: 1, text: '有帮助', type: 'like' },
|
||
{ id: 2, text: '没帮助', type: 'dislike' }
|
||
]
|
||
}
|
||
this.shouldScrollToBottom = true
|
||
this.messages.push(aiMessage)
|
||
this.$emit('ai-response', aiMessage)
|
||
},
|
||
// 发送流式消息(自动适配 H5 / 微信小程序)
|
||
async sendStreamMessage(userMessage) {
|
||
// 创建流式消息对象
|
||
const streamMessage = {
|
||
id: ++this.messageId,
|
||
role: 'ai',
|
||
type: 'text',
|
||
content: '',
|
||
timestamp: Date.now(),
|
||
isStreaming: true
|
||
};
|
||
// 标记正在流式响应
|
||
this.isStreaming = true;
|
||
// 移除加载状态,添加流式消息
|
||
this.messages = this.messages.filter(msg => msg.type !== 'loading');
|
||
this.shouldScrollToBottom = true;
|
||
this.messages.push(streamMessage);
|
||
|
||
// 添加消息后立即滚动一次
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
|
||
try {
|
||
// #ifdef H5
|
||
await aiService.sendHttpStream(
|
||
userMessage,
|
||
(chunk) => {
|
||
// 实时更新内容
|
||
streamMessage.content += chunk;
|
||
// 流式更新时滚动到底部显示最新内容(防抖)
|
||
this.scrollToBottom();
|
||
},
|
||
(completeResult) => {
|
||
// 流结束回调
|
||
let convId = '';
|
||
|
||
if (typeof completeResult !== 'string') {
|
||
convId = completeResult.conversation_id || '';
|
||
}
|
||
|
||
// 标记流式结束
|
||
streamMessage.isStreaming = false;
|
||
this.isStreaming = false;
|
||
|
||
// 检查内容是否为空,如果为空则移除该消息
|
||
if (!streamMessage.content || streamMessage.content.trim() === '') {
|
||
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id);
|
||
return;
|
||
}
|
||
|
||
// 保存会话 ID
|
||
if (convId) {
|
||
this.currentConversationId = convId;
|
||
aiService.setConversationId(convId);
|
||
this.saveConversationIdToLocal(convId);
|
||
}
|
||
|
||
// 流结束后再滚动到底部
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
|
||
// 触发事件
|
||
this.$emit('ai-response', streamMessage);
|
||
}
|
||
);
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// ===== 微信小程序: 使用 WebSocket =====
|
||
await aiService.sendStreamMessage(
|
||
userMessage,
|
||
(chunk) => {
|
||
// 实时更新内容
|
||
streamMessage.content += chunk;
|
||
// 流式更新时滚动到底部显示最新内容(防抖)
|
||
this.scrollToBottom();
|
||
},
|
||
(completeResult) => {
|
||
// 流结束回调
|
||
let convId = completeResult?.conversation_id || '';
|
||
|
||
// 标记流式结束
|
||
streamMessage.isStreaming = false;
|
||
this.isStreaming = false;
|
||
|
||
// 检查内容是否为空,如果为空则移除该消息
|
||
if (!streamMessage.content || streamMessage.content.trim() === '') {
|
||
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id);
|
||
return;
|
||
}
|
||
|
||
// 保存会话 ID
|
||
if (convId) {
|
||
this.currentConversationId = convId;
|
||
aiService.setConversationId(convId);
|
||
this.saveConversationIdToLocal(convId);
|
||
}
|
||
|
||
// 流结束后再滚动到底部
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
|
||
// 触发事件
|
||
this.$emit('ai-response', streamMessage);
|
||
}
|
||
);
|
||
// #endif
|
||
} catch (error) {
|
||
console.error('流式请求失败:', error);
|
||
// 标记流式结束
|
||
this.isStreaming = false;
|
||
// 移除流式消息
|
||
this.messages = this.messages.filter(msg => msg.id !== streamMessage.id);
|
||
// 显示错误
|
||
const errorMsg = {
|
||
id: ++this.messageId,
|
||
role: 'ai',
|
||
type: 'text',
|
||
content: '抱歉,服务暂时不可用,请稍后重试。',
|
||
timestamp: Date.now(),
|
||
actions: [{ id: 1, text: '重试', type: 'retry' }]
|
||
};
|
||
this.messages.push(errorMsg);
|
||
this.$emit('ai-response', errorMsg);
|
||
}
|
||
},
|
||
generateAIResponse(userMessage) {
|
||
const responses = {
|
||
'你好': '您好!我是AI智能客服,很高兴为您服务!有什么可以帮助您的吗?',
|
||
'价格': '我们的产品价格根据型号和配置有所不同,具体价格请查看商品详情页面。',
|
||
'发货': '一般情况下,订单会在24小时内发货,具体时效请查看物流信息。',
|
||
'退货': '支持7天无理由退货,请确保商品完好无损。',
|
||
'客服': '我们的客服工作时间是9:00-18:00,有问题可以随时联系我们。'
|
||
};
|
||
for (let key in responses) {
|
||
if (userMessage.includes(key)) {
|
||
return responses[key];
|
||
}
|
||
}
|
||
return `感谢您的咨询!关于"${userMessage}"的问题,我们的专业客服会尽快为您解答。您也可以查看我们的帮助文档获取更多信息。`;
|
||
},
|
||
|
||
|
||
// 增强版Markdown解析(无第三方依赖)
|
||
parseMarkdown(content) {
|
||
if (!content) return ''
|
||
|
||
// 辅助:内联格式处理
|
||
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;
|
||
|
||
// 如果是字符串格式,例如 "2026-04-16 03:56:20"
|
||
if (typeof timestamp === 'string') {
|
||
// 手动解析格式 "YYYY-MM-DD HH:mm:ss"
|
||
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;
|
||
// 先解析成本地时间,然后加上8小时时区差
|
||
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute), parseInt(second));
|
||
// 加上8小时
|
||
date.setHours(date.getHours() + 8);
|
||
} else {
|
||
// 其他字符串格式直接解析
|
||
date = new Date(timestamp);
|
||
}
|
||
}
|
||
// 如果是数字格式
|
||
else {
|
||
let timeValue = timestamp;
|
||
|
||
// 如果时间戳小于10000000000,说明是秒,需要乘以1000转成毫秒
|
||
if (typeof timeValue === 'number' && timeValue < 10000000000) {
|
||
timeValue = timeValue * 1000;
|
||
}
|
||
|
||
date = new Date(timeValue);
|
||
}
|
||
|
||
|
||
// 如果时间无效,就不显示时间
|
||
if (isNaN(date.getTime())) {
|
||
return '';
|
||
}
|
||
|
||
const hours = date.getHours().toString().padStart(2, '0');
|
||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||
const result = hours + ':' + minutes;
|
||
return result;
|
||
},
|
||
|
||
// 格式化文件大小
|
||
formatFileSize(bytes) {
|
||
if (!bytes) return '0B'
|
||
const k = 1024
|
||
const sizes = ['B', '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')
|
||
},
|
||
|
||
// 滚动到底部 - 统一使用 scroll-into-view
|
||
scrollToBottom() {
|
||
if (this.scrollTimer) {
|
||
clearTimeout(this.scrollTimer);
|
||
}
|
||
this.scrollTimer = setTimeout(() => {
|
||
if (this.messages.length > 0) {
|
||
const filteredMessages = this.messages.filter(msg => msg.content && msg.content.trim() !== '');
|
||
if (filteredMessages.length > 0) {
|
||
const lastMessage = filteredMessages[filteredMessages.length - 1];
|
||
const lastIndex = this.messages.indexOf(lastMessage);
|
||
// 先设为空,再设置新值,确保能触发滚动
|
||
this.scrollIntoViewId = null;
|
||
this.$nextTick(() => {
|
||
this.scrollIntoViewId = `msg-${lastMessage.id || lastMessage.timestamp}-${lastIndex}`;
|
||
});
|
||
}
|
||
}
|
||
}, 100);
|
||
},
|
||
|
||
// 加载更多历史消息
|
||
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 = '加载历史消息中...';
|
||
|
||
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: '加载历史失败', icon: 'none' });
|
||
this.hasMoreHistory = false;
|
||
} finally {
|
||
this.isLoadingHistory = false;
|
||
this.isFetchingHistory = false;
|
||
this.loadingText = '加载更多历史消息';
|
||
}
|
||
},
|
||
// 显示更多工具
|
||
showMoreTools() {
|
||
this.showToolsPanel = true
|
||
},
|
||
|
||
// 隐藏工具面板
|
||
hideToolsPanel() {
|
||
this.showToolsPanel = false
|
||
},
|
||
|
||
// 切换Emoji面板
|
||
toggleEmojiPanel() {
|
||
this.showEmojiPanel = !this.showEmojiPanel
|
||
},
|
||
|
||
// 隐藏Emoji面板
|
||
hideEmojiPanel() {
|
||
this.showEmojiPanel = false
|
||
},
|
||
|
||
// 插入Emoji
|
||
insertEmoji(emoji) {
|
||
this.inputText += emoji
|
||
this.hideEmojiPanel()
|
||
},
|
||
|
||
// 显示语音面板
|
||
showVoicePanel() {
|
||
this.enalbeVoicePanelShow = true
|
||
},
|
||
|
||
// 隐藏语音面板
|
||
hideVoicePanel() {
|
||
this.enalbeVoicePanelShow = false
|
||
this.voiceInputing = false
|
||
},
|
||
|
||
// 切换语音输入
|
||
toggleVoiceInput() {
|
||
this.voiceInputing = !this.voiceInputing
|
||
|
||
if (this.voiceInputing) {
|
||
this.showVoicePanel()
|
||
this.startVoiceInput()
|
||
} else {
|
||
this.stopVoiceInput()
|
||
this.hideVoicePanel()
|
||
}
|
||
},
|
||
|
||
// 开始语音输入
|
||
startVoiceInput() {
|
||
// #ifdef H5
|
||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||
.then(stream => {
|
||
// 语音输入处理
|
||
})
|
||
.catch(error => {
|
||
console.error('语音输入错误:', error)
|
||
uni.showToast({
|
||
title: '语音输入失败',
|
||
icon: 'none'
|
||
})
|
||
})
|
||
}
|
||
// #endif
|
||
},
|
||
|
||
// 停止语音输入
|
||
stopVoiceInput() {
|
||
this.voiceInputing = false
|
||
// 停止语音输入逻辑
|
||
},
|
||
|
||
// 预览文件
|
||
previewFile(message) {
|
||
uni.showLoading({ title: '打开文件中...' })
|
||
|
||
// #ifdef H5
|
||
window.open(message.url, '_blank')
|
||
// #endif
|
||
|
||
// #ifdef APP-PLUS
|
||
plus.runtime.openFile(message.url, {
|
||
error: (e) => {
|
||
uni.showToast({
|
||
title: '文件打开失败',
|
||
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 })
|
||
},
|
||
|
||
// 重试消息
|
||
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) {
|
||
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()
|
||
}
|
||
|
||
this.messages.push(loadingMessage)
|
||
|
||
try {
|
||
// 使用AI服务获取回复
|
||
if (this.enableStreaming) {
|
||
// 流式响应
|
||
await this.sendStreamMessage(userMessage.content)
|
||
} else {
|
||
// 普通响应
|
||
await this.sendNormalMessage(userMessage.content)
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('AI服务调用失败:', error)
|
||
|
||
// 移除加载状态
|
||
this.messages = this.messages.filter(msg => msg.type !== 'loading')
|
||
|
||
// 显示错误消息
|
||
const errorMessage = {
|
||
id: ++this.messageId,
|
||
role: 'ai',
|
||
type: 'text',
|
||
content: '抱歉,AI服务暂时不可用,请稍后重试。',
|
||
timestamp: Date.now(),
|
||
actions: [
|
||
{ id: 1, text: '重试', 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
|
||
},
|
||
|
||
// 打开扩大编辑器
|
||
openExpandEditor() {
|
||
this.expandInputText = this.inputText
|
||
this.showExpandEditor = true
|
||
this.hideEmojiPanel()
|
||
this.hideToolsPanel()
|
||
},
|
||
|
||
// 关闭扩大编辑器
|
||
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
|
||
},
|
||
|
||
// 选择图片
|
||
selectImage() {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
sizeType: ['compressed'],
|
||
sourceType: ['album', 'camera'],
|
||
success: (res) => {
|
||
const tempFilePaths = res.tempFilePaths
|
||
const imageMessage = {
|
||
id: ++this.messageId,
|
||
role: 'user',
|
||
type: 'image',
|
||
content: '',
|
||
imageUrl: tempFilePaths[0],
|
||
timestamp: Date.now()
|
||
}
|
||
this.messages.push(imageMessage)
|
||
this.hideToolsPanel()
|
||
this.$emit('image-selected', imageMessage)
|
||
}
|
||
})
|
||
},
|
||
|
||
// 选择文件
|
||
selectFile() {
|
||
// #ifdef H5
|
||
const input = document.createElement('input')
|
||
input.type = 'file'
|
||
input.accept = '*/*'
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0]
|
||
if (file) {
|
||
const fileMessage = {
|
||
id: ++this.messageId,
|
||
role: 'user',
|
||
type: 'file',
|
||
content: '',
|
||
fileName: file.name,
|
||
fileSize: file.size,
|
||
fileType: file.type,
|
||
timestamp: Date.now()
|
||
}
|
||
this.messages.push(fileMessage)
|
||
this.hideToolsPanel()
|
||
this.$emit('file-selected', fileMessage)
|
||
}
|
||
}
|
||
input.click()
|
||
// #endif
|
||
|
||
// #ifdef APP-PLUS
|
||
uni.chooseFile({
|
||
count: 1,
|
||
success: (res) => {
|
||
const file = res.tempFiles[0]
|
||
const fileMessage = {
|
||
id: ++this.messageId,
|
||
role: 'user',
|
||
type: 'file',
|
||
content: '',
|
||
fileName: file.name,
|
||
fileSize: file.size,
|
||
fileType: file.type,
|
||
url: file.path,
|
||
timestamp: Date.now()
|
||
}
|
||
this.messages.push(fileMessage)
|
||
this.hideToolsPanel()
|
||
this.$emit('file-selected', fileMessage)
|
||
}
|
||
})
|
||
// #endif
|
||
},
|
||
|
||
// 开始录音
|
||
startRecord() {
|
||
this.toggleVoiceInput()
|
||
},
|
||
|
||
// 选择位置
|
||
selectLocation() {
|
||
uni.chooseLocation({
|
||
success: (res) => {
|
||
const locationMessage = {
|
||
id: ++this.messageId,
|
||
role: 'user',
|
||
type: 'location',
|
||
content: '',
|
||
latitude: res.latitude,
|
||
longitude: res.longitude,
|
||
address: res.address,
|
||
name: res.name,
|
||
timestamp: Date.now()
|
||
}
|
||
this.messages.push(locationMessage)
|
||
this.hideToolsPanel()
|
||
this.$emit('location-selected', locationMessage)
|
||
}
|
||
})
|
||
},
|
||
|
||
|
||
|
||
// 清空聊天记录
|
||
clearMessages() {
|
||
// 清空消息数组
|
||
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>
|
||
/* 引入图标字体 */
|
||
@import url('/common/css/iconfont.css');
|
||
|
||
/* 全局变量:高级低饱和配色 */
|
||
$bg-main: #f5f7fa;
|
||
$bg-card: #ffffff;
|
||
$color-primary: #6c8ebf;
|
||
$color-primary-light: #8a9fb8;
|
||
$color-user: #8675a9;
|
||
$color-text: #4e5969;
|
||
$color-text-light: #8492a6;
|
||
$shadow-sm: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
|
||
$shadow-md: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||
$radius-sm: 12rpx;
|
||
$radius-md: 24rpx;
|
||
$radius-lg: 36rpx;
|
||
|
||
/* 页面样式 */
|
||
.ai-chat-container {
|
||
height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #f8f8f8; /* 微信聊天页面背景色 */
|
||
overflow: hidden;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
padding: 16rpx 24rpx;
|
||
overflow-y: auto;
|
||
box-sizing: border-box;
|
||
min-height: 0; /* 重要:防止flex元素溢出 */
|
||
|
||
/* 微信小程序专属:聊天消息区域增加底部内边距,确保键盘弹出时最后一条消息可见 */
|
||
/* #ifdef MP-WEIXIN */
|
||
padding-bottom: 300rpx;
|
||
/* #endif */
|
||
|
||
.load-more {
|
||
text-align: center;
|
||
padding: 16rpx 0;
|
||
color: #999;
|
||
}
|
||
|
||
.message-item {
|
||
margin-bottom: 20rpx;
|
||
|
||
&.first-message {
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.user-message, .ai-message {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
|
||
.avatar {
|
||
width: 88rpx;
|
||
height: 88rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
box-shadow: $shadow-sm;
|
||
background-color: $bg-card;
|
||
border: 2rpx solid #f0f4f8;
|
||
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: 56rpx;
|
||
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: 56rpx;
|
||
background-color: $bg-card;
|
||
}
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
margin-left: 24rpx;
|
||
max-width: calc(100% - 96rpx);
|
||
min-width: 0;
|
||
width: 0; /* 添加这个属性防止flex元素溢出 */
|
||
|
||
.message-bubble {
|
||
padding: 24rpx 32rpx;
|
||
max-width: 100%;
|
||
word-break: break-word;
|
||
box-shadow: $shadow-md;
|
||
position: relative;
|
||
border: 1rpx solid #f0f4f8;
|
||
|
||
.message-text {
|
||
font-size: 28rpx;
|
||
line-height: 1.7;
|
||
position: relative;
|
||
z-index: 1;
|
||
letter-spacing: 0.8rpx;
|
||
}
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 22rpx;
|
||
color: $color-text-light;
|
||
margin-top: 12rpx;
|
||
opacity: 0.9;
|
||
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-dots {
|
||
display: flex;
|
||
margin-right: 16rpx;
|
||
|
||
.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: 26rpx;
|
||
}
|
||
}
|
||
|
||
.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: space-between;
|
||
display: flex;
|
||
align-items: baseline;
|
||
width: 100%;
|
||
position: relative;
|
||
|
||
.ai-nickname-text {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
.message-time {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
margin-top: -2rpx;
|
||
}
|
||
}
|
||
|
||
/* 用户消息特有样式 */
|
||
.user-message {
|
||
flex-direction: row-reverse;
|
||
|
||
.message-content {
|
||
margin-left: 0;
|
||
margin-right: 24rpx;
|
||
text-align: right;
|
||
|
||
.message-bubble {
|
||
background: #07c160 !important; /* 微信绿色 */
|
||
color: white !important;
|
||
border-radius: 18rpx 4rpx 18rpx 18rpx; /* 微信风格:右上角直角 */
|
||
box-shadow: 0 2rpx 8rpx rgba(7, 193, 96, 0.2) !important;
|
||
border: none !important;
|
||
min-height: auto;
|
||
height: auto;
|
||
padding: 16rpx 20rpx; /* 微信风格内边距 */
|
||
display: inline-block;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
line-height: 1.5;
|
||
text-align: left;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 12rpx;
|
||
right: -8rpx;
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
background: #07c160 !important; /* 与气泡同色 */
|
||
transform: rotate(45deg);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* AI消息特有样式 */
|
||
.ai-message {
|
||
.message-content {
|
||
/* 确保昵称在气泡上方的布局 */
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
|
||
.message-bubble {
|
||
background: white !important; /* 微信风格白色 */
|
||
color: black !important;
|
||
border-radius: 4rpx 18rpx 18rpx 18rpx; /* 微信风格:左上角直角 */
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1) !important;
|
||
border: 1rpx solid #e0e0e0 !important;
|
||
min-height: auto;
|
||
height: auto;
|
||
padding: 16rpx 20rpx; /* 微信风格内边距 */
|
||
display: inline-block;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
line-height: 1.5;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 12rpx;
|
||
left: -8rpx;
|
||
width: 16rpx;
|
||
height: 16rpx;
|
||
background: white !important; /* 与气泡同色 */
|
||
transform: rotate(45deg);
|
||
border: 1rpx solid #e0e0e0;
|
||
border-right: none;
|
||
border-top: none;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 输入区域 */
|
||
.input-area {
|
||
background: white; /* 白色 */
|
||
border-top: 1rpx solid #e5e5e5;
|
||
padding: 16rpx 24rpx;
|
||
/* 确保在微信小程序中紧贴底部 */
|
||
flex-shrink: 0;
|
||
/* 微信小程序安全区域适配 */
|
||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||
/* 确保输入区域正确显示 */
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 10;
|
||
/* #ifdef MP-WEIXIN */
|
||
/* 微信小程序中使用绝对定位,方便动态设置bottom */
|
||
/* #endif */
|
||
|
||
.input-tools {
|
||
display: flex;
|
||
margin-bottom: 16rpx;
|
||
align-items: flex-start;
|
||
|
||
/* 微信小程序专属样式:减少工具条下边距 */
|
||
/* #ifdef MP-WEIXIN */
|
||
margin-bottom: 0rpx;
|
||
/* #endif */
|
||
|
||
.tool-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background-color: #f2f2f2;
|
||
margin-right: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
.iconfont {
|
||
font-size: 32rpx;
|
||
color: #666;
|
||
}
|
||
}
|
||
}
|
||
|
||
.input-container {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
/* 微信小程序专属样式:让输入容器更紧凑 */
|
||
/* #ifdef MP-WEIXIN */
|
||
padding-top: 0rpx;
|
||
padding-bottom: 0rpx;
|
||
/* #endif */
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
min-height: 72rpx;
|
||
max-height: 200rpx;
|
||
background-color: white;
|
||
border-radius: 36rpx;
|
||
padding: 16rpx 24rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1.5;
|
||
border: 1rpx solid #e5e5e5;
|
||
color: #333;
|
||
margin-right: -50rpx;
|
||
&::placeholder {
|
||
color: #999;
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* Emoji按钮 */
|
||
.emoji-btn {
|
||
width: 89rpx;
|
||
height: 89rpx;
|
||
border-radius: 12rpx;
|
||
|
||
margin-left: 2rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
padding: 0;
|
||
left:43rpx;
|
||
|
||
.iconfont {
|
||
font-size: 36rpx;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
|
||
/* 发送按钮 - 微信聊天样式 */
|
||
.send-btn {
|
||
width: 120rpx;
|
||
height: 72rpx;
|
||
border-radius: 12rpx;
|
||
background-color: #07c160;
|
||
margin-left: 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
border: none;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
line-height: 1;
|
||
|
||
&.disabled {
|
||
background-color: #ccc;
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
|
||
/* 微信小程序专属样式:调整输入框和发送按钮高度 */
|
||
/* #ifdef MP-WEIXIN */
|
||
.message-input {
|
||
min-height: 64rpx;
|
||
padding: 12rpx 16rpx;
|
||
margin-top: 0rpx;
|
||
margin-bottom: 0rpx;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 120rpx;
|
||
height: 72rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 33rpx;
|
||
margin-top: 0rpx;
|
||
margin-bottom: 0rpx;
|
||
}
|
||
/* #endif */
|
||
|
||
/* H5专属样式:发送按钮为微信聊天样式 */
|
||
/* #ifdef H5 */
|
||
.send-btn {
|
||
width: 120rpx;
|
||
height: 72rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 28rpx;
|
||
}
|
||
/* #endif */
|
||
}
|
||
}
|
||
|
||
/* 弹窗容器:确保弹窗层级正确,不影响主布局 */
|
||
.popups-container {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
pointer-events: none; /* 让鼠标事件穿透容器,不影响主内容交互 */
|
||
z-index: 999; /* 基础弹窗层级 */
|
||
|
||
/* 子弹窗需要开启 pointer-events,否则点击无效 */
|
||
> .tools-popup,
|
||
> .voice-popup,
|
||
> .avatar-selector-popup,
|
||
> .nickname-editor-popup {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
/* 昵称编辑弹窗层级最高,避免被其他弹窗遮挡 */
|
||
> .nickname-editor-popup {
|
||
z-index: 1001;
|
||
}
|
||
}
|
||
|
||
// 昵称编辑弹窗样式(修复右侧溢出问题)
|
||
.nickname-editor-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1001;
|
||
}
|
||
|
||
.nickname-editor-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.nickname-editor-panel {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background-color: $bg-card;
|
||
border-radius: $radius-lg;
|
||
padding: 24rpx 32rpx; // 统一内边距
|
||
width: calc(100% - 80rpx); // 限制弹窗宽度(避免超出屏幕)
|
||
max-width: 520rpx; // 最大宽度(适配不同设备)
|
||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||
box-sizing: border-box; // 确保内边距不影响宽度
|
||
|
||
.nickname-editor-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24rpx;
|
||
border-bottom: 2rpx solid #f0f4f8;
|
||
padding-bottom: 16rpx;
|
||
|
||
text {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-main;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 28rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
}
|
||
|
||
.nickname-editor-content {
|
||
.nickname-input-container {
|
||
margin-bottom: 24rpx;
|
||
width: 100%; // 输入框容器占满弹窗宽度
|
||
box-sizing: border-box;
|
||
|
||
.nickname-input {
|
||
width: 100%;
|
||
height: 96rpx;
|
||
padding: 0 24rpx;
|
||
border-radius: $radius-lg;
|
||
border: 2rpx solid #f0f4f8;
|
||
font-size: 28rpx;
|
||
color: $color-text;
|
||
background-color: $bg-main;
|
||
transition: all 0.2s ease;
|
||
box-shadow: none;
|
||
box-sizing: border-box; // 确保输入框不超出容器
|
||
|
||
&:focus {
|
||
border-color: $color-primary;
|
||
box-shadow: 0 0 0 4rpx rgba(108, 142, 191, 0.1);
|
||
}
|
||
|
||
&::placeholder {
|
||
color: $color-text-light;
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
}
|
||
|
||
.nickname-tip {
|
||
font-size: 24rpx;
|
||
color: $color-text-light;
|
||
margin-bottom: 32rpx;
|
||
display: block;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.nickname-editor-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 16rpx;
|
||
|
||
.nickname-action-btn {
|
||
padding: 16rpx 32rpx;
|
||
border-radius: $radius-lg;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&.cancel {
|
||
background-color: $bg-main;
|
||
color: $color-text-light;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
}
|
||
|
||
&.confirm {
|
||
background-color: $color-primary;
|
||
color: white;
|
||
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.2);
|
||
|
||
&.disabled {
|
||
background-color: $color-primary-light;
|
||
opacity: 0.7;
|
||
box-shadow: none;
|
||
}
|
||
|
||
&:not(.disabled):hover {
|
||
background-color: #5a7cb0;
|
||
transform: translateY(-2rpx);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 工具面板 */
|
||
.tools-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 999;
|
||
|
||
.tools-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.tools-panel {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: $bg-card;
|
||
border-radius: $radius-lg $radius-lg 0 0;
|
||
padding: 32rpx;
|
||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||
|
||
.tools-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 32rpx;
|
||
border-bottom: 2rpx solid #f0f4f8;
|
||
padding-bottom: 16rpx;
|
||
|
||
text {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-main;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 28rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
}
|
||
|
||
.tools-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 24rpx;
|
||
|
||
.tool-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 24rpx 16rpx;
|
||
border-radius: $radius-md;
|
||
background-color: $bg-main;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
transform: translateY(-4rpx);
|
||
}
|
||
|
||
.tool-icon {
|
||
width: 88rpx;
|
||
height: 88rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-card;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 16rpx;
|
||
box-shadow: $shadow-sm;
|
||
|
||
.iconfont {
|
||
font-size: 48rpx;
|
||
color: $color-primary;
|
||
}
|
||
}
|
||
|
||
.tool-text {
|
||
font-size: 24rpx;
|
||
color: $color-text;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 语音输入面板 */
|
||
.voice-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 999;
|
||
|
||
.voice-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.voice-input-panel {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background-color: $bg-card;
|
||
border-radius: $radius-lg;
|
||
padding: 48rpx;
|
||
width: 60%;
|
||
max-width: 520rpx;
|
||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||
|
||
.voice-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 32rpx;
|
||
border-bottom: 2rpx solid #f0f4f8;
|
||
padding-bottom: 16rpx;
|
||
|
||
text {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-main;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 28rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
}
|
||
|
||
.voice-content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
margin-bottom: 48rpx;
|
||
|
||
.voice-wave {
|
||
width: 280rpx;
|
||
height: 160rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-bottom: 24rpx;
|
||
|
||
&.recording .wave-bar {
|
||
animation: wave 1.2s infinite ease-in-out;
|
||
}
|
||
|
||
.wave-bar {
|
||
width: 8rpx;
|
||
height: 40rpx;
|
||
margin: 0 4rpx;
|
||
background-color: $color-primary;
|
||
border-radius: 4rpx;
|
||
animation: none;
|
||
|
||
&:nth-child(1) { height: 60rpx; animation-delay: 0s; }
|
||
&:nth-child(2) { height: 90rpx; animation-delay: 0.1s; }
|
||
&:nth-child(3) { height: 50rpx; animation-delay: 0.2s; }
|
||
&:nth-child(4) { height: 120rpx; animation-delay: 0.3s; }
|
||
&:nth-child(5) { height: 70rpx; animation-delay: 0.4s; }
|
||
&:nth-child(6) { height: 100rpx; animation-delay: 0.5s; }
|
||
&:nth-child(7) { height: 80rpx; animation-delay: 0.6s; }
|
||
&:nth-child(8) { height: 40rpx; animation-delay: 0.7s; }
|
||
}
|
||
}
|
||
|
||
.voice-tip {
|
||
font-size: 28rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
|
||
.voice-actions {
|
||
display: flex;
|
||
justify-content: center;
|
||
|
||
.voice-btn {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 50%;
|
||
background-color: $color-primary;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 8rpx 24rpx rgba(108, 142, 191, 0.3);
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&:hover {
|
||
transform: scale(1.05);
|
||
background-color: #5a7cb0;
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 56rpx;
|
||
color: white;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 头像选择弹窗 */
|
||
.avatar-selector-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 1000;
|
||
|
||
.avatar-selector-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.avatar-selector-panel {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background-color: $bg-card;
|
||
border-radius: $radius-lg;
|
||
padding: 32rpx;
|
||
width: calc(100% - 80rpx);
|
||
max-width: 520rpx;
|
||
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
|
||
box-sizing: border-box;
|
||
|
||
.avatar-selector-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24rpx;
|
||
border-bottom: 2rpx solid #f0f4f8;
|
||
padding-bottom: 16rpx;
|
||
|
||
text {
|
||
font-size: 34rpx;
|
||
font-weight: 600;
|
||
color: $color-text;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 56rpx;
|
||
height: 56rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-main;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
border: none;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 28rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
}
|
||
|
||
.avatar-selector-content {
|
||
.current-avatar-preview {
|
||
width: 180rpx;
|
||
height: 180rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
margin: 0 auto 32rpx;
|
||
box-shadow: $shadow-md;
|
||
border: 4rpx solid #f0f4f8;
|
||
|
||
image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.avatar-selector-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
|
||
.avatar-select-action {
|
||
padding: 24rpx;
|
||
border-radius: $radius-lg;
|
||
font-size: 28rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16rpx;
|
||
transition: all 0.2s ease;
|
||
border: 2rpx solid #f0f4f8;
|
||
background-color: $bg-main;
|
||
color: $color-text;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
}
|
||
|
||
&.cancel {
|
||
background-color: #f8fafc;
|
||
border-color: #e9edf2;
|
||
|
||
&:hover {
|
||
background-color: #f0f4f8;
|
||
}
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 36rpx;
|
||
color: $color-primary;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 动画定义 */
|
||
@keyframes dotPulse {
|
||
0%, 100% {
|
||
opacity: 0.4;
|
||
transform: scale(0.8);
|
||
}
|
||
50% {
|
||
opacity: 1;
|
||
transform: scale(1.2);
|
||
}
|
||
}
|
||
|
||
@keyframes wave {
|
||
0%, 100% {
|
||
transform: scaleY(0.5);
|
||
opacity: 0.7;
|
||
}
|
||
50% {
|
||
transform: scaleY(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@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: 72rpx;
|
||
height: 72rpx;
|
||
}
|
||
|
||
.message-content {
|
||
margin-left: 16rpx;
|
||
max-width: calc(100% - 72rpx);
|
||
|
||
.message-bubble {
|
||
padding: 16rpx 24rpx;
|
||
|
||
.message-text {
|
||
font-size: 26rpx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.input-area {
|
||
padding: 16rpx 24rpx;
|
||
|
||
.input-container {
|
||
.message-input {
|
||
min-height: 72rpx;
|
||
padding: 16rpx 24rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.send-btn {
|
||
width: 120rpx;
|
||
height: 72rpx;
|
||
border-radius: 12rpx;
|
||
font-size: 33rpx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Emoji面板 */
|
||
.emoji-popup {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 999;
|
||
|
||
.emoji-mask {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.4);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.emoji-panel {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #fff;
|
||
border-radius: 24rpx 24rpx 0 0;
|
||
padding: 0;
|
||
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
|
||
max-height: 55vh;
|
||
|
||
.emoji-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16rpx 20rpx;
|
||
border-bottom: 1rpx solid #e5e5e5;
|
||
background-color: #f7f7f7;
|
||
|
||
text {
|
||
font-size: 30rpx;
|
||
font-weight: 500;
|
||
color: #333;
|
||
}
|
||
|
||
.close-btn {
|
||
width: 48rpx;
|
||
height: 48rpx;
|
||
border-radius: 50%;
|
||
background-color: transparent;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: none;
|
||
|
||
&:hover {
|
||
background-color: #e5e5e5;
|
||
}
|
||
|
||
text {
|
||
font-size: 36rpx;
|
||
color: #999;
|
||
}
|
||
}
|
||
}
|
||
|
||
.emoji-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(8, 1fr);
|
||
gap: 8rpx;
|
||
padding: 20rpx 16rpx;
|
||
overflow-y: auto;
|
||
max-height: 45vh;
|
||
|
||
.emoji-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 80rpx;
|
||
border-radius: 8rpx;
|
||
background-color: transparent;
|
||
transition: all 0.15s ease;
|
||
|
||
&:hover {
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
&:active {
|
||
background-color: #e5e5e5;
|
||
}
|
||
|
||
.emoji-text {
|
||
font-size: 48rpx;
|
||
line-height: 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 扩大按钮 */
|
||
.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);
|
||
backdrop-filter: blur(6rpx);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|