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

2571 lines
72 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-view
class="chat-messages"
scroll-y
:scroll-top="scrollTop"
@scrolltoupper="loadMoreHistory"
:scroll-with-animation="true">
<!-- 加载更多历史消息 -->
<view class="load-more" v-if="hasMoreHistory">
<ns-loading :down-text="loadingText" :is-rotate="true" />
</view>
<!-- 消息列表 -->
<view
v-for="(message, index) in messages"
:key="message.id"
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-bubble">
<text class="message-text">{{ message.content }}</text>
</view>
<view class="message-time">{{ formatTime(message.timestamp) }}</view>
</view>
</view>
<!-- AI消息 -->
<view v-else class="ai-message">
<view class="avatar">
<!-- 直接使用内部固定的AI头像 -->
<image :src="aiAvatar" mode="aspectFill" />
</view>
<view class="message-content">
<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 class="message-time">{{ formatTime(message.timestamp) }}</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>
</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>
</template>
<script>
import nsLoading from '@/components/ns-loading/ns-loading.vue'
import aiService from '@/common/js/ai-service.js'
export default {
name: 'ai-chat-message',
components: {
nsLoading
},
props: {
// 初始消息列表
initialMessages: {
type: Array,
default: () => []
},
// 初始用户头像(外部传入,可选)
initUserAvatar: {
type: String,
default: ''
},
// 是否显示加载更多
showLoadMore: {
type: Boolean,
default: true
},
// 最大消息数量
maxMessages: {
type: Number,
default: 100
},
// 是否启用流式响应
enableStreaming: {
type: Boolean,
default: false
},
// 流式响应速度(字符/秒)
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,
// Dify API相关
currentConversationId: null,
isAIServiceAvailable: true,
aiServiceError: null
}
},
created() {
this.messages = [...this.initialMessages]
this.messageId = this.messages.length
this.initAudioContext()
// 初始化用户头像(缓存优先)
this.initUserAvatarData()
},
mounted() {
this.scrollToBottom()
},
watch: {
messages: {
handler() {
this.$nextTick(() => {
this.scrollToBottom()
})
},
deep: true
}
},
methods: {
// 初始化音频上下文
initAudioContext() {
// #ifdef H5
if (window.AudioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
// #endif
},
// 初始化用户头像(支持缓存持久化)
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
}
},
// 打开头像选择弹窗
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
})
}
}
})
},
// 发送消息
async sendMessage() {
if (!this.inputText.trim()) return
const userMessage = {
id: ++this.messageId,
role: 'user',
type: 'text',
content: this.inputText.trim(),
timestamp: Date.now()
}
this.messages.push(userMessage)
this.inputText = ''
// 显示AI正在输入
const loadingMessage = {
id: ++this.messageId,
role: 'ai',
type: 'loading',
content: '',
timestamp: Date.now()
}
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.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
}
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.messages.push(aiMessage)
this.$emit('ai-response', aiMessage)
},
// 发送流式消息
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.messages.push(streamMessage)
// 开始流式响应
await aiService.sendStreamMessage(
userMessage,
// 流式数据回调
(chunk) => {
streamMessage.content += chunk
// 触发内容更新
this.$forceUpdate()
},
// 完成回调
(completeContent) => {
streamMessage.isStreaming = false
streamMessage.content = completeContent
// 添加操作按钮
streamMessage.actions = [
{ id: 1, text: '有帮助', type: 'like' },
{ id: 2, text: '没帮助', type: 'dislike' }
]
this.$emit('ai-response', streamMessage)
}
)
},
// 生成AI回复模拟
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)
},
// 加载更多历史消息
loadMoreHistory() {
if (!this.showLoadMore) return
this.hasMoreHistory = true
this.loadingText = '加载历史消息中...'
// 模拟加载历史消息
setTimeout(() => {
const historyMessages = [
{
id: --this.messageId,
role: 'ai',
type: 'text',
content: '这是历史消息1',
timestamp: Date.now() - 86400000
},
{
id: --this.messageId,
role: 'user',
type: 'text',
content: '这是历史消息2',
timestamp: Date.now() - 86400000
}
]
this.messages = [...historyMessages, ...this.messages]
this.hasMoreHistory = false
this.$emit('history-loaded', historyMessages)
}, 1000)
},
// 显示更多工具
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: linear-gradient(135deg, #fde6f7 0%, #f8b7e8 20%, #c4e0ff 50%, #8cb4ff 80%, #ffffff 100%);
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;
text-align: right; // 与用户消息右对齐
letter-spacing: 0.5rpx;
}
.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: linear-gradient(135deg, #ffadd2, #f783ac) !important; /* 粉色渐变 */
color: white !important;
border-radius: 16rpx 16rpx 4rpx 16rpx; /* 右对齐气泡尖角适配 */
box-shadow: 0 8rpx 20rpx rgba(247, 131, 172, 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: inherit;
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 {
.message-bubble {
background: linear-gradient(135deg, #dbeafe, #bfdbfe) !important; /* 蓝色浅渐变 */
color: #2c3e50 !important;
border-radius: 16rpx 16rpx 16rpx 4rpx; /* 左对齐气泡尖角适配 */
box-shadow: 0 8rpx 20rpx rgba(191, 219, 254, 0.4) !important;
border: 1rpx solid #dbeafe !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: $bg-card;
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: linear-gradient(135deg, #fde6f7 0%, #f8b7e8 20%, #c4e0ff 50%, #8cb4ff 80%, #ffffff 100%);
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;
top: -17px;
&.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: 32rpx;
width: 580rpx;
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
.nickname-editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 24rpx;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 64rpx;
height: 64rpx;
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: 34rpx;
color: $color-text-light;
}
}
}
.nickname-editor-content {
.nickname-input-container {
margin-bottom: 24rpx;
.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;
&: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;
text-align: center;
}
.nickname-editor-actions {
display: flex;
gap: 24rpx;
.nickname-action-btn {
flex: 1;
height: 96rpx;
border-radius: $radius-lg;
font-size: 28rpx;
font-weight: 500;
transition: all 0.2s ease;
&.cancel {
background-color: $bg-card;
border: 2rpx solid #f0f4f8;
color: $color-text-light;
&:hover {
background-color: #f8fafc;
}
}
&.confirm {
background-color: $color-primary;
color: white;
box-shadow: 0 4rpx 12rpx rgba(108, 142, 191, 0.2);
&:hover {
background-color: #5a7cb0;
transform: translateY(-2rpx);
}
&.disabled {
background-color: $color-primary-light;
box-shadow: none;
opacity: 0.7;
transform: none;
}
}
}
}
}
}
/* 弹窗样式基础 */
.tools-popup, .voice-popup, .avatar-selector-popup {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.tools-mask, .voice-mask, .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);
}
/* 工具面板样式 */
.tools-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: $bg-card;
border-radius: $radius-lg $radius-lg 0 0;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.1);
.tools-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 64rpx;
height: 64rpx;
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: 34rpx;
color: $color-text-light;
}
}
}
.tools-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 32rpx;
gap: 32rpx;
.tool-item {
display: flex;
flex-direction: column;
align-items: center;
transition: all 0.3s ease;
&:hover {
transform: translateY(-4rpx);
}
.tool-icon {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background-color: $bg-main;
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: 26rpx;
color: $color-text;
}
}
}
}
/* 语音输入面板样式 */
.voice-input-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: $bg-card;
border-radius: $radius-lg $radius-lg 0 0;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.1);
.voice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 64rpx;
height: 64rpx;
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: 34rpx;
color: $color-text-light;
}
}
}
.voice-content {
padding: 64rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
.voice-wave {
width: 240rpx;
height: 240rpx;
border-radius: 50%;
background-color: $bg-main;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
box-shadow: $shadow-md;
.wave-bar {
width: 12rpx;
height: 40rpx;
background-color: $color-primary-light;
border-radius: 6rpx;
margin: 0 8rpx;
transition: height 0.3s ease;
}
&.recording {
.wave-bar {
animation: waveAnimation 1.2s infinite ease-in-out;
}
}
}
.voice-tip {
font-size: 28rpx;
color: $color-text;
}
}
.voice-actions {
padding: 32rpx;
display: flex;
justify-content: center;
.voice-btn {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background-color: $color-user;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(134, 117, 169, 0.3);
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
background-color: #756298;
}
.iconfont {
font-size: 56rpx;
color: white;
}
}
}
}
/* 头像选择弹窗样式 */
.avatar-selector-panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: $bg-card;
border-radius: $radius-lg;
padding: 32rpx;
width: 580rpx;
box-shadow: 0 8rpx 36rpx rgba(0, 0, 0, 0.15);
.avatar-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
border-bottom: 2rpx solid #f0f4f8;
padding-bottom: 24rpx;
text {
font-size: 34rpx;
font-weight: 600;
color: $color-text;
}
.close-btn {
width: 64rpx;
height: 64rpx;
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: 34rpx;
color: $color-text-light;
}
}
}
.avatar-selector-content {
.current-avatar-preview {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
overflow: hidden;
margin: 0 auto 48rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
border: 8rpx solid $bg-main;
image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-selector-actions {
display: flex;
flex-direction: column;
gap: 24rpx;
.avatar-select-action {
height: 96rpx;
border-radius: $radius-lg;
background-color: $bg-main;
font-size: 28rpx;
color: $color-text;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
transition: all 0.2s ease;
border: 2rpx solid #f0f4f8;
&:hover {
background-color: #e8edf3;
}
&.cancel {
background-color: $bg-card;
color: $color-text-light;
&:hover {
background-color: #f8fafc;
}
}
.iconfont {
font-size: 36rpx;
color: $color-primary;
}
}
}
}
}
/* 动画定义 */
@keyframes dotPulse {
0%, 100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.3);
opacity: 1;
}
}
@keyframes shine {
0% {
left: -100%;
}
100% {
left: 200%;
}
}
@keyframes waveAnimation {
0%, 100% {
height: 40rpx;
background-color: $color-primary-light;
}
50% {
height: 120rpx;
background-color: $color-primary;
}
}
/* 新增:渐变流动动画(仅为背景增加动效,不影响其他样式) */
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 富文本样式补充 */
rich-text {
line-height: 1.8;
font-size: 26rpx;
color: $color-text;
display: block;
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;
}
br {
display: block;
height: 24rpx;
}
}
/* 适配小程序和H5的按钮默认样式重置 */
button {
padding: 0;
margin: 0;
border: none;
background: none;
line-height: normal;
font-weight: normal;
outline: none;
&::after {
border: none;
}
}
/* 滑块样式优化 */
slider {
height: 12rpx;
&::before {
height: 12rpx;
border-radius: 6rpx;
}
&::after {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
background-color: $color-primary;
border: none;
box-shadow: 0 2rpx 8rpx rgba(108, 142, 191, 0.3);
}
}
/* 输入框聚焦样式优化 */
input:focus, textarea:focus {
outline: none;
}
/* 响应式适配:小屏幕设备 */
@media (max-width: 375px) {
.ai-chat-container {
padding-bottom: calc(env(safe-area-inset-bottom) + 16rpx);
}
.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);
}
.user-message .message-content {
margin-right: 16rpx;
}
}
.message-bubble {
padding: 20rpx 24rpx !important;
}
.message-text {
font-size: 26rpx !important;
}
.input-area {
padding: 16rpx 24rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
}
.input-tools .tool-btn {
width: 56rpx;
height: 56rpx;
margin-right: 12rpx;
}
.input-container .message-input {
min-height: 76rpx;
padding: 20rpx 24rpx;
font-size: 26rpx;
}
.send-btn {
width: 96rpx;
height: 68rpx;
font-size: 26rpx;
}
.avatar-selector-panel, .nickname-editor-panel {
width: 90vw;
padding: 24rpx;
}
.avatar-selector-content .current-avatar-preview {
width: 160rpx;
height: 160rpx;
margin-bottom: 32rpx;
}
.voice-input-panel .voice-content .voice-wave {
width: 200rpx;
height: 200rpx;
}
}
/* 深色模式适配(可选) */
@media (prefers-color-scheme: dark) {
$bg-main: #1e2128;
$bg-card: #2d3139;
$color-text: #e5e7eb;
$color-text-light: #9ca3af;
$color-primary: #8197c3;
$color-primary-light: #9aa7c0;
$color-user: #a594cc;
.ai-chat-container {
background-color: $bg-main;
}
.message-bubble {
border-color: #3d4149;
}
.ai-message .message-bubble::before {
background: $bg-card;
border-color: #3d4149;
}
.input-area {
background-color: $bg-card;
border-top-color: #3d4149;
}
.input-tools .tool-btn,
.tools-header .close-btn,
.voice-header .close-btn,
.avatar-selector-header .close-btn,
.nickname-editor-header .close-btn {
background-color: $bg-main;
}
.message-input,
.nickname-input {
background-color: $bg-main;
border-color: #3d4149;
color: $color-text;
}
.tools-panel, .voice-input-panel, .avatar-selector-panel, .nickname-editor-panel {
background-color: $bg-card;
}
.tools-header, .voice-header, .avatar-selector-header, .nickname-editor-header {
border-bottom-color: #3d4149;
}
.tool-item .tool-icon,
.voice-wave {
background-color: $bg-main;
}
.avatar-select-action,
.nickname-action-btn.cancel {
background-color: $bg-main;
border-color: #3d4149;
}
}
}
</style>