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

1550 lines
36 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="msg-actions"
>
<!-- 复制 -->
<view class="action-btn" @click="copyMessage(index)">
<text class="action-icon">📋</text>
</view>
<!-- 重新生成仅最后一条AI消息显示 -->
<view class="action-btn" v-if="isLastAiMessage(index)" @click="regenerateMessage(index)">
<text class="action-icon">🔄</text>
</view>
<!-- 语音朗读 -->
<view class="action-btn" @click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)">
<text class="action-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
<!-- 删除 -->
<view class="action-btn" @click="deleteMessage(index)">
<text class="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';
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();
});
},
// ---------- 消息操作方法(复制/删除/重新生成) ----------
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 = [];
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主路径为 response.data.choices[0].message.contentmodels-api 已尽量规范 data 形态
* 兼容上游 candidates或根级即 completion 等变体仅使用接口返回字段
* 成功路径不使用 api/tool.js getAIResponse 或本地关键词话术
*/
getGeminiReplyFromResponse(response) {
if (!response || typeof response !== 'object') return '';
const looksLikeCompletion = (o) =>
o && typeof o === 'object' &&
(Array.isArray(o.choices) || Array.isArray(o.candidates));
let data = response.data;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
return '';
}
}
// 主路径OpenAI 形态 choices[0].message.content与需求 data.choices[0].message.content 一致)
if (data && typeof data === 'object' && Array.isArray(data.choices) && data.choices.length > 0) {
const choice0 = data.choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
if (!looksLikeCompletion(data) && looksLikeCompletion(response)) {
data = response;
}
if (!data || typeof data !== 'object') return '';
const payload = this.getGeminiPayload(data);
if (!payload || typeof payload !== 'object') return '';
const choices = payload.choices;
if (Array.isArray(choices) && choices.length > 0) {
const choice0 = choices[0];
const msg = choice0 && choice0.message;
if (msg && typeof msg === 'object') {
const fromMsg = this.extractReplyContent(msg.content);
if (fromMsg.trim()) return fromMsg;
}
const delta = choice0 && choice0.delta;
if (delta && typeof delta === 'object') {
const fromDelta = this.extractReplyContent(delta.content);
if (fromDelta.trim()) return fromDelta;
}
if (choice0 && typeof choice0.text === 'string' && choice0.text.trim()) {
return choice0.text;
}
}
const cands = payload.candidates;
if (Array.isArray(cands) && cands.length > 0) {
const c0 = cands[0];
if (c0 && typeof c0 === 'object') {
const fromCand = this.extractReplyContent(c0.content);
if (fromCand.trim()) return fromCand;
if (typeof c0.text === 'string' && c0.text.trim()) return c0.text;
}
}
return '';
},
async sendToAI(content, type) {
this.isLoading = true;
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
this.messageList.push(aiMsg);
this.scrollToBottom();
const messages = this.buildGeminiMessages(content, type);
// 优先尝试流式输出以改善响应速度感知
try {
await new Promise((resolve, reject) => {
const ctrl = api.kieaiGeminiChatStream({ messages })
this._streamCtrl = ctrl
// 收到第一个 chunk 后切换为 streaming 状态
ctrl.onMessage((deltaText) => {
if (aiMsg.loading) {
aiMsg.loading = false
aiMsg.streaming = true
}
aiMsg.content += deltaText
this.messageList = [...this.messageList]
this.scrollToBottom()
})
ctrl.onError((err) => {
console.warn('[sendToAI] 流式请求失败,降级为非流式:', err)
this._streamCtrl = null
reject(err)
})
ctrl.onComplete(() => {
this._streamCtrl = null
resolve()
})
})
// 流式完成,检查内容
if (!aiMsg.content.trim()) {
aiMsg.content = '模型未返回有效内容,请稍后重试。'
}
} catch (streamError) {
// 流式失败,降级为非流式请求
try {
const response = await api.kieaiGeminiChat({
messages,
stream: false
});
const reply = this.getGeminiReplyFromResponse(response);
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。';
} catch (error) {
console.error('KieAI Gemini 对话失败:', error);
const errText = error && error.message ? String(error.message) : '';
aiMsg.content = errText || '抱歉,处理您的请求时出现错误,请稍后再试。';
}
} 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;
}
/* 消息操作按钮组 */
.msg-actions {
display: flex;
gap: 16rpx;
margin-top: 8rpx;
align-items: center;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.action-btn:active {
transform: scale(0.9);
}
.action-icon {
font-size: 24rpx;
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>