2026-02-28 05:40:21 +08:00
|
|
|
|
<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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
<view class="promo-right">
|
|
|
|
|
|
<view class="clear-btn" @click="clearChat">
|
|
|
|
|
|
<text class="clear-icon">🗑️</text>
|
|
|
|
|
|
<text class="clear-text">清空</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>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 消息列表 -->
|
|
|
|
|
|
<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']">
|
2026-03-25 15:32:53 +08:00
|
|
|
|
<!-- 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>
|
|
|
|
|
|
<image
|
|
|
|
|
|
v-else
|
|
|
|
|
|
:src="msg.imageUrl"
|
|
|
|
|
|
mode="widthFix"
|
2026-02-28 05:40:21 +08:00
|
|
|
|
class="message-image"
|
|
|
|
|
|
@click="previewImage(msg.imageUrl)"
|
|
|
|
|
|
></image>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 加载中提示 -->
|
|
|
|
|
|
<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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</scroll-view>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 快捷问题区域 -->
|
|
|
|
|
|
<view class="quick-questions">
|
|
|
|
|
|
<view class="quick-btn" @click="sendQuickQuestion('透析患者可以喝牛奶吗?')">
|
|
|
|
|
|
<text>透析患者可以喝牛奶吗?</text>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
<view class="quick-btn" @click="sendQuickQuestion('什么食物含磷比较低?')">
|
|
|
|
|
|
<text>什么食物含磷比较低?</text>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
<!-- 继续上传按钮 -->
|
|
|
|
|
|
<view class="preview-item add-btn" @click="chooseImage" v-if="pendingImages.length < 3">
|
|
|
|
|
|
<text class="add-icon">+</text>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 语音切换按钮 -->
|
|
|
|
|
|
<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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 文本输入框 -->
|
|
|
|
|
|
<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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 发送按钮 -->
|
|
|
|
|
|
<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>
|
2026-03-03 00:36:28 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</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营养师助手。
|
|
|
|
|
|
|
|
|
|
|
|
我可以帮您:
|
|
|
|
|
|
• 解答饮食疑问
|
|
|
|
|
|
• 评估食物适宜性
|
|
|
|
|
|
• 提供烹饪建议
|
|
|
|
|
|
• 解读检验报告
|
|
|
|
|
|
|
|
|
|
|
|
有什么想问的吗?`,
|
2026-02-28 21:12:03 +08:00
|
|
|
|
messageList: []
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-03 00:36:28 +08:00
|
|
|
|
/** 将本地图片转为 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
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-28 05:40:21 +08:00
|
|
|
|
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];
|
2026-03-03 00:36:28 +08:00
|
|
|
|
const text = this.inputText.trim();
|
|
|
|
|
|
this.pendingImages = [];
|
|
|
|
|
|
this.inputText = '';
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-03-03 00:36:28 +08:00
|
|
|
|
// 先展示用户消息到界面
|
2026-02-28 05:40:21 +08:00
|
|
|
|
for (const img of imagesToSend) {
|
|
|
|
|
|
this.messageList.push({
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
type: 'image',
|
|
|
|
|
|
imageUrl: img.path,
|
|
|
|
|
|
content: '[图片]'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-03-03 00:36:28 +08:00
|
|
|
|
if (text) {
|
|
|
|
|
|
this.messageList.push({ role: 'user', content: text, type: 'text' });
|
|
|
|
|
|
}
|
|
|
|
|
|
this.scrollToBottom();
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-03-03 00:36:28 +08:00
|
|
|
|
// 合并为一次多模态请求:图片(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;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 00:36:28 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-09 18:56:53 +08:00
|
|
|
|
/** 从 Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text }) */
|
2026-03-04 12:21:29 +08:00
|
|
|
|
extractReplyContent(content) {
|
|
|
|
|
|
if (content == null) return '';
|
|
|
|
|
|
if (typeof content === 'string') return content;
|
|
|
|
|
|
if (Array.isArray(content)) {
|
|
|
|
|
|
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
|
|
|
|
|
|
}
|
2026-03-09 18:56:53 +08:00
|
|
|
|
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;
|
2026-03-07 22:26:37 +08:00
|
|
|
|
}
|
2026-03-04 12:21:29 +08:00
|
|
|
|
return String(content);
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-25 15:32:53 +08:00
|
|
|
|
/** 工具方法:sleep ms 毫秒 */
|
|
|
|
|
|
sleep(ms) {
|
|
|
|
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-25 17:21:12 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 解包 Coze API 响应:后端返回双层包装 { code, data: { code, data: actualPayload } }
|
|
|
|
|
|
* 此方法统一提取最内层的业务数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
unwrapCozeResponse(response) {
|
|
|
|
|
|
if (!response) return null;
|
|
|
|
|
|
let data = response.data;
|
|
|
|
|
|
if (data && typeof data === 'object' && data.code !== undefined && data.data !== undefined) {
|
|
|
|
|
|
data = data.data;
|
|
|
|
|
|
}
|
|
|
|
|
|
return data;
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-02-28 05:40:21 +08:00
|
|
|
|
async sendToAI(content, type) {
|
2026-03-03 00:36:28 +08:00
|
|
|
|
this.isLoading = true;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-03-25 15:32:53 +08:00
|
|
|
|
// 添加 AI 占位消息(loading 状态,等待 Coze 返回后填充内容)
|
|
|
|
|
|
const aiMsg = { role: 'ai', content: '', loading: true };
|
|
|
|
|
|
this.messageList.push(aiMsg);
|
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
|
2026-03-25 14:54:31 +08:00
|
|
|
|
// 统一走 Coze API(文本、多模态、图片均使用 Coze Bot)
|
2026-03-03 00:36:28 +08:00
|
|
|
|
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
|
2026-02-28 05:40:21 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const messages = [];
|
2026-03-25 14:54:31 +08:00
|
|
|
|
if (type === 'text') {
|
|
|
|
|
|
// 纯文字消息
|
2026-03-03 00:36:28 +08:00
|
|
|
|
messages.push({
|
|
|
|
|
|
role: 'user',
|
2026-03-25 14:54:31 +08:00
|
|
|
|
content: typeof content === 'string' ? content : JSON.stringify(content),
|
2026-02-28 05:40:21 +08:00
|
|
|
|
content_type: 'text'
|
|
|
|
|
|
});
|
2026-03-25 14:54:31 +08:00
|
|
|
|
} else if (type === 'multimodal') {
|
|
|
|
|
|
// 图文混合:content 为 parts 数组 [{ type: 'text', text }, { type: 'image_url', ... }]
|
|
|
|
|
|
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
|
|
|
|
|
const textPart = parts.find(p => p && p.type === 'text');
|
|
|
|
|
|
const imgPart = parts.find(p => p && (p.type === 'image_url' || p.type === 'image'));
|
|
|
|
|
|
if (imgPart) {
|
|
|
|
|
|
const fileId = imgPart.file_id || (imgPart.image_url && imgPart.image_url.url) || '';
|
|
|
|
|
|
messages.push({
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: JSON.stringify([
|
|
|
|
|
|
...(textPart ? [{ type: 'text', text: textPart.text }] : []),
|
|
|
|
|
|
{ type: 'image', file_id: fileId }
|
|
|
|
|
|
]),
|
|
|
|
|
|
content_type: 'object_string'
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messages.push({
|
|
|
|
|
|
role: 'user',
|
|
|
|
|
|
content: textPart ? textPart.text : JSON.stringify(content),
|
|
|
|
|
|
content_type: 'text'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 图片路径(旧路径,content 为 fileInfo 对象)
|
|
|
|
|
|
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'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
const requestData = {
|
|
|
|
|
|
botId: this.botId,
|
|
|
|
|
|
userId: userId,
|
|
|
|
|
|
additionalMessages: messages,
|
|
|
|
|
|
stream: false,
|
|
|
|
|
|
autoSaveHistory: true
|
|
|
|
|
|
};
|
2026-03-03 00:36:28 +08:00
|
|
|
|
if (this.conversationId) requestData.conversationId = this.conversationId;
|
2026-03-25 17:21:12 +08:00
|
|
|
|
const response = await api.cozeChat(requestData);
|
|
|
|
|
|
const cozeData = this.unwrapCozeResponse(response);
|
|
|
|
|
|
if (cozeData) {
|
|
|
|
|
|
const chat = cozeData.chat || cozeData;
|
2026-03-03 00:36:28 +08:00
|
|
|
|
const conversationId = chat.conversation_id || chat.conversationID || chat.conversationId;
|
|
|
|
|
|
const chatId = chat.id;
|
|
|
|
|
|
if (conversationId && chatId) {
|
|
|
|
|
|
this.conversationId = conversationId;
|
2026-03-25 15:32:53 +08:00
|
|
|
|
await this.pollChatStatus(conversationId, chatId, aiMsg);
|
2026-03-03 00:36:28 +08:00
|
|
|
|
} else {
|
2026-03-25 17:21:12 +08:00
|
|
|
|
console.error('Coze chat response structure:', JSON.stringify(response));
|
2026-03-03 00:36:28 +08:00
|
|
|
|
throw new Error('发起对话失败:未返回会话或对话ID');
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('发起对话失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('发送消息失败:', error);
|
|
|
|
|
|
this.isLoading = false;
|
2026-03-25 15:32:53 +08:00
|
|
|
|
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
|
|
|
|
|
aiMsg.loading = false;
|
|
|
|
|
|
this.messageList = [...this.messageList]; // 触发响应式更新
|
2026-02-28 05:40:21 +08:00
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-25 15:32:53 +08:00
|
|
|
|
async pollChatStatus(conversationId, chatId, aiMsg) {
|
|
|
|
|
|
const maxAttempts = 60; // 最多轮询60次(每次1.5秒),即90秒
|
2026-02-28 05:40:21 +08:00
|
|
|
|
let attempts = 0;
|
|
|
|
|
|
|
|
|
|
|
|
const checkStatus = async () => {
|
|
|
|
|
|
attempts++;
|
|
|
|
|
|
if (attempts > maxAttempts) {
|
|
|
|
|
|
this.isLoading = false;
|
2026-03-25 15:32:53 +08:00
|
|
|
|
if (aiMsg) { aiMsg.content = '抱歉,AI 响应超时,请稍后再试。'; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
|
|
|
|
|
else { this.messageList.push({ role: 'ai', content: '抱歉,AI 响应超时,请稍后再试。' }); }
|
2026-02-28 05:40:21 +08:00
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-25 17:21:12 +08:00
|
|
|
|
const res = await api.cozeRetrieveChat({
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
chatId
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const retrieveData = this.unwrapCozeResponse(res);
|
|
|
|
|
|
if (retrieveData) {
|
|
|
|
|
|
console.log("====api.cozeRetrieveChat response====", retrieveData);
|
|
|
|
|
|
const chatObj = retrieveData.chat || retrieveData;
|
|
|
|
|
|
const status = chatObj && chatObj.status;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-03-25 15:32:53 +08:00
|
|
|
|
if (status === 'completed') {
|
|
|
|
|
|
// 对话完成,获取消息详情
|
|
|
|
|
|
await this.getChatMessages(conversationId, chatId, aiMsg);
|
|
|
|
|
|
} else if (status === 'failed' || status === 'canceled') {
|
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
|
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}。`;
|
|
|
|
|
|
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
|
|
|
|
|
else { this.messageList.push({ role: 'ai', content: failMsg }); }
|
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 继续轮询 (created, in_progress) 每 1.5 秒
|
|
|
|
|
|
setTimeout(checkStatus, 1500);
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 查询失败,重试
|
|
|
|
|
|
setTimeout(checkStatus, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('查询对话状态失败:', e);
|
|
|
|
|
|
setTimeout(checkStatus, 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
checkStatus();
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-03-25 15:32:53 +08:00
|
|
|
|
async getChatMessages(conversationId, chatId, aiMsg) {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
try {
|
2026-03-25 17:21:12 +08:00
|
|
|
|
const res = await api.cozeMessageList({
|
|
|
|
|
|
conversationId,
|
|
|
|
|
|
chatId
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
this.isLoading = false;
|
|
|
|
|
|
const msgData = this.unwrapCozeResponse(res);
|
|
|
|
|
|
console.log("====api.cozeMessageList response====", msgData);
|
|
|
|
|
|
const rawMessages = msgData && (Array.isArray(msgData.messages) ? msgData.messages : (Array.isArray(msgData) ? msgData : null));
|
2026-03-03 00:36:28 +08:00
|
|
|
|
if (rawMessages && rawMessages.length > 0) {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
// 过滤出 type='answer' 且 role='assistant' 的消息
|
2026-03-03 00:36:28 +08:00
|
|
|
|
const answerMsgs = rawMessages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
if (answerMsgs.length > 0) {
|
2026-03-25 15:32:53 +08:00
|
|
|
|
// 用第一条 answer 消息填充占位气泡,多条追加新气泡
|
|
|
|
|
|
if (aiMsg) {
|
|
|
|
|
|
aiMsg.content = answerMsgs[0].content;
|
|
|
|
|
|
aiMsg.loading = false;
|
|
|
|
|
|
for (let i = 1; i < answerMsgs.length; i++) {
|
|
|
|
|
|
this.messageList.push({ role: 'ai', content: answerMsgs[i].content });
|
|
|
|
|
|
}
|
|
|
|
|
|
this.messageList = [...this.messageList]; // 触发响应式更新
|
|
|
|
|
|
} else {
|
|
|
|
|
|
for (const msg of answerMsgs) { this.messageList.push({ role: 'ai', content: msg.content }); }
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 尝试查找其他类型的回复
|
2026-03-03 00:36:28 +08:00
|
|
|
|
const otherMsgs = rawMessages.filter(msg => msg.role === 'assistant');
|
2026-03-25 15:32:53 +08:00
|
|
|
|
const fallback = otherMsgs.length > 0 ? otherMsgs[0].content : '未能获取到有效回复。';
|
|
|
|
|
|
if (aiMsg) { aiMsg.content = fallback; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
|
|
|
|
|
else { this.messageList.push({ role: 'ai', content: fallback }); }
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('获取消息列表失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error('获取消息详情失败:', e);
|
|
|
|
|
|
this.isLoading = false;
|
2026-03-25 15:32:53 +08:00
|
|
|
|
const errContent = '获取回复内容失败。';
|
|
|
|
|
|
if (aiMsg) { aiMsg.content = errContent; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
|
|
|
|
|
else { this.messageList.push({ role: 'ai', content: errContent }); }
|
2026-02-28 05:40:21 +08:00
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 00:36:28 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-28 05:40:21 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 打字指示器 */
|
|
|
|
|
|
.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>
|