- 旧版多模态消息仅有用户 parts,模型无领域上下文,常回退到通用客套话 - 新增 SYSTEM_NUTRITIONIST:专业中文营养师人设 + 慢病饮食知识 + 非食物兜底 - 仅在含 image_url 时注入,避免影响纯文字流式(3-2)回归 - image 类型用户提示从「请分析这张图片」改为「识别食物,给营养成分与饮食建议」 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1531 lines
36 KiB
Vue
1531 lines
36 KiB
Vue
<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="msg-actions"
|
||
>
|
||
<!-- 复制 -->
|
||
<view class="action-btn" @click="copyMessage(index)">
|
||
<text class="iconfont icon-fuzhi action-icon"></text>
|
||
</view>
|
||
<!-- 重新生成(仅最后一条AI消息显示) -->
|
||
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
|
||
<text class="iconfont icon-shuaxin action-icon"></text>
|
||
</view>
|
||
<!-- 语音朗读 -->
|
||
<view class="action-btn" :class="{ 'action-btn-active': ttsPlayingIndex === index }" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
|
||
<text class="iconfont icon-laba action-icon"></text>
|
||
</view>
|
||
<!-- 删除 -->
|
||
<view class="action-btn" @click="deleteMessage(index)">
|
||
<text class="iconfont icon-shanchu action-icon"></text>
|
||
</view>
|
||
</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';
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
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 {
|
||
// 跳过 OSS 上传(图片接口会拒绝 mp3):直接读取 base64 调用一句话识别(≤60s)
|
||
const base64Data = await this.readFileAsBase64(res.tempFilePath);
|
||
if (!base64Data) {
|
||
throw new Error('读取录音文件失败');
|
||
}
|
||
const dataLen = res.fileSize || Math.floor(base64Data.length * 3 / 4);
|
||
const sentenceRes = await api.sentenceRecognition(base64Data, dataLen, 'mp3');
|
||
if (!sentenceRes || sentenceRes.code !== 200) {
|
||
throw new Error((sentenceRes && (sentenceRes.message || sentenceRes.msg)) || '语音识别失败');
|
||
}
|
||
const recognizedText = sentenceRes.data && sentenceRes.data.result ? String(sentenceRes.data.result).trim() : '';
|
||
|
||
if (recognizedText) {
|
||
this.inputText = this.inputText ? (this.inputText + ' ' + recognizedText) : recognizedText;
|
||
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: error && error.message ? error.message : '识别失败,请重试',
|
||
icon: 'none'
|
||
});
|
||
}
|
||
},
|
||
/** 读取本地文件 → base64(不含 data: 头) */
|
||
readFileAsBase64(filePath) {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const fs = uni.getFileSystemManager();
|
||
fs.readFile({
|
||
filePath,
|
||
encoding: 'base64',
|
||
success: (r) => resolve(r.data || ''),
|
||
fail: (e) => reject(e)
|
||
});
|
||
} catch (e) { reject(e); }
|
||
});
|
||
},
|
||
|
||
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();
|
||
});
|
||
},
|
||
// ---------- 消息操作方法(复制/删除/重新生成) ----------
|
||
|
||
copyMessage(index) {
|
||
const msg = this.messageList[index]
|
||
if (!msg || !msg.content) return
|
||
uni.setClipboardData({
|
||
data: msg.content,
|
||
success: () => {
|
||
uni.showToast({ title: '已复制', icon: 'success' })
|
||
}
|
||
})
|
||
},
|
||
|
||
deleteMessage(index) {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '确定删除这条消息吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
this.messageList.splice(index, 1)
|
||
this.messageList = [...this.messageList]
|
||
}
|
||
}
|
||
})
|
||
},
|
||
|
||
regenerateMessage(index) {
|
||
// 找到该 AI 消息对应的上一条用户消息
|
||
let userMsgIndex = index - 1
|
||
while (userMsgIndex >= 0 && this.messageList[userMsgIndex].role !== 'user') {
|
||
userMsgIndex--
|
||
}
|
||
if (userMsgIndex < 0) return
|
||
const userMsg = this.messageList[userMsgIndex]
|
||
// 移除当前 AI 回复
|
||
this.messageList.splice(index, 1)
|
||
this.messageList = [...this.messageList]
|
||
// 重新发送
|
||
this.sendToAI(userMsg.content, userMsg.type || 'text')
|
||
},
|
||
|
||
isLastAiMessage(index) {
|
||
for (let i = this.messageList.length - 1; i >= 0; i--) {
|
||
if (this.messageList[i].role === 'ai' && !this.messageList[i].loading) {
|
||
return i === index
|
||
}
|
||
}
|
||
return false
|
||
},
|
||
|
||
clearChat() {
|
||
uni.showModal({
|
||
title: '提示',
|
||
content: '确定要清空对话吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
if (this._streamCtrl) {
|
||
this._streamCtrl.abort();
|
||
this._streamCtrl = null;
|
||
}
|
||
this.isLoading = false;
|
||
this.messageList = [];
|
||
}
|
||
}
|
||
})
|
||
},
|
||
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();
|
||
},
|
||
|
||
/**
|
||
* 构建 OpenAI 兼容格式的 messages(KieAI Gemini /api/front/kieai/gemini/chat)
|
||
* @param {string|Array} content 消息内容
|
||
* @param {string} type 消息类型 text / image / multimodal
|
||
* @returns {Array} messages 数组 [{role, content}]
|
||
*/
|
||
buildChatMessages(content, type) {
|
||
// test-0415 反馈3-4:图文问答需明确告诉模型「请基于图片识别食物 / 营养建议」,
|
||
// 否则模型容易回退到通用客套话;纯文字场景仍走旧逻辑(避免影响 3-2 流式回归)
|
||
const SYSTEM_NUTRITIONIST = '你是一名专业的中文营养师助手。当用户消息中包含图片(image_url)时,请先识别图片中的食物或菜品,然后基于其常见营养成分(能量、蛋白质、脂肪、碳水、钾、磷、钠、嘌呤等)给出针对慢性肾病/痛风/糖尿病人群的饮食建议;若图片不是食物,请明确说明「这张图不是食物」并简短回应用户。'
|
||
|
||
if (type === 'text') {
|
||
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }]
|
||
}
|
||
if (type === 'image') {
|
||
let fileInfo = content
|
||
if (typeof fileInfo === 'string') {
|
||
try { fileInfo = JSON.parse(fileInfo) } catch (e) { /* 非JSON */ }
|
||
}
|
||
const imageUrl = (fileInfo && fileInfo.url) || (fileInfo && fileInfo.path) || ''
|
||
if (imageUrl) {
|
||
return [
|
||
{ role: 'system', content: SYSTEM_NUTRITIONIST },
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{ type: 'image_url', image_url: { url: imageUrl } },
|
||
{ type: 'text', text: '请基于这张图片识别食物,给出营养成分与饮食建议' }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
return [{ role: 'user', content: '我发送了一张图片,请帮我分析' }]
|
||
}
|
||
// multimodal:直接传多模态 parts;含 image_url 时附加 system prompt 引导模型分析图片
|
||
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }]
|
||
const hasImage = parts.some((p) => p && (p.type === 'image_url' || p.image_url))
|
||
const messages = []
|
||
if (hasImage) {
|
||
messages.push({ role: 'system', content: SYSTEM_NUTRITIONIST })
|
||
}
|
||
messages.push({ role: 'user', content: parts })
|
||
return messages
|
||
},
|
||
|
||
async sendToAI(content, type) {
|
||
this.isLoading = true;
|
||
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
|
||
this.messageList.push(aiMsg);
|
||
this.scrollToBottom();
|
||
|
||
try {
|
||
// BUG-005:本页文本/多模态对话统一走 KieAI Gemini(POST /api/front/kieai/gemini/chat)
|
||
await this._sendViaGemini(content, type, aiMsg)
|
||
} 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));
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// ========== KieAI Gemini(流式 SSE,test-0415 反馈3-2)==========
|
||
// 旧版 stream: false 等待全文返回 → 5-15s 才出文字。
|
||
// 现行:调用 kieaiGeminiChatStream,首包到达即停 loading 圈,逐 chunk 渲染 → TTFB <1.5s
|
||
_sendViaGemini(content, type, aiMsg) {
|
||
return new Promise((resolve) => {
|
||
const messages = this.buildChatMessages(content, type)
|
||
let receivedAny = false
|
||
let errorMsg = ''
|
||
|
||
const ctrl = api.kieaiGeminiChatStream({ messages })
|
||
this._streamCtrl = ctrl
|
||
ctrl.onMessage((delta) => {
|
||
if (!receivedAny) {
|
||
receivedAny = true
|
||
aiMsg.loading = false
|
||
aiMsg.streaming = true
|
||
this.messageList = [...this.messageList]
|
||
}
|
||
aiMsg.content += delta
|
||
// 每个 chunk 触发响应式刷新(保证 streaming 光标移动)
|
||
this.messageList = [...this.messageList]
|
||
this.scrollToBottom()
|
||
})
|
||
.onError((err) => {
|
||
errorMsg = (err && err.message) || ''
|
||
console.error('Gemini 流式对话失败:', err)
|
||
})
|
||
.onComplete(() => {
|
||
if (!receivedAny) {
|
||
aiMsg.content = errorMsg || '抱歉,处理您的请求时出现错误,请稍后再试。'
|
||
}
|
||
this._streamCtrl = null
|
||
resolve()
|
||
})
|
||
})
|
||
},
|
||
|
||
// ---------- TTS 方法 ----------
|
||
|
||
initAudioContext() {
|
||
// #ifdef MP-WEIXIN || APP-PLUS
|
||
const ctx = uni.createInnerAudioContext()
|
||
ctx.onEnded(() => {
|
||
// test-0415 反馈3-3:分句队列里还有下一句则继续播;否则结束
|
||
if (this.ttsPlaying && (this._ttsPrefetch || (this._ttsQueue && this._ttsQueue.length > 0))) {
|
||
this._playNextTTSChunk()
|
||
return
|
||
}
|
||
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()
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 分句播放 TTS(test-0415 反馈3-3)
|
||
* - 旧版:把全文丢给 TTS → 大文本合成耗时长 → 首声延迟 4-8s
|
||
* - 现行:按中文句号/问号/感叹号/分号切分;首句合成立即播放,
|
||
* 下一句在前一句播放期间预合成,形成流水线
|
||
*/
|
||
async playTTS(index) {
|
||
const msg = this.messageList[index]
|
||
if (!msg || !msg.content) return
|
||
if (this.ttsPlaying) this.stopTTS()
|
||
|
||
const sentences = this.splitTTSSentences(msg.content)
|
||
if (sentences.length === 0) return
|
||
|
||
this.ttsPlayingIndex = index
|
||
this.ttsPlaying = true
|
||
this._ttsQueue = sentences.slice()
|
||
this._ttsCurrentIndex = index
|
||
// 预先发起首句合成(首字节最快)
|
||
this._ttsPrefetch = api.cozeTextToSpeech({ input: this._ttsQueue.shift() })
|
||
this._playNextTTSChunk()
|
||
},
|
||
|
||
async _playNextTTSChunk() {
|
||
if (!this.ttsPlaying || this.ttsPlayingIndex !== this._ttsCurrentIndex) return
|
||
let prefetch = this._ttsPrefetch
|
||
this._ttsPrefetch = null
|
||
|
||
// 提前发起下一句合成(pipeline)
|
||
if (this._ttsQueue && this._ttsQueue.length > 0) {
|
||
const nextText = this._ttsQueue.shift()
|
||
this._ttsPrefetch = api.cozeTextToSpeech({ input: nextText })
|
||
}
|
||
|
||
try {
|
||
const tempPath = await prefetch
|
||
if (!this.innerAudioContext || !this.ttsPlaying) return
|
||
this.innerAudioContext.src = tempPath
|
||
this.innerAudioContext.play()
|
||
} catch (e) {
|
||
console.error('[TTS] 合成失败', e)
|
||
if (this._ttsQueue && this._ttsQueue.length > 0) {
|
||
// 当前句失败,跳到下一句
|
||
this._playNextTTSChunk()
|
||
} else {
|
||
uni.showToast({ title: '语音合成失败', icon: 'none' })
|
||
this.ttsPlaying = false
|
||
this.ttsPlayingIndex = -1
|
||
}
|
||
}
|
||
},
|
||
|
||
/** 中文句末标点切分;保留标点;空白过滤;超长(>120 字)强切 */
|
||
splitTTSSentences(text) {
|
||
if (!text) return []
|
||
const re = /[^。!?!?;;\n]+[。!?!?;;]?/g
|
||
const arr = (String(text).match(re) || [])
|
||
.map((s) => s.trim())
|
||
.filter(Boolean)
|
||
const result = []
|
||
for (const s of arr) {
|
||
if (s.length <= 120) {
|
||
result.push(s)
|
||
} else {
|
||
// 超长无终点:按 80 字硬切
|
||
for (let i = 0; i < s.length; i += 80) result.push(s.slice(i, i + 80))
|
||
}
|
||
}
|
||
return result
|
||
},
|
||
|
||
stopTTS() {
|
||
if (this.innerAudioContext) {
|
||
this.innerAudioContext.stop()
|
||
}
|
||
this._ttsQueue = []
|
||
this._ttsPrefetch = null
|
||
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;
|
||
}
|
||
|
||
/* 消息操作按钮组 */
|
||
.msg-actions {
|
||
display: flex;
|
||
gap: 20rpx;
|
||
margin-top: 12rpx;
|
||
align-items: center;
|
||
}
|
||
|
||
.action-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 52rpx;
|
||
height: 52rpx;
|
||
background: rgba(0, 0, 0, 0.04);
|
||
border-radius: 12rpx;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.action-btn:active {
|
||
transform: scale(0.92);
|
||
background: rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
.action-btn-active {
|
||
background: rgba(76, 175, 80, 0.15);
|
||
}
|
||
|
||
.action-icon {
|
||
font-size: 28rpx;
|
||
color: #666;
|
||
}
|
||
|
||
.action-btn-active .action-icon {
|
||
color: #4caf50;
|
||
}
|
||
|
||
.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>
|