2827 lines
80 KiB
Vue
2827 lines
80 KiB
Vue
<template>
|
||
<view class="ai-chat-container">
|
||
<!-- 聊天消息列表 -->
|
||
<scroll-view
|
||
class="chat-messages"
|
||
scroll-y
|
||
:scroll-top="scrollTop"
|
||
@scroll="onScroll"
|
||
:scroll-with-animation="false">
|
||
|
||
<!-- 加载更多历史消息 -->
|
||
<view class="load-more" v-if="hasMoreHistory">
|
||
<ns-loading :down-text="loadingText" :is-rotate="true" />
|
||
</view>
|
||
|
||
<!-- 消息列表 -->
|
||
<view
|
||
v-for="(message, index) in messages"
|
||
:key="`msg-${message.id || message.timestamp}-${index}`"
|
||
class="message-item"
|
||
:class="[message.role, { 'first-message': index === 0 }]">
|
||
|
||
<!-- 用户消息 -->
|
||
<view v-if="message.role === 'user'" class="user-message">
|
||
<view class="avatar">
|
||
<!-- 绑定本地用户头像,支持实时更新 -->
|
||
<image :src="localUserAvatar" mode="aspectFill" />
|
||
</view>
|
||
<view class="message-content">
|
||
<!-- 新增:显示用户昵称 -->
|
||
<view class="message-nickname" v-if="localUserNickname">{{ localUserNickname }}</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">
|
||
<!-- 直接使用内部固定的AI头像 -->
|
||
<image :src="aiAvatar" mode="aspectFill" />
|
||
</view>
|
||
<view class="message-content">
|
||
<!-- AI昵称固定,不可修改 - 左移到气泡上方 -->
|
||
<view class="message-nickname ai-nickname">智能助手</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
|
||
:src="message.url"
|
||
:poster="message.cover"
|
||
:controls="true"
|
||
:autoplay="false"
|
||
class="video-element"
|
||
@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" />
|
||
</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 === 'product'" class="product-content">
|
||
<view class="product-card" @click="viewProduct(message)">
|
||
<view class="product-image">
|
||
<image :src="message.image" mode="aspectFill" />
|
||
</view>
|
||
<view class="product-info">
|
||
<text class="product-title">{{ message.title }}</text>
|
||
<text class="product-price color-base-text">¥{{ message.price }}</text>
|
||
<text class="product-desc">{{ message.description }}</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">
|
||
<view class="input-tools">
|
||
<button class="tool-btn" @click="showMoreTools">
|
||
<text class="iconfont icon-plus">📌</text>
|
||
</button>
|
||
<button class="tool-btn" @click="toggleVoiceInput">
|
||
<text class="iconfont" :class="voiceInputing ? 'icon-stop' : 'icon-voice'">🎤</text>
|
||
</button>
|
||
<!-- 更换用户头像按钮 -->
|
||
<button class="tool-btn" @click="openAvatarSelector">
|
||
<text class="iconfont icon-user">📷</text>
|
||
</button>
|
||
<!-- 新增:更换用户昵称按钮 -->
|
||
<button class="tool-btn" @click="openNicknameEditor">
|
||
<text class="iconfont icon-edit">✏️</text>
|
||
</button>
|
||
</view>
|
||
|
||
<view class="input-container">
|
||
<textarea
|
||
v-model="inputText"
|
||
class="message-input"
|
||
placeholder="请输入您的问题..."
|
||
:maxlength="500"
|
||
:auto-height="true"
|
||
:show-confirm-bar="false"
|
||
@confirm="sendMessage"
|
||
@input="onInput" />
|
||
|
||
<!-- 发送按钮 -->
|
||
<button
|
||
class="send-btn"
|
||
:class="{ disabled: !inputText.trim() }"
|
||
@click="sendMessage"
|
||
:disabled="!inputText.trim()">
|
||
<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>
|
||
|
||
<!-- 语音输入面板 -->
|
||
<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">
|
||
<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" />
|
||
</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" @click="takeAvatarWithCamera">
|
||
<text class="iconfont icon-camera"></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'
|
||
|
||
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,
|
||
hasMoreHistory: false,
|
||
loadingText: '加载更多历史消息',
|
||
voiceInputing: false,
|
||
audioContext: null,
|
||
currentAudio: null,
|
||
messageId: 0,
|
||
showToolsPanel: false,
|
||
enalbeVoicePanelShow: false,
|
||
streamingMessage: null,
|
||
streamInterval: null,
|
||
streamContent: '',
|
||
isStreaming: false,
|
||
// 固定AI头像
|
||
aiAvatar: '/static/images/ai-avatar.png',
|
||
// 本地用户头像(优先使用缓存,无缓存则用默认图)
|
||
localUserAvatar: '',
|
||
// 默认头像路径
|
||
defaultAvatar: '/static/images/default-avatar.png',
|
||
// 头像选择弹窗控制
|
||
showAvatarSelector: false,
|
||
// 新增:用户昵称相关
|
||
localUserNickname: '', // 当前用户昵称
|
||
defaultNickname: '我', // 默认昵称
|
||
showNicknameEditor: false, // 昵称编辑弹窗控制
|
||
tempNickname: '', // 昵称编辑临时值
|
||
// Dify API相关
|
||
currentConversationId: null,
|
||
isAIServiceAvailable: true,
|
||
aiServiceError: null,
|
||
isLoadingHistory: false, // 新增:标记历史加载状态
|
||
shouldScrollToBottom: false,
|
||
scrollTimer: null,
|
||
lastScrollTop: 0,
|
||
historyOffset: 0,
|
||
isFetchingHistory: false
|
||
}
|
||
},
|
||
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;
|
||
});
|
||
// 移除:this.historyOffset = 0;(避免覆盖初始历史的偏移量)
|
||
},
|
||
// 新增:组件销毁时清除防抖定时器
|
||
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() {
|
||
this.$nextTick(() => {
|
||
// 仅当不是加载历史、且需要滚底时,才执行滚底
|
||
if (!this.isLoadingHistory && this.shouldScrollToBottom) {
|
||
this.scrollToBottom()
|
||
// 滚底后重置状态,避免重复触发
|
||
this.shouldScrollToBottom = false
|
||
}
|
||
})
|
||
},
|
||
deep: true
|
||
}
|
||
},
|
||
methods: {
|
||
// 【新增:会话 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 {
|
||
const history = await aiService.getChatHistory();
|
||
if (!history || !Array.isArray(history.messages)) {
|
||
console.warn('历史记录为空或格式无效');
|
||
return;
|
||
}
|
||
|
||
this.messages = history.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 || '';
|
||
}
|
||
return {
|
||
id: msg.message_id || (Date.now() + Math.random() * 10000),
|
||
role: msg.role === 'user' ? 'user' : 'assistant',
|
||
content: content,
|
||
timestamp: msg.created_at,
|
||
actions: msg.role !== 'user' ? [
|
||
{ id: 1, text: '有帮助', type: 'like' },
|
||
{ id: 2, text: '没帮助', type: 'dislike' }
|
||
] : []
|
||
};
|
||
});
|
||
} catch (error) {
|
||
console.error('加载历史记录失败:', error);
|
||
aiService.clearConversationId();
|
||
this.clearConversationIdFromLocal();
|
||
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('album')
|
||
},
|
||
|
||
// 拍照上传头像
|
||
takeAvatarWithCamera() {
|
||
this.selectAvatar('camera')
|
||
},
|
||
|
||
// 统一头像选择方法
|
||
selectAvatar(sourceType) {
|
||
uni.chooseImage({
|
||
count: 1, // 仅选择1张
|
||
sizeType: ['compressed'], // 压缩图片
|
||
sourceType: [sourceType], // 来源:相册/相机
|
||
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.includes('deny')) {
|
||
uni.showToast({
|
||
title: '请开启相册/相机权限',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
} else {
|
||
uni.showToast({
|
||
title: '头像选择失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
})
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
// 新增:打开昵称编辑弹窗
|
||
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() {
|
||
if (!this.inputText.trim()) return
|
||
|
||
const userMessage = {
|
||
id: ++this.messageId,
|
||
role: 'user',
|
||
type: 'text',
|
||
content: this.inputText.trim(),
|
||
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服务获取回复
|
||
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.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.messages = this.messages.filter(msg => msg.type !== 'loading');
|
||
this.shouldScrollToBottom = true;
|
||
this.messages.push(streamMessage);
|
||
|
||
try {
|
||
// #ifdef H5
|
||
// ===== H5: 使用 POST + 流式 (fetch readable stream) =====
|
||
await aiService.sendHttpStream(
|
||
userMessage,
|
||
(chunk) => {
|
||
// 实时更新内容
|
||
streamMessage.content += chunk;
|
||
this.$forceUpdate(); // 强制更新视图
|
||
},
|
||
(completeResult) => {
|
||
// 流结束回调
|
||
let finalContent = '';
|
||
let convId = '';
|
||
|
||
if (typeof completeResult === 'string') {
|
||
finalContent = completeResult;
|
||
} else {
|
||
finalContent = completeResult.content || '';
|
||
convId = completeResult.conversation_id || '';
|
||
}
|
||
|
||
// 更新最终内容
|
||
streamMessage.isStreaming = false;
|
||
streamMessage.content = finalContent;
|
||
|
||
// 添加操作按钮
|
||
streamMessage.actions = [
|
||
{ id: 1, text: '有帮助', type: 'like' },
|
||
{ id: 2, text: '没帮助', type: 'dislike' }
|
||
];
|
||
|
||
// 保存会话 ID
|
||
if (convId) {
|
||
this.currentConversationId = convId;
|
||
aiService.setConversationId(convId);
|
||
this.saveConversationIdToLocal(convId);
|
||
}
|
||
|
||
// 触发事件
|
||
this.$emit('ai-response', streamMessage);
|
||
}
|
||
);
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
// ===== 微信小程序: 使用 WebSocket =====
|
||
await aiService.sendStreamMessage(
|
||
userMessage,
|
||
(chunk) => {
|
||
// 实时更新内容
|
||
streamMessage.content += chunk;
|
||
this.$forceUpdate();
|
||
},
|
||
(completeResult) => {
|
||
// 流结束回调
|
||
let finalContent = completeResult?.content || '';
|
||
let convId = completeResult?.conversation_id || '';
|
||
|
||
// 更新最终内容
|
||
streamMessage.isStreaming = false;
|
||
streamMessage.content = finalContent;
|
||
|
||
// 添加操作按钮
|
||
streamMessage.actions = [
|
||
{ id: 1, text: '有帮助', type: 'like' },
|
||
{ id: 2, text: '没帮助', type: 'dislike' }
|
||
];
|
||
|
||
// 保存会话 ID
|
||
if (convId) {
|
||
this.currentConversationId = convId;
|
||
aiService.setConversationId(convId);
|
||
this.saveConversationIdToLocal(convId);
|
||
}
|
||
|
||
// 触发事件
|
||
this.$emit('ai-response', streamMessage);
|
||
}
|
||
);
|
||
// #endif
|
||
} catch (error) {
|
||
console.error('流式请求失败:', error);
|
||
// 移除流式消息
|
||
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) {
|
||
const date = new Date(timestamp)
|
||
const now = new Date()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) { // 1分钟内
|
||
return '刚刚'
|
||
} else if (diff < 3600000) { // 1小时内
|
||
return Math.floor(diff / 60000) + '分钟前'
|
||
} else if (diff < 86400000) { // 1天内
|
||
return Math.floor(diff / 3600000) + '小时前'
|
||
} else {
|
||
return date.getMonth() + 1 + '月' + date.getDate() + '日 ' +
|
||
date.getHours().toString().padStart(2, '0') + ':' +
|
||
date.getMinutes().toString().padStart(2, '0')
|
||
}
|
||
},
|
||
|
||
// 格式化文件大小
|
||
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')
|
||
},
|
||
|
||
// 滚动到底部
|
||
scrollToBottom() {
|
||
setTimeout(() => {
|
||
this.scrollTop = 999999
|
||
}, 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 => ({
|
||
id: msg.id || `history-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||
role: msg.role === 'user' ? 'user' : 'ai',
|
||
type: 'text',
|
||
content: msg.content,
|
||
timestamp: msg.create_time * 1000
|
||
}));
|
||
|
||
// 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
|
||
},
|
||
|
||
// 显示语音面板
|
||
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 => {
|
||
// 语音输入处理
|
||
console.log('语音输入开始')
|
||
})
|
||
.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)
|
||
}
|
||
},
|
||
|
||
// 视频播放
|
||
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)
|
||
},
|
||
|
||
// 查看商品
|
||
viewProduct(message) {
|
||
this.$emit('product-view', 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)
|
||
},
|
||
|
||
// 选择图片
|
||
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)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
</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: white; /* 白色 */
|
||
background-size: 200% 200%;
|
||
animation: gradient-flow 15s ease infinite;
|
||
/* 原有样式保持不变 */
|
||
overflow: hidden;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
|
||
.chat-messages {
|
||
flex: 1;
|
||
padding: 24rpx 32rpx;
|
||
overflow-y: auto;
|
||
box-sizing: border-box;
|
||
min-height: 0; /* 重要:防止flex元素溢出 */
|
||
|
||
.load-more {
|
||
text-align: center;
|
||
padding: 24rpx 0;
|
||
color: $color-text-light;
|
||
}
|
||
|
||
.message-item {
|
||
margin-bottom: 32rpx;
|
||
|
||
&.first-message {
|
||
margin-top: 24rpx;
|
||
}
|
||
|
||
.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;
|
||
|
||
&:hover {
|
||
transform: scale(1.03);
|
||
}
|
||
|
||
image {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
}
|
||
|
||
.message-content {
|
||
flex: 1;
|
||
margin-left: 24rpx;
|
||
max-width: calc(100% - 112rpx);
|
||
min-width: 0;
|
||
width: 0; /* 添加这个属性防止flex元素溢出 */
|
||
|
||
.message-nickname {
|
||
font-size: 24rpx;
|
||
color: $color-text-light;
|
||
margin-bottom: 8rpx;
|
||
letter-spacing: 0.5rpx;
|
||
// 用户昵称右对齐,AI昵称左对齐
|
||
&:not(.ai-message .message-nickname) {
|
||
text-align: right;
|
||
}
|
||
// AI昵称专属样式 - 左移到气泡上方
|
||
&.ai-nickname {
|
||
text-align: left;
|
||
margin-left: 0;
|
||
padding-left: 0;
|
||
align-self: flex-start;
|
||
margin-bottom: 4rpx;
|
||
}
|
||
}
|
||
|
||
.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;
|
||
|
||
.action-btn {
|
||
border-radius: $radius-lg;
|
||
font-size: 24rpx;
|
||
background-color: $bg-card;
|
||
padding: 12rpx 28rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
transition: all 0.2s ease;
|
||
border: 1rpx solid #f0f4f8;
|
||
color: $color-text;
|
||
|
||
&.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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 用户消息特有样式 */
|
||
.user-message {
|
||
flex-direction: row-reverse;
|
||
|
||
.message-content {
|
||
margin-left: 0;
|
||
margin-right: 24rpx;
|
||
text-align: right;
|
||
|
||
.message-bubble {
|
||
background: #c4e0ff !important; /* 浅蓝色 */
|
||
color: black !important;
|
||
border-radius: 16rpx 16rpx 4rpx 16rpx; /* 右对齐气泡尖角适配 */
|
||
box-shadow: 0 8rpx 20rpx rgba(196, 224, 255, 0.3) !important;
|
||
border: none !important;
|
||
/* ✅ 关键:允许内容撑开高度 */
|
||
min-height: auto;
|
||
height: auto;
|
||
padding: 24rpx 32rpx; /* 保留内边距 */
|
||
display: inline-block; /* 让宽度也随内容收缩(可选) */
|
||
max-width: 80%; /* 防止过宽 */
|
||
word-break: break-word;
|
||
white-space: pre-wrap; /* 保留用户输入的换行符 */
|
||
line-height: 1.6;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 20rpx;
|
||
right: -14rpx;
|
||
width: 24rpx;
|
||
height: 24rpx;
|
||
background: #ffffff !important; /* 菱形改为白色 */
|
||
border-radius: 6rpx;
|
||
transform: rotate(45deg);
|
||
box-shadow: 4rpx -4rpx 4rpx rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 50%;
|
||
height: 100%;
|
||
background: linear-gradient(
|
||
to right,
|
||
rgba(255, 255, 255, 0) 0%,
|
||
rgba(255, 255, 255, 0.15) 100%
|
||
);
|
||
animation: shine 4s infinite linear;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* AI消息特有样式 */
|
||
.ai-message {
|
||
.message-content {
|
||
/* 确保昵称在气泡上方的布局 */
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
|
||
.message-bubble {
|
||
background: white !important; /* 白色 */
|
||
color: black !important;
|
||
border-radius: 16rpx 16rpx 16rpx 4rpx; /* 左对齐气泡尖角适配 */
|
||
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.1) !important;
|
||
border: 1rpx solid #e0e0e0 !important;
|
||
min-height: auto;
|
||
height: auto;
|
||
padding: 24rpx 32rpx;
|
||
display: inline-block;
|
||
max-width: 80%;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
line-height: 1.6;
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 20rpx;
|
||
left: -14rpx;
|
||
width: 24rpx;
|
||
height: 24rpx;
|
||
background: #ffffff !important; /* 菱形改为白色 */
|
||
border-radius: 6rpx;
|
||
transform: rotate(45deg);
|
||
box-shadow: -4rpx 4rpx 4rpx rgba(0, 0, 0, 0.05);
|
||
border: 1rpx solid #f0f4f8;
|
||
border-right: none;
|
||
border-top: none;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 输入区域 */
|
||
.input-area {
|
||
background: #c4e0ff; /* 浅蓝色 */
|
||
border-top: 2rpx solid #f0f4f8;
|
||
padding: 24rpx 32rpx;
|
||
/* 确保在微信小程序中紧贴底部 */
|
||
flex-shrink: 0;
|
||
/* 微信小程序安全区域适配 */
|
||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||
/* 确保输入区域正确显示 */
|
||
position: relative;
|
||
z-index: 10;
|
||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||
|
||
.input-tools {
|
||
display: flex;
|
||
margin-bottom: 16rpx;
|
||
|
||
.tool-btn {
|
||
width: 64rpx;
|
||
height: 64rpx;
|
||
border-radius: 50%;
|
||
background-color: $bg-main;
|
||
margin-right: 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
|
||
&:hover {
|
||
background-color: #e8edf3;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.iconfont {
|
||
font-size: 34rpx;
|
||
color: $color-text-light;
|
||
}
|
||
}
|
||
}
|
||
|
||
.input-container {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
min-height: 88rpx;
|
||
max-height: 220rpx;
|
||
background-color: $bg-main;
|
||
border-radius: $radius-lg;
|
||
padding: 24rpx 32rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
border: 1rpx solid #e8edf3;
|
||
transition: all 0.2s ease;
|
||
color: $color-text;
|
||
|
||
&:focus {
|
||
background-color: $bg-card;
|
||
border-color: $color-primary-light;
|
||
box-shadow: 0 0 0 4rpx rgba(138, 159, 184, 0.1);
|
||
}
|
||
|
||
&::placeholder {
|
||
color: $color-text-light;
|
||
opacity: 0.8;
|
||
}
|
||
}
|
||
|
||
/* 发送按钮 */
|
||
.send-btn {
|
||
width: 112rpx;
|
||
height: 76rpx;
|
||
border-radius: $radius-lg;
|
||
background-color: $color-primary;
|
||
margin-left: 16rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.2);
|
||
transition: all 0.2s ease;
|
||
color: white;
|
||
font-size: 28rpx;
|
||
font-weight: 500;
|
||
border: none;
|
||
text-align: center; /* 兼容多端文字居中 */
|
||
white-space: nowrap; /* 强制文字单行横向显示 */
|
||
line-height: 1; /* 重置行高,避免文字垂直偏移 */
|
||
top:-10px;
|
||
|
||
&.disabled {
|
||
background-color: $color-primary-light;
|
||
box-shadow: none;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
&:not(.disabled):hover {
|
||
transform: scale(1.05);
|
||
background-color: #5a7cb0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 弹窗容器:确保弹窗层级正确,不影响主布局 */
|
||
.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;
|
||
}
|
||
}
|
||
|
||
/* 响应式适配 */
|
||
@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: 96rpx;
|
||
height: 68rpx;
|
||
font-size: 26rpx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|