Files
msh-system/msh_single_uniapp/pages/tool/ai-nutritionist.vue

1361 lines
31 KiB
Vue
Raw Normal View History

<template>
<view class="ai-chat-page">
<!-- 顶部区域 -->
<view class="header-container">
<!-- 宣传横幅 -->
<view class="promo-banner">
<view class="promo-content">
<view class="promo-left">
<text class="promo-title">慢生活守护健康</text>
<text class="promo-sparkle"></text>
</view>
<text class="promo-subtitle">营养师专家入驻在线答疑</text>
</view>
<view class="promo-right">
<view class="tts-toggle-btn" @click="toggleTTS" :class="{ active: ttsEnabled }">
<text class="tts-toggle-icon">🔊</text>
<!--<text class="tts-toggle-text">{{ ttsEnabled ? '播报开' : '播报关' }}</text>-->
</view>
<view class="clear-btn" @click="clearChat">
<text class="clear-icon">🗑</text>
</view>
<image class="promo-avatar" src="https://uthink2025.oss-cn-shanghai.aliyuncs.com//crmebimage/public/content/2026/01/11/afcaba68d00b4fccaa49ad2a42c78e7fkk03hqv5vl.png" mode="aspectFit"></image>
</view>
</view>
</view>
<!-- 聊天消息列表 -->
<scroll-view
class="chat-container"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
>
<view class="message-list">
<!-- AI欢迎消息 -->
<view class="message-item ai-message">
<view class="message-avatar">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-sender">AI营养师</text>
</view>
<view class="message-bubble ai-bubble">
<text class="message-text">{{ welcomeMessage }}</text>
</view>
</view>
</view>
<!-- 消息列表 -->
<view
v-for="(msg, index) in messageList"
:key="index"
:class="['message-item', msg.role === 'user' ? 'user-message' : 'ai-message']"
>
<!-- AI头像 -->
<view class="message-avatar" v-if="msg.role === 'ai'">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<!-- AI发送者名称 -->
<view class="message-header" v-if="msg.role === 'ai'">
<text class="message-sender">AI营养师</text>
</view>
<!-- 消息气泡 -->
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
<!-- AI 消息 loading 占位等待回复时显示打字动画-->
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}<text v-if="msg.streaming" class="streaming-cursor">|</text></text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
</view>
<!-- AI 回复播放按钮 -->
<view
v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'"
class="tts-play-btn"
@click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)"
>
<text class="tts-play-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
</view>
</view>
<!-- 加载中提示仅在没有流式占位消息时显示 -->
<view v-if="isLoading && !messageList.some(m => m.loading || m.streaming)" class="message-item ai-message">
<view class="message-avatar">
<text class="avatar-icon">🤖</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-sender">AI营养师</text>
</view>
<view class="message-bubble ai-bubble">
<view class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 快捷问题区域 -->
<view class="quick-questions">
<view class="quick-btn" @click="sendQuickQuestion('透析患者可以喝牛奶吗?')">
<text>透析患者可以喝牛奶吗</text>
</view>
<view class="quick-btn" @click="sendQuickQuestion('什么食物含磷比较低?')">
<text>什么食物含磷比较低</text>
</view>
</view>
<!-- 输入区域 -->
<view class="input-container">
<!-- 图片预览区域 -->
<view class="image-preview-area" v-if="pendingImages.length > 0">
<view class="preview-item" v-for="(img, index) in pendingImages" :key="index">
<image :src="img.path" mode="aspectFill" class="preview-img"></image>
<view class="delete-btn" @click="removeImage(index)">
<text class="delete-icon">×</text>
</view>
</view>
<!-- 继续上传按钮 -->
<view class="preview-item add-btn" @click="chooseImage" v-if="pendingImages.length < 3">
<text class="add-icon">+</text>
</view>
</view>
<view class="input-wrapper">
<!-- 照片上传按钮 -->
<view class="action-btn" @click="chooseImage">
<image class="action-icon-svg" src="/static/images/icon-camera.svg" mode="aspectFit"></image>
</view>
<!-- 语音切换按钮 -->
<view class="action-btn" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" class="action-icon-svg mic" src="/static/images/icon-mic.svg" mode="aspectFit"></image>
<text v-else class="action-icon"></text>
</view>
<!-- 文本输入框 -->
<input
v-if="!isVoiceMode"
class="chat-input"
v-model="inputText"
placeholder="输入您的问题..."
placeholder-style="color: #9fa5c0"
confirm-type="send"
@confirm="sendMessage"
:focus="inputFocus"
@focus="onInputFocus"
@blur="onInputBlur"
/>
<!-- 语音按住按钮 -->
<view
v-else
class="voice-hold-btn"
:class="{ recording: isRecording }"
@touchstart="startRecord"
@touchend="stopRecord"
>
<text>{{ isRecording ? '松开 结束' : '按住 说话' }}</text>
</view>
<!-- 发送按钮 -->
<view
class="send-btn"
:class="{ active: inputText.trim().length > 0 || pendingImages.length > 0 }"
@click="sendMessage"
>
<image
v-if="inputText.trim().length > 0 || pendingImages.length > 0"
class="send-icon-svg"
src="/static/images/icon-send-active.svg"
mode="aspectFit"
></image>
<image
v-else
class="send-icon-svg"
src="/static/images/icon-send.svg"
mode="aspectFit"
></image>
</view>
</view>
</view>
</view>
</template>
<script>
import api from '@/api/models-api.js';
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters(['userInfo','uid'])
},
data() {
return {
botId: '7591133240535449654',
conversationId: '',
scrollTop: 0,
lastScrollTop: 0, // 用于动态滚动
inputText: '',
inputFocus: false,
isLoading: false,
isVoiceMode: false,
isRecording: false,
recorderManager: null,
recordDuration: 0,
recordTimer: null,
pendingImages: [], // 待发送图片
welcomeMessage: `👋您好我是您的AI营养师助手。
我可以帮您
解答饮食疑问
评估食物适宜性
提供烹饪建议
解读检验报告
有什么想问的吗`,
messageList: [],
// TTS
ttsEnabled: false,
ttsPlaying: false,
ttsPlayingIndex: -1,
innerAudioContext: null
}
},
onLoad() {
// 页面加载时滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
});
this.initRecorder();
this.initAudioContext();
},
onUnload() {
this.stopRecordTimer();
if (this.isRecording && this.recorderManager) {
this.recorderManager.stop();
}
if (this._streamCtrl) {
this._streamCtrl.abort();
this._streamCtrl = null;
}
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
},
methods: {
// 初始化录音管理器
initRecorder() {
// #ifdef MP-WEIXIN || APP-PLUS
this.recorderManager = uni.getRecorderManager();
this.recorderManager.onStart(() => {
console.log('录音开始');
this.recordDuration = 0;
this.startRecordTimer();
});
this.recorderManager.onStop((res) => {
console.log('录音结束', res);
this.stopRecordTimer();
if (res.duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none'
});
return;
}
this.handleRecordResult(res);
});
this.recorderManager.onError((err) => {
console.error('录音错误:', err);
this.isRecording = false;
this.stopRecordTimer();
uni.showToast({
title: '录音失败,请重试',
icon: 'none'
});
});
// #endif
},
startRecordTimer() {
this.recordTimer = setInterval(() => {
this.recordDuration++;
if (this.recordDuration >= 60) {
this.stopRecord();
}
}, 1000);
},
stopRecordTimer() {
if (this.recordTimer) {
clearInterval(this.recordTimer);
this.recordTimer = null;
}
this.recordDuration = 0;
},
toggleVoiceMode() {
this.isVoiceMode = !this.isVoiceMode;
if (this.isVoiceMode) {
// 隐藏键盘
uni.hideKeyboard();
} else {
// 聚焦输入框
this.$nextTick(() => {
this.inputFocus = true;
});
}
},
startRecord() {
// #ifdef H5
uni.showModal({
title: '提示',
content: 'H5环境暂不支持语音输入请使用小程序或APP',
showCancel: false
});
return;
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
if (!this.recorderManager) {
uni.showToast({
title: '录音功能初始化失败',
icon: 'none'
});
return;
}
uni.authorize({
scope: 'scope.record',
success: () => {
this.isRecording = true;
this.recorderManager.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
});
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启录音权限',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting();
}
}
});
}
});
// #endif
},
stopRecord() {
if (!this.isRecording) return;
this.isRecording = false;
// #ifdef MP-WEIXIN || APP-PLUS
if (this.recorderManager) {
this.recorderManager.stop();
}
// #endif
uni.showToast({
title: '正在识别...',
icon: 'loading',
duration: 2000
});
},
async handleRecordResult(res) {
try {
const uploadRes = await api.uploadFile(res.tempFilePath, {
model: 'audio',
pid: '8'
});
if (!uploadRes || uploadRes.code !== 200) {
throw new Error('录音文件上传失败');
}
const audioUrl = uploadRes.data.fullUrl;
const recognizedText = await this.recognizeSpeech(audioUrl);
if (recognizedText) {
this.inputText = this.inputText ? (this.inputText + ' ' + recognizedText) : recognizedText;
// Auto-switch back to text mode so user can see the recognized text
if (this.isVoiceMode) {
this.isVoiceMode = false;
this.$nextTick(() => {
this.inputFocus = true;
});
}
} else {
uni.showToast({
title: '未能识别出语音内容',
icon: 'none'
});
}
} catch (error) {
console.error('语音识别流程失败:', error);
uni.showToast({
title: '识别失败,请重试',
icon: 'none'
});
}
},
async recognizeSpeech(audioUrl) {
try {
const createTaskRes = await api.createAsrTask({
url: audioUrl,
engineModelType: '16k_zh',
channelNum: 1,
resTextFormat: 0,
sourceType: 0
});
if (!createTaskRes || createTaskRes.code !== 200) {
throw new Error('创建识别任务失败');
}
const taskId = createTaskRes.data.taskId;
const result = await this.pollAsrResult(taskId);
return this.parseAsrResult(result);
} catch (error) {
console.error('语音识别请求失败:', error);
throw error;
}
},
async pollAsrResult(taskId, maxAttempts = 30, interval = 2000) {
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
const statusRes = await api.queryAsrStatus(taskId);
if (!statusRes || statusRes.code !== 200) throw new Error('查询识别状态失败');
const status = statusRes.data.status;
if (status === 2) return statusRes.data;
else if (status === 3) throw new Error(statusRes.data.errorMsg || '识别失败');
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
console.error('查询识别状态出错:', error);
if (attempts >= maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, interval));
}
}
throw new Error('识别超时,请重试');
},
parseAsrResult(data) {
if (!data || !data.result) return '';
let text = data.result;
text = text.replace(/\[\d+:\d+\.\d+,\d+:\d+\.\d+\]\s*/g, '');
text = text.replace(/\n+/g, ' ').trim();
return text.replace(/\s+/g, ' ');
},
chooseImage() {
uni.chooseImage({
count: 3 - this.pendingImages.length, // 限制最多选择3张
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uni.showLoading({ title: '处理中...' });
try {
// 遍历处理所有选择的图片
for (const filePath of res.tempFilePaths) {
// 使用新的 Coze 上传接口
const uploadRes = await api.cozeUploadFile(filePath);
if (uploadRes && (uploadRes.code === 0 || uploadRes.code === 200)) {
const fileInfo = uploadRes.data;
// 添加到待发送列表
this.pendingImages.push({
path: filePath,
fileInfo: fileInfo
});
} else {
throw new Error('上传失败: ' + (uploadRes?.msg || uploadRes?.message || '服务器返回异常'));
}
}
} catch (error) {
console.error('上传图片失败:', error);
uni.showToast({
title: error.message || '上传图片失败',
icon: 'none',
duration: 3000
});
} finally {
uni.hideLoading();
}
}
});
},
removeImage(index) {
this.pendingImages.splice(index, 1);
},
/** 将本地图片转为 data URL用于 KieAI 多模态合并请求 */
readFileAsDataUrl(filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager();
const isJpeg = /\.(jpe?g|jfif)$/i.test(filePath);
fs.readFile({
filePath,
encoding: 'base64',
success: (res) => {
const mime = isJpeg ? 'image/jpeg' : 'image/png';
resolve(`data:${mime};base64,${res.data}`);
},
fail: reject
});
});
},
sendImageMessage(imageUrl, fileInfo) {
// 该方法已废弃,逻辑合并到 sendMessage
},
previewImage(url) {
uni.previewImage({
urls: [url]
});
},
sendQuickQuestion(question) {
this.inputText = question;
this.$nextTick(() => {
this.sendMessage();
});
},
clearChat() {
uni.showModal({
title: '提示',
content: '确定要清空对话吗?',
success: (res) => {
if (res.confirm) {
if (this._streamCtrl) {
this._streamCtrl.abort();
this._streamCtrl = null;
}
this.isLoading = false;
this.messageList = [];
this.conversationId = '';
}
}
})
},
showCommonQuestions() {
uni.showToast({
title: '常见问题功能开发中',
icon: 'none'
})
},
onInputFocus() {
this.inputFocus = true
// 延迟滚动到底部,等待键盘弹出
setTimeout(() => {
this.scrollToBottom()
}, 300)
},
onInputBlur() {
this.inputFocus = false
},
async sendMessage() {
if (!this.inputText.trim() && this.pendingImages.length === 0) {
return
}
const imagesToSend = [...this.pendingImages];
const text = this.inputText.trim();
this.pendingImages = [];
this.inputText = '';
// 先展示用户消息到界面
for (const img of imagesToSend) {
this.messageList.push({
role: 'user',
type: 'image',
imageUrl: img.path,
content: '[图片]'
});
}
if (text) {
this.messageList.push({ role: 'user', content: text, type: 'text' });
}
this.scrollToBottom();
// 合并为一次多模态请求:图片(base64) + 文字,统一走 KieAI Gemini
const contentParts = [];
try {
for (const img of imagesToSend) {
const dataUrl = await this.readFileAsDataUrl(img.path);
contentParts.push({ type: 'image_url', image_url: { url: dataUrl } });
}
if (text) {
contentParts.push({ type: 'text', text });
} else if (contentParts.length > 0) {
contentParts.push({ type: 'text', text: '请描述或分析这张图片' });
}
} catch (e) {
console.error('读取图片失败:', e);
this.messageList.push({ role: 'ai', content: '读取图片失败,请重试。' });
this.scrollToBottom();
return;
}
if (contentParts.length === 0) return;
// 仅文字时走纯文字接口;否则走多模态(图+文)一次请求
if (contentParts.length === 1 && contentParts[0].type === 'text') {
await this.sendToAI(contentParts[0].text, 'text');
} else {
await this.sendToAI(contentParts, 'multimodal');
}
this.scrollToBottom();
},
/**
* CommonResult.data 应为 OpenAI 形态若网关多包一层 data则取内层含 choices/candidates 的对象
* 展示内容仍只来自模型字段 choices[0].message.content或经后端规范后的同路径
*/
getGeminiPayload(data) {
if (!data || typeof data !== 'object') return null;
if (Array.isArray(data.choices) && data.choices.length > 0) return data;
if (Array.isArray(data.candidates) && data.candidates.length > 0) return data;
const inner = data.data;
if (inner && typeof inner === 'object') {
if (Array.isArray(inner.choices) && inner.choices.length > 0) return inner;
if (Array.isArray(inner.candidates) && inner.candidates.length > 0) return inner;
}
return data;
},
/** BUG-005: 从 KieAI Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text } / { content } */
extractReplyContent(content) {
if (content == null) return '';
if (typeof content === 'number' || typeof content === 'boolean') return String(content);
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content === 'object') {
if (Array.isArray(content.parts)) {
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content;
}
return '';
},
/** 工具方法sleep ms 毫秒 */
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
buildGeminiMessages(content, type) {
if (type === 'text') {
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }];
}
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
return [{ role: 'user', content: parts }];
},
/** BUG-005严格从 CommonResult.data.choices[0].message.content 读取回复 */
getGeminiReplyFromResponse(response) {
const content = response &&
response.data &&
Array.isArray(response.data.choices) &&
response.data.choices[0] &&
response.data.choices[0].message
? response.data.choices[0].message.content
: '';
return this.extractReplyContent(content);
},
async sendToAI(content, type) {
this.isLoading = true;
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
this.messageList.push(aiMsg);
this.scrollToBottom();
try {
const response = await api.kieaiGeminiChat({
messages: this.buildGeminiMessages(content, type),
stream: false
});
const reply = this.getGeminiReplyFromResponse(response);
aiMsg.content = reply || '抱歉,未获取到模型回复。';
} catch (error) {
console.error('KieAI Gemini 对话失败:', error);
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
} finally {
aiMsg.loading = false;
aiMsg.streaming = false;
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
if (this.ttsEnabled && aiMsg.content) {
const aiIdx = this.messageList.indexOf(aiMsg);
if (aiIdx !== -1) {
this.$nextTick(() => this.playTTS(aiIdx));
}
}
}
},
// ---------- TTS 方法 ----------
initAudioContext() {
// #ifdef MP-WEIXIN || APP-PLUS
const ctx = uni.createInnerAudioContext()
ctx.onEnded(() => {
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
ctx.onError((e) => {
console.error('[TTS] 播放出错', e)
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
this.innerAudioContext = ctx
// #endif
},
toggleTTS() {
this.ttsEnabled = !this.ttsEnabled
if (!this.ttsEnabled && this.ttsPlaying) {
this.stopTTS()
}
},
async playTTS(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
if (this.ttsPlaying) {
this.stopTTS()
}
try {
const tempPath = await api.cozeTextToSpeech({ input: msg.content })
if (!this.innerAudioContext) {
console.warn('[TTS] innerAudioContext 未初始化')
return
}
this.ttsPlayingIndex = index
this.ttsPlaying = true
this.innerAudioContext.src = tempPath
this.innerAudioContext.play()
} catch (e) {
console.error('[TTS] 合成失败', e)
uni.showToast({ title: '语音合成失败', icon: 'none' })
this.ttsPlaying = false
this.ttsPlayingIndex = -1
}
},
stopTTS() {
if (this.innerAudioContext) {
this.innerAudioContext.stop()
}
this.ttsPlaying = false
this.ttsPlayingIndex = -1
},
// ---------- 滚动 ----------
scrollToBottom() {
this.$nextTick(() => {
// 动态切换 scrollTop 值以触发滚动更新
this.lastScrollTop = this.lastScrollTop === 99998 ? 99999 : 99998
this.scrollTop = this.lastScrollTop
})
}
}
}
</script>
<style lang="scss" scoped>
.ai-chat-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f4f5f7;
}
/* 顶部区域 */
.header-container {
background: #ffffff;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
flex-shrink: 0;
}
.nav-bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 98rpx;
padding: 0 24rpx;
border-bottom: 1rpx solid #d0dbea;
}
.nav-back {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
.back-icon {
font-size: 40rpx;
color: #2e3e5c;
font-weight: 300;
}
&:active {
opacity: 0.6;
}
}
.nav-title {
font-size: 32rpx;
color: #2e3e5c;
font-weight: 400;
letter-spacing: -0.5rpx;
}
.nav-placeholder {
width: 56rpx;
}
/* 宣传横幅 */
.promo-banner {
height: 192rpx;
background: linear-gradient(135deg, #fdfaff 0%, #fcf5ff 50%, #ffffff 100%);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx;
position: relative;
overflow: hidden;
}
.promo-content {
display: flex;
flex-direction: column;
gap: 8rpx;
z-index: 1;
}
.promo-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.promo-title {
font-size: 36rpx;
font-weight: 600;
color: #ff6b35;
letter-spacing: 1rpx;
}
.promo-sparkle {
font-size: 28rpx;
color: #ff6b35;
}
.promo-subtitle {
font-size: 28rpx;
color: #5c6bc0;
font-weight: 400;
}
.promo-right {
display: flex;
align-items: center;
gap: 20rpx;
z-index: 1;
}
.clear-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.clear-btn:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.clear-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.clear-text {
font-size: 22rpx;
color: #ff6b35;
font-weight: 500;
}
.tts-toggle-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
margin-right: 12rpx;
&.active {
background: rgba(76, 175, 80, 0.15);
box-shadow: 0 2rpx 8rpx rgba(76, 175, 80, 0.3);
}
}
.tts-toggle-btn:active {
transform: scale(0.95);
}
.tts-toggle-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.tts-toggle-text {
font-size: 20rpx;
color: #4caf50;
font-weight: 500;
}
.tts-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8rpx;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.tts-play-btn:active {
transform: scale(0.9);
}
.tts-play-icon {
font-size: 24rpx;
color: #4caf50;
}
.promo-avatar {
width: 180rpx;
height: 180rpx;
}
/* 聊天容器 */
.chat-container {
flex: 1;
overflow-y: auto;
}
.message-list {
padding: 32rpx;
display: flex;
flex-direction: column;
gap: 32rpx;
}
.message-item {
display: flex;
gap: 16rpx;
&.ai-message {
justify-content: flex-start;
}
&.user-message {
justify-content: flex-end;
}
}
.message-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: linear-gradient(180deg, #4facfe 0%, #00f2fe 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.avatar-icon {
font-size: 28rpx;
}
}
.message-content {
flex: 1;
max-width: 75%;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.message-header {
display: flex;
align-items: center;
padding-left: 16rpx;
.message-sender {
font-size: 24rpx;
color: #9fa5c0;
}
}
.message-bubble {
border-radius: 32rpx;
padding: 24rpx 32rpx;
word-wrap: break-word;
transition: all 0.3s ease;
&.ai-bubble {
background: #ffffff;
border-top-left-radius: 8rpx;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
.message-text {
color: #3e5481;
}
}
&.user-bubble {
background: linear-gradient(135deg, #ff8c52 0%, #ff6b35 100%);
border-top-right-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(255, 107, 53, 0.3);
.message-text {
color: #ffffff;
}
}
.message-text {
font-size: 28rpx;
line-height: 1.65;
white-space: pre-wrap;
letter-spacing: -0.2rpx;
}
}
/* 流式输出闪烁光标 */
.streaming-cursor {
animation: blink 0.8s step-end infinite;
color: #4facfe;
font-weight: 600;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* 打字指示器 */
.typing-indicator {
display: flex;
gap: 10rpx;
align-items: center;
padding: 8rpx 0;
}
.typing-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
background: #4facfe;
animation: typing 1.4s infinite;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
@keyframes typing {
0%, 60%, 100% {
transform: translateY(0);
opacity: 0.4;
}
30% {
transform: translateY(-10rpx);
opacity: 1;
}
}
/* 消息图片 */
.message-image {
max-width: 400rpx;
border-radius: 16rpx;
}
/* 快捷问题区域 */
.quick-questions {
display: flex;
gap: 16rpx;
padding: 20rpx 32rpx;
flex-shrink: 0;
}
.quick-btn {
flex: 1;
height: 84rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
text {
font-size: 24rpx;
color: #3e5481;
letter-spacing: -0.2rpx;
}
&:active {
background: #f4f5f7;
transform: scale(0.98);
}
}
/* 图片预览区域 */
.image-preview-area {
display: flex;
gap: 20rpx;
padding-bottom: 20rpx;
overflow-x: auto;
white-space: nowrap;
.preview-item {
position: relative;
width: 120rpx;
height: 120rpx;
flex-shrink: 0;
.preview-img {
width: 100%;
height: 100%;
border-radius: 16rpx;
background: #f5f5f5;
}
.delete-btn {
position: absolute;
top: -10rpx;
right: -10rpx;
width: 36rpx;
height: 36rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.delete-icon {
color: #ffffff;
font-size: 24rpx;
line-height: 1;
margin-top: -2rpx;
}
}
&.add-btn {
background: #f5f5f5;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed #d0dbea;
.add-icon {
font-size: 48rpx;
color: #9fa5c0;
line-height: 1;
margin-top: -4rpx;
}
&:active {
background: #e0e0e0;
}
}
}
}
/* 输入区域 */
.input-container {
background: #f4f5f7;
padding: 16rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
flex-shrink: 0;
}
.input-wrapper {
display: flex;
align-items: center;
gap: 16rpx;
}
/* 操作按钮 */
.action-btn {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.action-icon {
font-size: 44rpx;
}
.action-icon-svg {
width: 48rpx;
height: 48rpx;
&.mic {
width: 40rpx;
height: 40rpx;
}
}
&:active {
opacity: 0.7;
}
}
.nav-back-hover {
opacity: 0.6;
}
/* 语音按住按钮 */
.voice-hold-btn {
flex: 1;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 100rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
color: #3e5481;
font-weight: 500;
transition: all 0.2s;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
&.recording {
background: linear-gradient(135deg, #ff8c52 0%, #ff6b35 100%);
color: #ffffff;
border-color: transparent;
transform: scale(0.98);
}
&:active {
background: #f4f5f7;
}
}
.chat-input {
flex: 1;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #e5e7eb;
border-radius: 100rpx;
padding: 0 96rpx 0 32rpx;
font-size: 32rpx;
color: #3e5481;
box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1), 0 2rpx 4rpx rgba(0, 0, 0, 0.06);
}
.send-btn {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: transparent;
&.active {
background: rgba(255, 107, 53, 0.1);
.send-icon-svg {
transform: scale(1.1);
}
&:active {
transform: scale(0.9);
background: rgba(255, 107, 53, 0.2);
}
}
.send-icon-svg {
width: 40rpx;
height: 40rpx;
transition: all 0.3s;
}
}
</style>