Files
lucky_shop/pages_tool/ai-chat/ai-chat-message.vue

3786 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="ai-chat-container">
<!-- 聊天消息列表 - 统一使用 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 }); // 或 redirectTo 首页
}, 1000);
return; // 关键:阻止后续初始化
}
// 优先读取本地缓存的会话 ID
const localConvId = this.getConversationIdFromLocal();
if (localConvId) {
this.currentConversationId = localConvId;
aiService.setConversationId(localConvId);
} else {
this.currentConversationId = aiService.getConversationId();
}
// 加载初始历史后,同步historyOffset
this.loadChatHistoryIfExist().then(() => {
// 初始历史条数作为偏移量
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.showLoadMore) {
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' : 'assistant',
content: content,
timestamp: realTimestamp,
actions: msg.role !== 'user' ? [
{ id: 1, text: '有帮助', type: 'like' },
{ id: 2, text: '没帮助', type: 'dislike' }
] : []
};
}).filter(msg => msg.content.trim() !== ''); // 过滤掉内容为空的消息
} 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 {
// 检查AI服务状态
const status = await aiService.getServiceStatus()
this.isAIServiceAvailable = status.available
if (!this.isAIServiceAvailable) {
throw new Error('AI服务暂不可用')
}
// 使用AI服务获取回复
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;
},
(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;
},
(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 ''
// 简单的Markdown解析
let html = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // 粗体
.replace(/\*(.*?)\*/g, '<em>$1</em>') // 斜体
.replace(/`(.*?)`/g, '<code>$1</code>') // 代码
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>') // 链接
.replace(/\n/g, '<br>') // 换行
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.showLoadMore || !this.currentConversationId || this.isLoadingHistory) {
return;
}
this.isFetchingHistory = true;
this.isLoadingHistory = true;
this.loadingText = '加载历史消息中...';
this.hasMoreHistory = true;
try {
// 关键修改:用独立的historyOffset作为偏移量,不再依赖messages.length
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
};
});
// 1. 记录新增消息数量
const newMsgCount = historyMessages.length;
// 2. 添加到消息列表开头
this.messages = [...historyMessages, ...this.messages];
// 3. 更新历史偏移量(累加,不是覆盖)
this.historyOffset += newMsgCount;
// 4. 等待DOM渲染后,精准计算滚动位置
await this.$nextTick();
// 获取新增消息的实际DOM高度
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); // 加上消息间距(32rpx)
}).exec();
}));
}
// 计算总高度
const heights = await Promise.all(heightPromises);
const totalNewHeight = heights.reduce((sum, h) => sum + h, 0);
// 5. 设置scrollTop,保持滚动条在顶部附近
this.scrollTop = totalNewHeight;
} else {
// 无更多历史
this.showLoadMore = false;
}
} catch (error) {
console.error('加载历史失败:', error);
uni.showToast({ title: '加载历史失败', icon: 'none' });
this.showLoadMore = false;
} finally {
this.isLoadingHistory = false;
this.isFetchingHistory = false;
this.hasMoreHistory = 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.$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% - 112rpx);
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;
max-width: 70%; /* 微信风格宽度 */
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;
max-width: 70%; /* 微信风格宽度 */
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% - 88rpx);
.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>