Files
xk-admin/apps/web-antd/src/views/business/chat/components/MessageBubble.vue
李琦 e6306eea75
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
1. 加工费模块重构
2. 快递费模块重构
3. 订单导出功能优化
4. 商品订单的折扣功能
5. 修复了一些已知BUG
2026-06-24 08:11:15 +08:00

1428 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Avatar, Button, Image } from 'ant-design-vue';
import { getChatMessageRegisterInfoApi } from '#/views/business/chat/api';
import { useChatStore } from '#/views/business/chat/stores/chat';
import { sendMessage } from '#/views/business/chat/utils/request';
// 辅助函数:按需解析消息内容
const getParsedContent = (messageType, messageContent) => {
// 对于需要 JSON 解析的消息类型4, 9, 10, 11, 12, 13, 14
if ([4, 9, 10, 11, 12, 13, 14].includes(messageType)) {
try {
return JSON.parse(messageContent);
} catch (e) {
console.error('解析消息内容失败:', e);
return messageContent;
}
}
return messageContent;
};
// 辅助函数:获取消息类型名称
const getMessageTypeName = (messageType) => {
const types = {
0: 'text',
1: 'image',
2: 'audio',
3: 'video',
4: 'prescription',
5: 'medical-record',
6: 'video-call',
7: 'audio-call',
8: 'file',
9: 'system',
10: 'register',
11: 'patient-experience',
12: 'product-card',
13: 'end-consultation',
14: 'transfer-prescription',
};
return types[messageType] || 'unknown';
};
import { getPrescriptionCheckStatusApi } from '#/views/business/order/prescription/api';
import { receptionApi } from '#/views/doctor/doctor-reception/api';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import RefusalOfTreatmentModal from '#/views/doctor/doctor-reception/components/RefusalOfTreatmentModal.vue';
import previewMedia from '../composables/useMediaPreview.ts';
import { useThemeStore } from '../stores/theme.ts';
import { useUserStore as chatUseUserStore } from '../stores/user';
import AudioMessage from './AudioMessage.vue';
import { XgVideoPlayer } from '#/components/video-player';
import PatientExperienceCard from '#/views/doctor/online-consultation/components/PatientExperienceCard.vue';
import FollowUpDrugAssistantCard from './FollowUpDrugAssistantCard.vue';
import ProductCard from '#/views/doctor/online-consultation/components/ProductCard.vue';
import EndConsultationCard from '#/views/doctor/online-consultation/components/EndConsultationCard.vue';
import TransferPrescriptionMessageCard from '#/views/doctor/online-consultation/components/TransferPrescriptionMessageCard.vue';
import TransferPrescriptionViewDrawer from '#/views/doctor/online-consultation/components/TransferPrescriptionViewDrawer.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
isSent: {
type: Boolean,
default: false,
},
senderInfo: {
type: Object,
required: true,
},
});
const emit = defineEmits(['show-user-profile', 'open-prescription', 'transfer-import-success']);
const chatUserStore = chatUseUserStore();
const userStore = useUserStore();
const chatStore = useChatStore();
const themeStore = useThemeStore();
// 转接方查看抽屉相关
const showTransferDrawer = ref(false);
const transferRegisterId = ref(null);
const [RefusalOfTreatmentModals, RefusalOfTreatmentModalApi] = useVbenModal({
connectedComponent: RefusalOfTreatmentModal,
});
const avatarStyle = computed(() => ({
background: props.senderInfo.color,
}));
const statusInfo = ref({
status: 0,
text: '',
});
// 挂号操作状态
const registerOperating = ref(false);
// 添加处方状态映射
const prescriptionStatusMap = {
0: '待审核',
1: '已审核',
2: '未通过',
};
// 添加挂号状态映射 0待支付1已支付待接诊2接诊中3已结束4已取消5待评价6已评价7已拒诊
const registerStatusMap = {
0: '待支付',
1: '待接诊',
2: '接诊中',
3: '已结束',
4: '已取消',
5: '待评价',
6: '已评价',
7: '已拒诊',
};
// 添加支付状态映射
const payStatusMap = {
0: '未支付',
1: '已支付',
};
const checkPrescriptionStatus = (parsedContent) => {
// 优先使用 message_content 中的 status 字段(由定时任务更新)
if (parsedContent && parsedContent.status !== undefined) {
statusInfo.value.status = parsedContent.status;
statusInfo.value.text = prescriptionStatusMap[parsedContent.status];
return prescriptionStatusMap[parsedContent.status];
}
// 如果 status 不存在,才调用 API作为备用方案
if (parsedContent && parsedContent.id) {
getPrescriptionCheckStatusApi({ id: parsedContent.id }).then((res) => {
statusInfo.value.status = res.status;
statusInfo.value.text = prescriptionStatusMap[res.status];
return prescriptionStatusMap[res.status];
});
}
};
// 计算属性:解析后的消息内容
const parsedContent = computed(() => {
// 确保 message_content 字段存在,优先使用 message_content其次使用 content
const messageContent = props.message.message_content || props.message.content || '';
return getParsedContent(props.message.message_type, messageContent);
});
// 计算属性:消息类型名称
const messageTypeName = computed(() => {
return getMessageTypeName(props.message.message_type);
});
/** type10接口补全的挂号卡字段不写回 props避免破坏响应式 */
const registerCardPayload = ref(null);
const registerCardDisplay = computed(() => {
if (props.message.message_type !== 10) {
return {};
}
let base = {};
const raw = props.message.message_content || props.message.content || '';
try {
base =
typeof raw === 'string' && raw
? JSON.parse(raw)
: typeof raw === 'object' && raw
? { ...raw }
: {};
} catch {
base = {};
}
const enrich = registerCardPayload.value;
if (enrich && typeof enrich === 'object' && !Array.isArray(enrich)) {
return { ...base, ...enrich };
}
return base;
});
const registerDrugNamesLine = computed(() => {
const pc = registerCardDisplay.value;
if (!pc || typeof pc !== 'object') return '';
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
return arr.map((d) => d?.name).filter(Boolean).join('、');
}
if (pc.drug?.name) return pc.drug.name;
return '';
});
const isFollowUpDrugAssistant = computed(() => {
if (props.message.message_type !== 11) return false;
const pc = parsedContent.value;
return !!(pc && typeof pc === 'object' && pc.flow === 'follow_up_drug');
});
function fetchRegisterCard() {
if (props.message.message_type !== 10) return;
const content = getParsedContent(
props.message.message_type,
props.message.message_content,
);
const id = content && typeof content === 'object' ? content.id : undefined;
if (!id) return;
getChatMessageRegisterInfoApi({ id }).then((res) => {
const body = res?.data?.data ?? res?.data ?? res;
registerCardPayload.value =
body && typeof body === 'object' && !Array.isArray(body) ? body : null;
});
}
watch(
() => [
props.message.message_type,
props.message.id,
props.message.message_content,
],
() => {
registerCardPayload.value = null;
fetchRegisterCard();
},
{ immediate: true },
);
// 接诊操作
const acceptRegister = async (registerId, orderNo) => {
if (registerOperating.value) return;
registerOperating.value = true;
try {
// 模拟API调用
receptionApi(registerId).then(async (value) => {
// message.success('接诊成功');
// 更新消息状态
fetchRegisterCard();
const messageObj = {
message_type: 0,
message_content: '我已接诊,请简单说明您的情况~',
created_at_text: new Date().toLocaleTimeString().slice(0, 5),
sender_user_id: `doctor-${chatUserStore.currentUser.doctor_id}`,
receiver_user_id: chatStore.currentFriend.id,
isSent: true,
read: true,
duration: 0,
};
localStorage.setItem('currentRegisterId', String(registerId));
// chatStore.currentFriend?.register_id = reregisterId;
chatStore.addMessage(messageObj, chatUserStore.currentUser.doctor_id);
await sendMessage({
senderId: chatUserStore.currentUser.doctor_id,
receiverId: chatStore.currentFriend.id,
roomId: chatStore.currentFriend.room_id,
type: 'text',
content: '我已接诊,请简单说明您的情况~',
});
});
} catch (error) {
console.error('接诊失败:', error);
} finally {
registerOperating.value = false;
}
};
// 查看转接方详情
const handleViewTransferDetail = (transferData) => {
transferRegisterId.value = chatStore.currentFriend?.register_id;
showTransferDrawer.value = true;
};
// 处理转接方导入成功
const handleTransferImportSuccess = (prescriptionData) => {
// 触发打开处方模态框事件
const registerId = transferRegisterId.value || chatStore.currentFriend?.register_id;
if (registerId) {
emit('open-prescription', registerId);
}
};
// 拒诊操作
const rejectRegister = async (registerId, orderNo) => {
if (registerOperating.value) return;
registerOperating.value = true;
RefusalOfTreatmentModalApi.setData({
values: {
register_id: registerId,
},
});
RefusalOfTreatmentModalApi.open();
registerOperating.value = false;
};
const bubbleClasses = computed(() => {
const baseClasses = ['message-bubble'];
if (props.isSent) {
baseClasses.push('sent');
} else {
baseClasses.push('received');
}
if (props.message.message_type === 0) {
baseClasses.push('bubble-bg');
}
if (themeStore.isDarkMode) {
baseClasses.push('dark');
}
return baseClasses;
});
const handlePreviewMedia = (content, type) => {
previewMedia(content, type);
};
const showUserProfile = () => {
emit('show-user-profile', props.senderInfo);
};
const formatDuration = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatTextWithLinks = (text) => {
console.log(text, props.message);
if (!text) return text; // 或 return text (但可能返回 undefined/null根据需求调整)
// 如果 text 是对象,转换为字符串
if (typeof text === 'object') {
text = JSON.stringify(text);
}
const urlRegex = /(https?:\/\/\S+)/g;
return text.replaceAll(
urlRegex,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="text-link">$1</a>',
);
};
const openUrl = (url) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
const getCallStatusText = (status) => {
const statusMap = {
invite: '发起通话',
accepted: '通话已接通',
rejected: '通话被拒绝',
ended: '通话已结束',
missed: '未接通话',
};
return statusMap[status] || '通话消息';
};
const handleImageError = (event) => {
event.target.src = '/placeholder.svg?height=200&width=300';
};
const handleVideoError = (event) => {
console.error('视频加载失败:', event);
};
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
const viewPrescription = (item) => {
PrescriptionDetailModalApi.setData({
values: item.id,
});
PrescriptionDetailModalApi.open();
};
// 格式化日期时间
const formatDateTime = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
// 获取性别文本
const getSexText = (sex) => {
return sex === 1 ? '男' : '女';
};
</script>
<template>
<div
:class="isSent ? 'flex-row-reverse' : 'flex-row'"
class="mb-4 flex items-start gap-3"
>
<RefusalOfTreatmentModals />
<PrescriptionDetailModal />
<!-- 头像 -->
<div
v-if="isSent"
:style="avatarStyle"
class="cursor-pointer rounded-full"
@click="showUserProfile"
>
<Avatar
v-if="userStore.userInfo?.avatar"
:size="48"
:src="userStore.userInfo?.avatar"
/>
<Avatar v-else :size="48">
{{ userStore.userInfo?.nick_name.charAt(0) }}
</Avatar>
</div>
<div
v-else
:style="avatarStyle"
class="cursor-pointer rounded-full"
@click="showUserProfile"
>
<Avatar v-if="senderInfo.avatar" :size="48" :src="senderInfo.avatar" />
<Avatar v-else :size="48">{{ senderInfo.nick_name.charAt(0) }}</Avatar>
</div>
<!-- 消息内容区域 -->
<div
:class="isSent ? 'items-end' : 'items-start'"
class="flex max-w-xs flex-col md:max-w-md lg:max-w-lg xl:max-w-xl"
>
<!-- 发送者名称 -->
<div
v-if="isSent"
class="mb-1 px-2 text-xs text-gray-500 dark:text-gray-400"
>
{{ userStore.userInfo?.nick_name }}
</div>
<div v-else class="mb-1 px-2 text-xs text-gray-500 dark:text-gray-400">
{{ senderInfo.nick_name }}
</div>
<!-- 消息气泡 -->
<div :class="bubbleClasses" class="relative rounded-2xl p-4 shadow-sm">
<!-- 图片消息 -->
<div
v-if="message.message_type === 1"
class="image-message relative cursor-pointer"
style="width: 400px;"
@click="handlePreviewMedia(parsedContent, 'image')"
>
<Image
:src="parsedContent"
alt="图片消息"
class="max-w-full rounded-lg"
@error="handleImageError"
/>
</div>
<!-- 视频消息 -->
<div
v-else-if="message.message_type === 3"
class="video-message relative cursor-pointer"
@click="handlePreviewMedia(parsedContent, 'video')"
>
<XgVideoPlayer
:src="parsedContent"
class="video-thumbnail max-w-full rounded-lg"
thumbnail
/>
<div class="video-overlay">
<div class="play-button">
<i class="fas fa-play"></i>
</div>
<div v-if="message.duration" class="video-duration">
{{ formatDuration(message.duration) }}
</div>
</div>
</div>
<!-- URL链接消息 -->
<div v-else-if="messageTypeName === 'url'" class="url-message">
<div class="url-preview" @click="openUrl(message.url)">
<div class="url-favicon">
<img
v-if="message.favicon"
:alt="message.domain"
:src="message.favicon"
/>
<i v-else class="fas fa-link"></i>
</div>
<div class="url-content">
<div class="url-title">{{ message.title || message.url }}</div>
<div v-if="message.description" class="url-description">
{{ message.description }}
</div>
<div class="url-domain">{{ message.domain }}</div>
</div>
<div v-if="message.thumbnail" class="url-thumbnail">
<img :alt="message.title" :src="message.thumbnail" />
</div>
</div>
<div v-if="message.text" class="url-text">
<Button type="link">{{ message.text }}</Button>
</div>
</div>
<!-- 音频消息 -->
<AudioMessage
v-else-if="message.message_type === 2"
:is-sent="isSent"
:message="message"
/>
<!-- 挂号卡片消息 -->
<div v-else-if="message.message_type === 10" class="register-card">
<div class="flex items-center gap-3">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900"
>
<i
class="fas fa-calendar-check text-xl text-green-500 dark:text-green-300"
></i>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-800 dark:text-gray-100">
挂号信息
</div>
<div
:class="{
'bg-yellow-100 text-yellow-800':
registerCardDisplay.status === 0,
'bg-blue-100 text-blue-800': registerCardDisplay.status === 1,
'bg-red-100 text-red-800': registerCardDisplay.status === 2,
'bg-red-100 text-red-800': registerCardDisplay.status === 3,
'bg-gray-100 text-gray-800': registerCardDisplay.status === 4,
'bg-purple-100 text-purple-800':
registerCardDisplay.status === 5,
'bg-indigo-100 text-indigo-800':
registerCardDisplay.status === 6,
'bg-pink-100 text-pink-800': registerCardDisplay.status === 7,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{ registerStatusMap[registerCardDisplay.status] || '未知状态' }}
</div>
</div>
<div
class="mt-1 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<div class="flex items-center gap-1">
<i class="fas fa-hashtag text-xs"></i>
<span class="max-w-[120px] truncate">{{
registerCardDisplay.order_no
}}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-list-ol text-xs"></i>
<span>{{ registerCardDisplay.order_number }}</span>
</div>
</div>
</div>
</div>
<!-- 患者信息 -->
<div class="mt-3 rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<div class="font-medium text-gray-700 dark:text-gray-200">
患者信息
</div>
<div
:class="{
'bg-red-100 text-red-800': registerCardDisplay.is_pay === 0,
'bg-green-100 text-green-800': registerCardDisplay.is_pay === 1,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{ payStatusMap[registerCardDisplay.is_pay] || '未知' }}
</div>
</div>
<div
class="grid grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-300"
>
<div class="flex items-center gap-1">
<i class="fas fa-user text-xs"></i>
<span>{{ registerCardDisplay.user_patient?.name || '未知' }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-birthday-cake text-xs"></i>
<span>{{ registerCardDisplay.user_patient?.age || 0 }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-venus-mars text-xs"></i>
<span>{{ getSexText(registerCardDisplay.user_patient?.sex) }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-phone text-xs"></i>
<span>{{
registerCardDisplay.user_patient?.mobile ? '已绑定' : '未绑定'
}}</span>
</div>
</div>
</div>
<!-- 主诉 / 所选药品 / 数量与小程序挂号卡对齐 -->
<div
v-if="registerCardDisplay.chief_complaint || registerDrugNamesLine || (registerCardDisplay.number != null && registerCardDisplay.number !== '')"
class="mt-3 space-y-2 rounded-lg border border-gray-100 p-3 text-sm dark:border-gray-600"
>
<div v-if="registerCardDisplay.chief_complaint">
<span class="font-medium text-gray-600 dark:text-gray-300">主诉</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerCardDisplay.chief_complaint
}}</span>
</div>
<div v-if="registerDrugNamesLine">
<span class="font-medium text-gray-600 dark:text-gray-300">所选药品</span>
<span class="text-gray-800 dark:text-gray-100">{{
registerDrugNamesLine
}}</span>
</div>
<div
v-if="registerCardDisplay.number != null && registerCardDisplay.number !== ''"
>
<span class="font-medium text-gray-600 dark:text-gray-300">数量</span>
<span class="text-gray-800 dark:text-gray-100"
> {{ registerCardDisplay.number }} </span
>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-medium text-gray-700 dark:text-gray-200">
挂号费用:
<span class="text-green-500 dark:text-green-400">¥{{ registerCardDisplay.price }}</span>
</div>
</div>
<!-- 接诊/拒诊按钮 - 只在待就诊状态显示 -->
<div v-if="registerCardDisplay.status === 1" class="mt-2 flex gap-2">
<Button
:loading="registerOperating"
class="flex-1"
size="small"
type="primary"
@click="
acceptRegister(registerCardDisplay.id, registerCardDisplay.order_no)
"
>
<i class="fas fa-check mr-1"></i>
接诊
</Button>
<Button
:loading="registerOperating"
class="flex-1"
danger
size="small"
@click="
rejectRegister(registerCardDisplay.id, registerCardDisplay.order_no)
"
>
<i class="fas fa-times mr-1"></i>
拒诊
</Button>
</div>
<!-- 查看详情按钮 -->
<!-- <div class="flex items-center justify-center mt-2">-->
<!-- <div class="flex items-center text-blue-500 dark:text-blue-300 hover:text-blue-600 dark:hover:text-blue-200 transition-colors cursor-pointer">-->
<!-- <span class="text-sm font-medium">查看详情</span>-->
<!-- <i class="fas fa-chevron-right ml-1 text-xs"></i>-->
<!-- </div>-->
<!-- </div>-->
</div>
<!-- 时间信息 -->
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
创建时间: {{ registerCardDisplay.created_at }}
<span v-if="registerCardDisplay.pay_time" class="ml-2">
支付时间: {{ formatDateTime(registerCardDisplay.pay_time) }}
</span>
</div>
</div>
<!-- 处方卡片消息 -->
<div
v-else-if="message.message_type === 4"
class="prescription-card"
@click="viewPrescription(parsedContent)"
>
<div class="flex items-center gap-3">
<div
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900"
>
<i
class="fas fa-file-prescription text-xl text-blue-500 dark:text-blue-300"
></i>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-800 dark:text-gray-100">
电子处方
</div>
<div
:class="{
'bg-yellow-100 text-yellow-800': statusInfo.status === 0,
'bg-green-100 text-green-800': statusInfo.status === 1,
'bg-red-100 text-red-800': statusInfo.status === 2,
'bg-blue-100 text-blue-800': statusInfo.status === 3,
'bg-gray-100 text-gray-800': statusInfo.status === 4,
}"
class="rounded-full px-2 py-1 text-xs font-medium"
>
{{
checkPrescriptionStatus(parsedContent) ||
statusInfo.text ||
'加载中'
}}
</div>
</div>
<div
class="mt-1 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
>
<div class="flex items-center gap-1">
<i class="fas fa-hashtag text-xs"></i>
<span class="max-w-[100px] truncate">{{
parsedContent.order_no
}}</span>
</div>
<div class="flex items-center gap-1">
<i class="fas fa-truck text-xs"></i>
<span>{{ parsedContent.express_name }}</span>
</div>
</div>
</div>
</div>
<div
class="mt-3 flex items-center justify-between border-t border-gray-200 pt-2 dark:border-gray-600"
>
<div class="text-sm font-medium text-gray-700 dark:text-gray-200">
支付金额:
<span class="text-red-500 dark:text-red-400">
¥{{ parsedContent.total_pay_price }}
</span>
</div>
<div
class="flex items-center text-blue-500 transition-colors hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-200"
>
<span class="text-sm font-medium">查看处方</span>
<i class="fas fa-chevron-right ml-1 text-xs"></i>
</div>
</div>
</div>
<!-- 在线复诊|购药信息 -->
<div
v-else-if="messageTypeName === 'follow-up-visit'"
class="group transition-all duration-200"
>
<!-- 卡片容器 -->
<div
:class="{
'border-green-200': parsedContent.hasVisited === '1', // 已到访状态
'border-yellow-200': parsedContent.hasVisited === '0', // 未到访状态
}"
class="rounded-lg border p-4 shadow-sm"
>
<!-- 标题区域 -->
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center text-lg font-semibold">
<svg
class="mr-2 h-4 w-4 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
/>
</svg>
{{ parsedContent.drug_name }} 复诊信息
</h3>
<span
:class="{
'text-green-800': parsedContent.hasVisited === '1',
'bg-yellow-100 text-yellow-800':
parsedContent.hasVisited === '0',
}"
class="rounded-full px-2 py-1 text-xs"
>
{{ parsedContent.hasVisited === '1' ? '就诊过' : '未就诊过' }}
</span>
</div>
<!-- 症状信息 -->
<div class="space-y-2">
<p class="flex items-start text-sm">
<span class="w-20">症状描述</span>
<span class="flex-1">{{ parsedContent.illnessInfo }}</span>
</p>
<!-- 用药状态 -->
<p
:class="{
'font-medium text-blue-600':
parsedContent.hasUsedDrug === '1',
'text-gray-500': parsedContent.hasUsedDrug === '0',
}"
class="text-sm"
>
<span class="w-20 text-gray-500">用药情况</span>
{{
parsedContent.hasUsedDrug === '1' ? '使用过' : '未使用过'
}}
</p>
<!-- 医生信息 -->
<p
class="mt-3 border-t border-gray-100 pt-3 text-sm text-gray-500"
>
<span class="w-20 text-gray-400">主诊医生</span>
ID: {{ parsedContent.doctor_id }}
</p>
</div>
</div>
</div>
<!-- 系统消息/价格更新卡片 (type=9) -->
<div
v-else-if="message.message_type === 9"
class="system-message-card"
>
<div
v-if="parsedContent?.type === 'price_update'"
class="price-update-card"
>
<div class="price-update-header">
<div class="header-icon">
<i class="fas fa-tag"></i>
</div>
<span class="price-title">订单价格更新</span>
</div>
<div class="price-update-content">
<p class="price-message">{{ parsedContent.message }}</p>
<!-- 订单信息 -->
<div class="order-info">
<div class="info-item">
<span class="info-label">
<i class="fas fa-hashtag"></i>
订单号
</span>
<span class="info-value">{{ parsedContent.order_no }}</span>
</div>
</div>
<!-- 价格详情 -->
<div class="price-info-section">
<div class="price-info-row">
<span class="price-label">
<i class="fas fa-shipping-fast"></i>
运费
</span>
<span class="price-value shipping-fee">
<span v-if="parsedContent.is_free_shipping === 1 || parsedContent.is_free_shipping === '1'" class="free-shipping-tag">
<i class="fas fa-check-circle"></i>
包邮
</span>
<span v-else>¥{{ parsedContent.trans_expenses || '0.00' }}</span>
</span>
</div>
<div class="price-info-row total-row">
<span class="price-label">
<i class="fas fa-money-bill-wave"></i>
总价
</span>
<span class="price-amount">¥{{ parsedContent.total_pay_price }}</span>
</div>
</div>
</div>
</div>
<div v-else class="system-message-text">
{{ typeof parsedContent === 'string' ? parsedContent : '系统消息' }}
</div>
</div>
<!-- type11复诊助理用药确认与药店患者就诊经历卡片分流 -->
<FollowUpDrugAssistantCard
v-else-if="message.message_type === 11 && isFollowUpDrugAssistant"
:content="parsedContent"
/>
<!-- 患者就诊经历卡片 (type=11) -->
<PatientExperienceCard
v-else-if="message.message_type === 11"
:content="parsedContent"
/>
<!-- 商品卡片 (type=12) -->
<ProductCard
v-else-if="message.message_type === 12"
:content="parsedContent"
@click-product="(product) => console.log('点击商品:', product)"
/>
<!-- 结束问诊卡片 (type=13) -->
<EndConsultationCard
v-else-if="message.message_type === 13"
:content="parsedContent"
/>
<!-- 转接方消息卡片 (type=14) -->
<TransferPrescriptionMessageCard
v-else-if="message.message_type === 14"
:content="parsedContent"
:register-id="chatStore.currentFriend?.register_id"
@view-detail="handleViewTransferDetail"
@import-success="handleTransferImportSuccess"
/>
<!-- 通话消息 -->
<div
v-else-if="
message.message_type === 6 || message.message_type === 7
"
class="call-message"
>
<div class="call-info">
<i
:class="message.message_type === 6 ? 'fa-video' : 'fa-phone'"
class="fas"
></i>
<span>{{ getCallStatusText(message.callStatus) }}</span>
</div>
<div v-if="message.duration" class="call-duration">
通话时长: {{ formatDuration(message.duration) }}
</div>
</div>
<!-- 文本消息 -->
<div v-else class="whitespace-pre-wrap text-base leading-relaxed">
<span v-html="formatTextWithLinks(parsedContent || message.message_content || message.content || '')"></span>
</div>
</div>
<!-- 消息时间和状态 -->
<div
class="mt-1 flex items-center gap-1 px-2 text-xs text-gray-400 dark:text-gray-500"
>
<span>{{ message.time }}</span>
<i
v-if="isSent && message.read"
class="fas fa-check-double text-green-500"
></i>
<i
v-else-if="isSent && !message.read"
class="fas fa-clock text-gray-400"
></i>
</div>
</div>
<!-- 转接方查看抽屉 -->
<TransferPrescriptionViewDrawer
v-model:visible="showTransferDrawer"
:register-id="transferRegisterId"
@import-success="handleTransferImportSuccess"
/>
</div>
</template>
<style scoped>
.message-bubble {
position: relative;
max-width: 100%;
word-wrap: break-word;
transition: all 0.2s ease;
}
.message-bubble.sent {
color: white;
border-bottom-right-radius: 4px;
}
.bubble-bg {
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.bubble-bg.dark {
background: linear-gradient(135deg, #4361ee, #3f37c9);
}
.message-bubble.received.bubble-bg {
background: white;
color: #1a202c;
border: 1px solid #e2e8f0;
border-bottom-left-radius: 4px;
}
.message-bubble.received.dark.bubble-bg {
background: #374151;
color: #f7fafc;
border-color: #4b5563;
}
.video-message {
position: relative;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
}
.video-thumbnail {
width: 100%;
height: auto;
max-height: 200px;
object-fit: cover;
display: block;
}
.video-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.3) 100%
);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-message:hover .video-overlay {
opacity: 1;
}
.play-button {
width: 60px;
height: 60px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.play-button:hover {
background: rgba(0, 0, 0, 0.8);
transform: scale(1.1);
}
.video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.call-message {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.call-info {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.call-info i {
width: 16px;
text-align: center;
}
.call-duration {
font-size: 12px;
opacity: 0.8;
}
.url-message {
max-width: 400px;
}
.url-preview {
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s ease;
background: white;
display: flex;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
.url-preview:hover {
border-color: #4361ee;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(67, 97, 238, 0.15);
}
.message-bubble.sent .url-preview {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.message-bubble.received.dark .url-preview {
border-color: #4b5563;
background: #4b5563;
}
.url-favicon {
width: 20px;
height: 20px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.url-favicon img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 2px;
}
.url-content {
flex: 1;
min-width: 0;
}
.url-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
color: #1a202c;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-title {
color: white;
}
.message-bubble.received.dark .url-title {
color: #f7fafc;
}
.url-description {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.message-bubble.sent .url-description {
color: rgba(255, 255, 255, 0.8);
}
.message-bubble.received.dark .url-description {
color: #9ca3af;
}
.url-domain {
font-size: 11px;
color: #94a3b8;
font-weight: 500;
}
.message-bubble.sent .url-domain {
color: rgba(255, 255, 255, 0.7);
}
.message-bubble.received.dark .url-domain {
color: #6b7280;
}
.url-thumbnail {
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
}
.url-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.url-text {
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
:deep(.text-link) {
color: #4361ee;
text-decoration: underline;
transition: color 0.2s ease;
}
:deep(.text-link:hover) {
color: #3f37c9;
}
.message-bubble.sent :deep(.text-link) {
color: rgba(255, 255, 255, 0.9);
}
.message-bubble.sent :deep(.text-link:hover) {
color: white;
}
/* 挂号卡片样式 */
.register-card {
@apply w-full max-w-xs cursor-pointer rounded-xl bg-white p-4 shadow-sm transition-all duration-300 md:max-w-sm dark:bg-gray-700;
@apply border border-gray-200 hover:border-green-300 hover:shadow-md dark:border-gray-600 dark:hover:border-green-500;
}
.message-bubble.sent .register-card {
@apply border-green-200/50 bg-green-50/30 dark:border-green-700/70 dark:bg-green-900/30;
}
.register-card:hover {
@apply -translate-y-0.5 transform;
}
/* 处方卡片样式 - 使用TailwindCSS类替代 */
.prescription-card {
@apply w-full max-w-xs cursor-pointer rounded-xl bg-white p-4 shadow-sm transition-all duration-300 md:max-w-sm dark:bg-gray-700;
@apply border border-gray-200 hover:border-blue-300 hover:shadow-md dark:border-gray-600 dark:hover:border-blue-500;
}
.message-bubble.sent .prescription-card {
@apply border-blue-200/50 bg-blue-50/30 dark:border-blue-700/70 dark:bg-blue-900/30;
}
.prescription-card:hover {
@apply -translate-y-0.5 transform;
}
/* 系统消息/价格更新卡片样式 */
.system-message-card {
@apply w-full max-w-md rounded-xl p-4;
}
.price-update-card {
@apply rounded-xl border border-orange-200 bg-gradient-to-r from-orange-50 to-amber-50 p-4 shadow-sm dark:border-orange-700 dark:from-orange-900/30 dark:to-amber-900/30;
transition: all 0.3s ease;
}
.price-update-card:hover {
@apply shadow-md;
transform: translateY(-2px);
}
.message-bubble.sent .price-update-card {
@apply border-orange-200/50 bg-orange-50/30 dark:border-orange-700/70 dark:bg-orange-900/30;
}
.price-update-header {
@apply mb-3 flex items-center gap-3;
}
.header-icon {
@apply flex h-10 w-10 items-center justify-center rounded-lg bg-orange-100 text-orange-500 dark:bg-orange-900/50 dark:text-orange-300;
}
.price-title {
@apply flex-1 text-base font-semibold text-orange-700 dark:text-orange-300;
}
.price-update-content {
@apply space-y-3;
}
.price-message {
@apply rounded-lg bg-white/60 p-2 text-sm text-gray-700 dark:bg-gray-800/60 dark:text-gray-300;
}
.order-info {
@apply rounded-lg bg-white/60 p-3 dark:bg-gray-800/60;
}
.info-item {
@apply flex items-center justify-between;
}
.info-label {
@apply flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400;
}
.info-label i {
@apply text-orange-500 dark:text-orange-400;
}
.info-value {
@apply text-sm font-medium text-gray-700 dark:text-gray-200;
}
.price-info-section {
@apply space-y-2 rounded-lg border-t border-orange-200/50 bg-white/60 pt-3 dark:border-orange-700/50 dark:bg-gray-800/60;
}
.price-info-row {
@apply flex items-center justify-between;
}
.price-label {
@apply flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-300;
}
.price-label i {
@apply text-orange-500 dark:text-orange-400;
}
.price-value {
@apply text-sm font-medium text-gray-700 dark:text-gray-200;
}
.shipping-fee {
@apply flex items-center;
}
.free-shipping-tag {
@apply inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/50 dark:text-green-300;
}
.free-shipping-tag i {
@apply text-green-600 dark:text-green-400;
}
.total-row {
@apply border-t border-orange-200/50 pt-2 dark:border-orange-700/50;
}
.total-row .price-label {
@apply text-base font-semibold;
}
.price-amount {
@apply text-lg font-bold text-orange-600 dark:text-orange-400;
}
.system-message-text {
@apply rounded-lg bg-gray-100 p-3 text-center text-sm text-gray-600 dark:bg-gray-700 dark:text-gray-300;
}
/* 患者就诊经历卡片发送样式 */
.message-bubble.sent .patient-experience-card {
@apply border-blue-200/50 bg-blue-50/30 dark:border-blue-700/70 dark:bg-blue-900/30;
}
/* 商品卡片发送样式 */
.message-bubble.sent .product-card {
@apply border-purple-200/50 bg-purple-50/30 dark:border-purple-700/70 dark:bg-purple-900/30;
}
/* 结束问诊卡片发送样式 */
.message-bubble.sent .end-consultation-card {
@apply border-gray-200/50 bg-gray-50/30 dark:border-gray-700/70 dark:bg-gray-900/30;
}
/* 图片和视频消息发送样式 */
.message-bubble.sent .image-message,
.message-bubble.sent .video-message {
opacity: 0.9;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 12px;
}
/* 通话消息发送样式 */
.message-bubble.sent .call-message {
color: white;
}
</style>