218 lines
12 KiB
Vue
218 lines
12 KiB
Vue
<template>
|
||
<page-meta page-style="overflow: hidden;"></page-meta>
|
||
<view class="chat-page" :style="{ bottom: `${keyboardHeight}px` }">
|
||
<view v-if="session" class="patient-info-bar" @click="openPatientInfo">
|
||
<view class="patient-summary">
|
||
<text class="patient-name">{{ patientDisplayName }}</text>
|
||
<text v-if="patientDetail" class="patient-detail">{{ patientDetail }}</text>
|
||
</view>
|
||
<text class="patient-action">查看/修改</text>
|
||
<text class="patient-arrow">›</text>
|
||
</view>
|
||
<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">
|
||
<image v-if="item.sender === 'assistant'" class="avatar assistant-avatar" :src="assistantAvatar" mode="aspectFill" @error="handleAssistantAvatarError" />
|
||
<view class="message-bubble-container">
|
||
<view v-if="item.sender === 'assistant'" class="username-label">{{ assistantDisplayName }}</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"><image class="avatar assistant-avatar" :src="assistantAvatar" mode="aspectFill" @error="handleAssistantAvatarError" /><view class="message-bubble-container"><view class="username-label">{{ assistantDisplayName }}</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 fixed confirm-type="send" :show-confirm-bar="false" :adjust-position="false" placeholder="请输入消息" @confirm="send" />
|
||
<button class="send-btn" :disabled="!content.trim()" @click="send">发送</button>
|
||
</view>
|
||
<view v-else class="ended">本次咨询已结束</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, nextTick, computed } from 'vue';
|
||
import { onLoad, onShow, onUnload } 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';
|
||
import { removeAiLabel } from '@/utils/ai-consult-display';
|
||
|
||
const { openid } = storeToRefs(useAccountStore());
|
||
const corpId = ref('');
|
||
const sessionId = ref('');
|
||
const session = ref(null);
|
||
const patientInfo = ref(null);
|
||
const messages = ref([]);
|
||
const content = ref('');
|
||
const pendingRequestCount = ref(0);
|
||
const waitingAi = ref(false);
|
||
const bottomId = ref('');
|
||
const active = ref(false);
|
||
const keyboardHeight = ref(0);
|
||
const avatarLoadFailed = ref(false);
|
||
const defaultAssistantAvatar = '/static/home/ai-consult-assistant.png';
|
||
const assistantDisplayName = computed(() => removeAiLabel(session.value?.assistantName, '咨询助理'));
|
||
const assistantAvatar = computed(() => avatarLoadFailed.value ? defaultAssistantAvatar : (session.value?.assistantAvatar || defaultAssistantAvatar));
|
||
const rateAssistantInfo = computed(() => ({ name: assistantDisplayName.value, title: '咨询助理', department: session.value?.teamName || '', avatar: assistantAvatar.value }));
|
||
const patientDisplayName = computed(() => patientInfo.value?.name || session.value?.customerName || '未建档患者');
|
||
const patientDetail = computed(() => {
|
||
const patient = patientInfo.value || {};
|
||
const details = [];
|
||
if (patient.sex) details.push(patient.sex);
|
||
if (patient.age !== '' && patient.age !== null && patient.age !== undefined) details.push(`${patient.age}岁`);
|
||
if (patient.relationship) details.push(patient.relationship);
|
||
return details.join(' · ');
|
||
});
|
||
|
||
function displayContent(item) {
|
||
const value = item?.content || '';
|
||
if (typeof value !== 'string' || item?.sender === 'patient') return value;
|
||
if (item?.sender !== 'assistant') return removeAiLabel(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 removeAiLabel(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';
|
||
}
|
||
function handleKeyboardHeightChange(res) {
|
||
keyboardHeight.value = Math.max(0, Number(res?.height) || 0);
|
||
if (keyboardHeight.value > 0) scrollToBottom();
|
||
}
|
||
function handleAssistantAvatarError() {
|
||
avatarLoadFailed.value = true;
|
||
}
|
||
function openPatientInfo() {
|
||
const customerId = session.value?.archiveCustomerId;
|
||
if (!customerId) return uni.showToast({ title: '暂无可查看的患者档案', icon: 'none' });
|
||
const params = [
|
||
`teamId=${encodeURIComponent(session.value?.teamId || '')}`,
|
||
`corpId=${encodeURIComponent(corpId.value)}`,
|
||
`id=${encodeURIComponent(customerId)}`,
|
||
];
|
||
uni.navigateTo({ url: `/pages/archive/edit-archive?${params.join('&')}` });
|
||
}
|
||
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: removeAiLabel(res?.message, '加载失败'), icon: 'none' });
|
||
session.value = res.data.session;
|
||
avatarLoadFailed.value = false;
|
||
patientInfo.value = res.data.patient || null;
|
||
messages.value = res.data.messages || [];
|
||
active.value = session.value.status === 'active';
|
||
uni.setNavigationBarTitle({ title: assistantDisplayName.value });
|
||
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: removeAiLabel(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;
|
||
uni.onKeyboardHeightChange(handleKeyboardHeightChange);
|
||
detail();
|
||
});
|
||
onShow(detail);
|
||
onUnload(() => {
|
||
uni.offKeyboardHeightChange(handleKeyboardHeightChange);
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chat-page { position: fixed; top: 0; right: 0; left: 0; display: flex; flex-direction: column; background: #f5f7fb; overflow: hidden; }
|
||
.patient-info-bar { height: 80rpx; padding: 0 24rpx; display: flex; align-items: center; box-sizing: border-box; background: #fff; border-bottom: 1rpx solid #edf0f4; flex-shrink: 0; }
|
||
.patient-summary { display: flex; align-items: center; gap: 16rpx; min-width: 0; flex: 1; }
|
||
.patient-name { max-width: 220rpx; color: #283242; font-size: 30rpx; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.patient-detail { min-width: 0; color: #7d8590; font-size: 27rpx; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.patient-action { margin-left: 16rpx; color: #0877f1; font-size: 26rpx; white-space: nowrap; flex-shrink: 0; }
|
||
.patient-arrow { margin-left: 6rpx; color: #0877f1; font-size: 34rpx; line-height: 1; flex-shrink: 0; }
|
||
.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: 26rpx; 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: 26rpx; line-height: 34rpx; }
|
||
.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: 32rpx; line-height: 48rpx; white-space: pre-wrap; word-break: break-word; }
|
||
.time-divider { margin: 2rpx 0 22rpx; color: #a0a6b0; text-align: center; font-size: 25rpx; }
|
||
.system-message { display: inline-block; margin: 0 auto; padding: 10rpx 20rpx; color: #79808c; background: #e9ecf1; border-radius: 22rpx; font-size: 26rpx; line-height: 38rpx; }
|
||
.message-system { text-align: center; }
|
||
.system-message.warn { color: #d84256; background: #fff0f1; }
|
||
.send-status { margin-top: 8rpx; color: #e34d59; font-size: 25rpx; }
|
||
.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: 31rpx; line-height: 42rpx; }
|
||
.send-btn { flex: 0 0 auto; height: 72rpx; margin: 0; padding: 0 26rpx; color: #fff; background: #0877f1; border-radius: 12rpx; font-size: 29rpx; 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: 29rpx; }
|
||
</style>
|