Files
msh-system/msh_single_uniapp/pages/tool/ai-nutritionist.vue
scottpan 8ba027b194 FIX-001: 移除 AI 营养师页面初始假数据
- 将 messageList 初始值从示例消息改为空数组 []
- 保留 welcomeMessage 和页面其他功能
- 用户首次进入时只显示欢迎语,无假对话记录

Fixes: FIX-001-remove-fake-data
2026-02-28 21:12:03 +08:00

1253 lines
29 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-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>
<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>
<!-- 聊天消息列表 -->
<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']">
<text v-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
</view>
</view>
</view>
<!-- 加载中提示 -->
<view v-if="isLoading" 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', //coze智能体机器人ID
conversationId: '', // 存储会话ID用于多轮对话
scrollTop: 0,
lastScrollTop: 0, // 用于动态滚动
inputText: '',
inputFocus: false,
isLoading: false,
isVoiceMode: false,
isRecording: false,
recorderManager: null,
recordDuration: 0,
recordTimer: null,
pendingImages: [], // 待发送图片
welcomeMessage: `👋您好我是您的AI营养师助手。
我可以帮您:
• 解答饮食疑问
• 评估食物适宜性
• 提供烹饪建议
• 解读检验报告
有什么想问的吗?`,
messageList: []
}
},
onLoad() {
// 页面加载时滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
});
this.initRecorder();
},
onUnload() {
this.stopRecordTimer();
if (this.isRecording && this.recorderManager) {
this.recorderManager.stop();
}
},
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);
},
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) {
this.messageList = []
this.conversationId = '' // 清空会话ID开始新的对话
}
}
})
},
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];
this.pendingImages = []; // 清空待发送图片
for (const img of imagesToSend) {
// 添加用户图片消息到界面
this.messageList.push({
role: 'user',
type: 'image',
imageUrl: img.path,
content: '[图片]'
});
// 发送图片给AI
// 注意:这里我们不等待图片发送完成,而是让它并行处理,
// 或者如果需要严格顺序,可以 await this.sendToAI(...)
// 但考虑到用户体验,我们先显示,后台发送
await this.sendToAI(img.fileInfo || img.path, 'image');
}
// 处理文本消息
if (this.inputText.trim()) {
const text = this.inputText.trim();
// 添加用户文本消息到界面
const userMessage = {
role: 'user',
content: text,
type: 'text'
};
this.messageList.push(userMessage);
// 清空输入框
this.inputText = ''
// 滚动到底部
this.scrollToBottom()
await this.sendToAI(text, 'text');
}
this.scrollToBottom();
},
async sendToAI(content, type) {
// 显示加载中
this.isLoading = true
// 获取用户信息,提供 fallback
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
try {
// 构建 Coze 接口参数
const messages = [];
if (type === 'text') {
messages.push({
role: 'user',
type: 'question',
content: content,
content_type: 'text'
});
} else if (type === 'image') {
// 解析 fileInfo可能是对象或 JSON 字符串
let fileInfo = content;
if (typeof fileInfo === 'string') {
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* 非JSON保持原值 */ }
}
const fileId = (fileInfo && fileInfo.id) || (fileInfo && fileInfo.file_id) || '';
if (fileId) {
messages.push({
role: 'user',
content: JSON.stringify([{ type: 'image', file_id: fileId }]),
content_type: 'object_string'
});
} else {
// 降级处理
messages.push({
role: 'user',
content: '我发送了一张图片,请帮我分析',
content_type: 'text'
});
}
}
const requestData = {
botId: this.botId,
userId: userId,
additionalMessages: messages,
stream: false,
autoSaveHistory: true
};
// 如果有已存在的会话ID传入以保持多轮对话上下文
if (this.conversationId) {
requestData.conversationId = this.conversationId;
}
// 调用 Coze Chat 接口
const response = await api.cozeChat(requestData);
// 处理响应
// Coze 非流式返回会包含 data 字段,其中有 conversation_id 和 id (chat_id)
// 以及 status (created, in_progress, completed, failed, requires_action)
if (response && response.data) {
console.log("====api.cozeChat response====", response.data);
const { conversation_id, id: chat_id } = response.data.chat;
// 存储会话ID用于后续多轮对话
if (conversation_id) {
this.conversationId = conversation_id;
}
// 轮询查询对话状态
await this.pollChatStatus(conversation_id, chat_id);
} else {
throw new Error('发起对话失败');
}
} catch (error) {
console.error('发送消息失败:', error);
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: type === 'text' ? this.getAIResponse(content) : '抱歉,处理您的请求时出现错误,请稍后再试。'
});
this.scrollToBottom();
}
},
async pollChatStatus(conversationId, chatId) {
const maxAttempts = 60; // 最多轮询60次即60秒
let attempts = 0;
const checkStatus = async () => {
attempts++;
if (attempts > maxAttempts) {
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: '抱歉AI 响应超时,请稍后再试。'
});
this.scrollToBottom();
return;
}
try {
const res = await api.cozeRetrieveChat({
conversationId,
chatId
});
if (res && res.data) {
console.log("====api.cozeRetrieveChat response====", res.data);
const status = res.data.chat.status;
if (status === 'completed') {
// 对话完成,获取消息详情
await this.getChatMessages(conversationId, chatId);
} else if (status === 'failed' || status === 'canceled') {
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}`
});
this.scrollToBottom();
} else {
// 继续轮询 (created, in_progress, requires_action)
// 注意requires_action 可能需要额外处理,这里暂时视为继续等待或需人工干预
setTimeout(checkStatus, 1000);
}
} else {
// 查询失败,重试
setTimeout(checkStatus, 1000);
}
} catch (e) {
console.error('查询对话状态失败:', e);
setTimeout(checkStatus, 1000);
}
};
checkStatus();
},
async getChatMessages(conversationId, chatId) {
try {
const res = await api.cozeMessageList({
conversationId,
chatId
});
this.isLoading = false;
console.log("====api.cozeMessageList response====", res.data);
if (res && res.data && Array.isArray(res.data.messages)) {
// 过滤出 type='answer' 且 role='assistant' 的消息
const answerMsgs = res.data.messages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
if (answerMsgs.length > 0) {
// 可能有多条回复,依次显示
for (const msg of answerMsgs) {
this.messageList.push({
role: 'ai',
content: msg.content
});
}
} else {
// 尝试查找其他类型的回复
const otherMsgs = res.data.messages.filter(msg => msg.role === 'assistant');
if (otherMsgs.length > 0) {
for (const msg of otherMsgs) {
this.messageList.push({
role: 'ai',
content: msg.content
});
}
} else {
this.messageList.push({
role: 'ai',
content: '未能获取到有效回复。'
});
}
}
this.scrollToBottom();
} else {
throw new Error('获取消息列表失败');
}
} catch (e) {
console.error('获取消息详情失败:', e);
this.isLoading = false;
this.messageList.push({
role: 'ai',
content: '获取回复内容失败。'
});
this.scrollToBottom();
}
},
getAIResponse(question) {
// 这里可以根据问题返回不同的回复
// 实际应该调用AI API
const responses = {
'香蕉': '香蕉含钾量较高每100g约330mg对于需要控制钾摄入的透析患者来说需要谨慎食用。\n\n建议\n• 如果血钾控制良好,可以少量食用(如半根)\n• 透析后食用更安全\n• 建议咨询您的主治医生确认\n\n您最近的血钾指标如何呢',
'苹果': '苹果是相对安全的水果选择含钾量中等。建议适量食用每天1-2个即可。',
'蛋白质': '对于肾病患者蛋白质的摄入需要根据CKD分期和透析情况来调整。建议咨询专业营养师制定个性化方案。'
}
// 简单的关键词匹配
for (let key in responses) {
if (question.includes(key)) {
return responses[key]
}
}
// 默认回复
return `感谢您的提问。关于"${question}",我建议您:\n\n• 咨询您的主治医生获取专业建议\n• 根据您的CKD分期和透析情况调整饮食\n• 定期监测相关指标\n\n如需更详细的指导可以联系专业营养师。`
},
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-avatar {
width: 180rpx;
height: 180rpx;
position: absolute;
right: 20rpx;
bottom: 0;
}
/* 聊天容器 */
.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;
}
}
/* 打字指示器 */
.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>