159 lines
9.1 KiB
Vue
159 lines
9.1 KiB
Vue
<template>
|
||
<view class="chat-page">
|
||
<scroll-view scroll-y class="chat-content" :scroll-into-view="bottomId">
|
||
<view class="message-list">
|
||
<view v-for="(item, index) in messages" :key="item._id" :id="`message-${item._id}`" class="message-item" :class="`message-${item.sender}`">
|
||
<view v-if="shouldShowTime(item, index)" class="time-divider">{{ formatMessageTime(item.createTime) }}</view>
|
||
<evaluation-card v-if="item.messageType === 'consult_ended' && (item.rateId || session?.rateId)" class="rate-card" :extension="{ rateId: item.rateId || session.rateId }" :corp-id="corpId" :doctor-info="rateAssistantInfo" />
|
||
<view v-else-if="item.sender === 'system' && item.messageType !== 'risk_notice'" class="system-message" :class="{ warn: item.messageType === 'sensitive_notice' }">{{ displayContent(item) }}</view>
|
||
<view v-else-if="item.messageType !== 'risk_notice'" class="message-content">
|
||
<view v-if="item.sender === 'assistant'" class="avatar assistant-avatar">AI</view>
|
||
<view class="message-bubble-container">
|
||
<view v-if="item.sender === 'assistant'" class="username-label">{{ session?.assistantName || 'AI咨询助理' }}</view>
|
||
<view class="message-bubble" :class="{ pending: item._sendStatus === 'pending' }">
|
||
<text class="message-text">{{ displayContent(item) }}</text>
|
||
</view>
|
||
<text v-if="item._sendStatus === 'failed'" class="send-status">发送失败,请重试</text>
|
||
</view>
|
||
<view v-if="item.sender === 'patient'" class="avatar patient-avatar">我</view>
|
||
</view>
|
||
</view>
|
||
<view v-if="waitingAi" class="message-item message-assistant">
|
||
<view class="message-content"><view class="avatar assistant-avatar">AI</view><view class="message-bubble-container"><view class="username-label">{{ session?.assistantName || 'AI咨询助理' }}</view><view class="message-bubble thinking"><view class="thinking-dot"></view><view class="thinking-dot"></view><view class="thinking-dot"></view></view></view></view>
|
||
</view>
|
||
<view id="bottom" />
|
||
</view>
|
||
</scroll-view>
|
||
<view v-if="active" class="input-section">
|
||
<textarea v-model="content" class="text-input" maxlength="1000" auto-height confirm-type="send" :show-confirm-bar="false" placeholder="请输入消息" @confirm="send" />
|
||
<button class="send-btn" :disabled="!content.trim()" @click="send">发送</button>
|
||
</view>
|
||
<view v-else class="ended">本次AI咨询已结束</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, nextTick, computed } from 'vue';
|
||
import { onLoad, onShow } from '@dcloudio/uni-app';
|
||
import api from '@/utils/api';
|
||
import useAccountStore from '@/store/account';
|
||
import { storeToRefs } from 'pinia';
|
||
import EvaluationCard from '@/pages/message/components/special-message/evaluation.vue';
|
||
|
||
const { openid } = storeToRefs(useAccountStore());
|
||
const corpId = ref('');
|
||
const sessionId = ref('');
|
||
const session = ref(null);
|
||
const messages = ref([]);
|
||
const content = ref('');
|
||
const pendingRequestCount = ref(0);
|
||
const waitingAi = ref(false);
|
||
const bottomId = ref('');
|
||
const active = ref(false);
|
||
const rateAssistantInfo = computed(() => ({ name: session.value?.assistantName || 'AI咨询助理', title: 'AI咨询助理', department: session.value?.teamName || '', avatar: '' }));
|
||
|
||
function displayContent(item) {
|
||
const value = item?.content || '';
|
||
if (item?.sender !== 'assistant' || typeof value !== 'string') return value;
|
||
const contents = [];
|
||
const matcher = /"streamContent"\s*:\s*"((?:\\.|[^"\\])*)"/g;
|
||
let match;
|
||
while ((match = matcher.exec(value))) {
|
||
try {
|
||
const text = JSON.parse(`"${match[1]}"`);
|
||
if (text) contents.push(text);
|
||
} catch (error) {
|
||
// 兼容历史会话中无法解析的单段协议消息。
|
||
}
|
||
}
|
||
return contents.join('') || value;
|
||
}
|
||
function formatMessageTime(value) {
|
||
const date = new Date(Number(value));
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
function shouldShowTime(item, index) {
|
||
if (index === 0) return Boolean(item?.createTime);
|
||
return Number(item?.createTime) - Number(messages.value[index - 1]?.createTime) > 5 * 60 * 1000;
|
||
}
|
||
async function scrollToBottom() {
|
||
bottomId.value = '';
|
||
await nextTick();
|
||
bottomId.value = 'bottom';
|
||
}
|
||
async function detail() {
|
||
const res = await api('getAiConsultSessionDetail', { corpId: corpId.value, sessionId: sessionId.value, miniAppId: openid.value || uni.getStorageSync('openid') }, false);
|
||
if (!res?.success) return uni.showToast({ title: res?.message || '加载失败', icon: 'none' });
|
||
session.value = res.data.session;
|
||
messages.value = res.data.messages || [];
|
||
active.value = session.value.status === 'active';
|
||
uni.setNavigationBarTitle({ title: session.value.assistantName || 'AI咨询助理' });
|
||
await scrollToBottom();
|
||
}
|
||
async function send() {
|
||
if (!active.value || !content.value.trim()) return;
|
||
const value = content.value.trim();
|
||
const localMessage = { _id: `local-${Date.now()}`, sender: 'patient', content: value, messageType: 'text', createTime: Date.now(), _sendStatus: 'pending' };
|
||
messages.value.push(localMessage);
|
||
content.value = '';
|
||
pendingRequestCount.value += 1;
|
||
waitingAi.value = true;
|
||
await scrollToBottom();
|
||
try {
|
||
const res = await api('sendAiConsultMessage', { corpId: corpId.value, sessionId: sessionId.value, customerId: session.value.customerId, miniAppId: openid.value || uni.getStorageSync('openid'), content: value }, false);
|
||
if (!res?.success) {
|
||
localMessage._sendStatus = 'failed';
|
||
uni.showToast({ title: res?.message || '发送失败', icon: 'none' });
|
||
return;
|
||
}
|
||
await detail();
|
||
} catch (error) {
|
||
localMessage._sendStatus = 'failed';
|
||
uni.showToast({ title: '发送失败,请重试', icon: 'none' });
|
||
} finally {
|
||
pendingRequestCount.value -= 1;
|
||
waitingAi.value = pendingRequestCount.value > 0;
|
||
await scrollToBottom();
|
||
}
|
||
}
|
||
|
||
onLoad(opts => { corpId.value = opts.corpId; sessionId.value = opts.sessionId; detail(); });
|
||
onShow(detail);
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-page { height: 100vh; display: flex; flex-direction: column; background: #f5f7fb; overflow: hidden; }
|
||
.chat-content { flex: 1; height: 0; box-sizing: border-box; }
|
||
.message-list { padding: 28rpx 24rpx 40rpx; }
|
||
.message-item { margin-bottom: 28rpx; }
|
||
.message-content { display: flex; align-items: flex-start; gap: 16rpx; }
|
||
.message-patient .message-content { justify-content: flex-end; }
|
||
.avatar { width: 72rpx; height: 72rpx; flex: 0 0 72rpx; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 24rpx; font-weight: 600; }
|
||
.assistant-avatar { color: #fff; background: linear-gradient(135deg, #4d93ff, #0877f1); }
|
||
.patient-avatar { color: #0877f1; background: #e2efff; }
|
||
.message-bubble-container { max-width: 74%; min-width: 0; }
|
||
.message-patient .message-bubble-container { display: flex; flex-direction: column; align-items: flex-end; }
|
||
.username-label { margin: 2rpx 0 10rpx; color: #8a919f; font-size: 24rpx; line-height: 32rpx; }
|
||
.message-bubble { padding: 18rpx 22rpx; border-radius: 8rpx 22rpx 22rpx; background: #fff; box-shadow: 0 4rpx 14rpx rgba(17, 48, 89, .06); }
|
||
.message-patient .message-bubble { color: #fff; border-radius: 22rpx 8rpx 22rpx 22rpx; background: #0877f1; box-shadow: 0 4rpx 14rpx rgba(8, 119, 241, .18); }
|
||
.message-bubble.pending { opacity: .72; }
|
||
.message-text { color: inherit; font-size: 30rpx; line-height: 46rpx; white-space: pre-wrap; word-break: break-word; }
|
||
.time-divider { margin: 2rpx 0 22rpx; color: #a0a6b0; text-align: center; font-size: 23rpx; }
|
||
.system-message { display: inline-block; margin: 0 auto; padding: 10rpx 20rpx; color: #79808c; background: #e9ecf1; border-radius: 22rpx; font-size: 24rpx; line-height: 36rpx; }
|
||
.message-system { text-align: center; }
|
||
.system-message.warn { color: #d84256; background: #fff0f1; }
|
||
.send-status { margin-top: 8rpx; color: #e34d59; font-size: 23rpx; }
|
||
.thinking { display: flex; align-items: center; gap: 8rpx; min-width: 84rpx; padding: 26rpx 28rpx; }
|
||
.thinking-dot { width: 10rpx; height: 10rpx; border-radius: 50%; background: #7f8998; animation: thinking 1.2s infinite ease-in-out; }
|
||
.thinking-dot:nth-child(2) { animation-delay: .16s; }
|
||
.thinking-dot:nth-child(3) { animation-delay: .32s; }
|
||
@keyframes thinking { 0%, 60%, 100% { opacity: .35; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-7rpx); } }
|
||
.input-section { display: flex; align-items: flex-end; gap: 16rpx; padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom)); background: #fff; border-top: 1rpx solid #edf0f4; }
|
||
.text-input { flex: 1; max-height: 180rpx; padding: 16rpx 20rpx; box-sizing: border-box; color: #283242; background: #f3f5f8; border-radius: 12rpx; font-size: 29rpx; line-height: 40rpx; }
|
||
.send-btn { flex: 0 0 auto; height: 72rpx; margin: 0; padding: 0 26rpx; color: #fff; background: #0877f1; border-radius: 12rpx; font-size: 27rpx; line-height: 72rpx; }
|
||
.send-btn[disabled] { color: #fff; background: #a9cfff; }
|
||
.rate-card { display: block; margin: 20rpx 0 0; }
|
||
.ended { padding: 30rpx; color: #7d8590; text-align: center; background: #fff; font-size: 27rpx; }
|
||
</style>
|