|
|
@@ -0,0 +1,1136 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import { h, onMounted, onUnmounted, nextTick } from 'vue';
|
|
|
+import { watchDebounced } from '@vueuse/core';
|
|
|
+import { message, Upload, Badge, Image } from 'ant-design-vue';
|
|
|
+import { UploadIFile } from '@/request/api/follow.api';
|
|
|
+import {
|
|
|
+ SearchOutlined,
|
|
|
+ PictureOutlined,
|
|
|
+ HistoryOutlined,
|
|
|
+ ReloadOutlined,
|
|
|
+ UserOutlined,
|
|
|
+ RobotOutlined,
|
|
|
+ VerticalAlignTopOutlined,
|
|
|
+ CustomerServiceOutlined,
|
|
|
+} from '@ant-design/icons-vue';
|
|
|
+import dayjs from 'dayjs';
|
|
|
+import {
|
|
|
+ getConsultantListMethod,
|
|
|
+ getAllConsultChatListMethod,
|
|
|
+ pinConsultantMethod,
|
|
|
+ cancelPinConsultantMethod,
|
|
|
+ refreshConsultMethod,
|
|
|
+ sendConsultChatMethod,
|
|
|
+} from '@/request/api/consult.api';
|
|
|
+import type { ConsultantPeopleModel, ChatMessageModel } from '@/model/consult.model';
|
|
|
+import ChatHistory from '@/components/ChatHistory.vue';
|
|
|
+import { VxeUI } from 'vxe-pc-ui';
|
|
|
+import { useRequest } from 'alova/client';
|
|
|
+import PatientHealthRecordPreview from '@/components/PatientHealthRecordPreview.vue';
|
|
|
+defineOptions({ name: 'OnlineConsultPage' });
|
|
|
+
|
|
|
+// 左边咨询的患者列表
|
|
|
+const patients = ref<ConsultantPeopleModel[]>([]);
|
|
|
+const { loading, data } = useRequest(getConsultantListMethod, {
|
|
|
+ immediate: true,
|
|
|
+}).onSuccess(({ data }) => {
|
|
|
+ if (data && data.length > 0) {
|
|
|
+ patients.value = data;
|
|
|
+ // 等待响应式更新完成后再选择第一个患者
|
|
|
+ nextTick(() => {
|
|
|
+ if (!selectedPatient.value && patients.value.length > 0) {
|
|
|
+ selectPatient(patients.value[0]);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+});
|
|
|
+// 搜索关键词
|
|
|
+const searchKeyword = ref('');
|
|
|
+
|
|
|
+// 当前选中的患者
|
|
|
+const selectedPatient = ref<ConsultantPeopleModel | null>(null);
|
|
|
+// 保存上一次选中患者的 progress 状态,用于检测状态变化
|
|
|
+const previousProgress = ref<number | string | undefined>(undefined);
|
|
|
+watchDebounced(
|
|
|
+ searchKeyword,
|
|
|
+ (newVal: any) => {
|
|
|
+ refreshPatientList(newVal);
|
|
|
+ },
|
|
|
+ { debounce: 500 }
|
|
|
+);
|
|
|
+
|
|
|
+// 当前患者的消息列表
|
|
|
+const chatMessages = ref<ChatMessageModel[]>([]);
|
|
|
+
|
|
|
+// 输入消息
|
|
|
+const inputMessage = ref('');
|
|
|
+
|
|
|
+// 最大字数限制
|
|
|
+const MAX_MESSAGE_LENGTH = 2000;
|
|
|
+
|
|
|
+// 当前输入字数
|
|
|
+const currentMessageLength = computed(() => inputMessage.value.length);
|
|
|
+
|
|
|
+// 待发送的图片列表
|
|
|
+const pendingImages = ref<string[]>([]);
|
|
|
+
|
|
|
+// 聊天消息容器引用
|
|
|
+const chatMessagesRef = ref<HTMLElement | null>(null);
|
|
|
+
|
|
|
+// 判断是否可以发送消息:咨询中且已转人工
|
|
|
+const canSendMessage = computed(() => {
|
|
|
+ if (selectedPatient.value?.isCanSend) {
|
|
|
+ return true;
|
|
|
+ } else {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 选择患者
|
|
|
+function selectPatient(patient: ConsultantPeopleModel) {
|
|
|
+ selectedPatient.value = patient;
|
|
|
+ // 保存当前患者的 progress 状态
|
|
|
+ previousProgress.value = patient.progress;
|
|
|
+ // 加载该患者的聊天记录
|
|
|
+ loadChatMessages(patient.consultRecordId as number);
|
|
|
+ // 清除未读数量
|
|
|
+ patient.doctorUnreadCount = 0;
|
|
|
+}
|
|
|
+
|
|
|
+// 加载聊天消息(模拟)
|
|
|
+async function loadChatMessages(consultRecordId: number) {
|
|
|
+ // 这里应该调用 API 加载该患者的聊天记录
|
|
|
+ const res = await getAllConsultChatListMethod(consultRecordId);
|
|
|
+ if (res && res.length > 0) {
|
|
|
+ chatMessages.value = res as unknown as ChatMessageModel[];
|
|
|
+ // 加载完成后滚动到底部
|
|
|
+ nextTick(() => {
|
|
|
+ scrollToBottom();
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ chatMessages.value = [];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 滚动到消息列表底部
|
|
|
+function scrollToBottom() {
|
|
|
+ if (chatMessagesRef.value) {
|
|
|
+ chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight;
|
|
|
+ }
|
|
|
+}
|
|
|
+//刷新患者列表
|
|
|
+async function refreshPatientList() {
|
|
|
+ const res = await getConsultantListMethod(searchKeyword.value);
|
|
|
+ if (res && res.length > 0) {
|
|
|
+ patients.value = res as unknown as ConsultantPeopleModel[];
|
|
|
+ // 如果当前有选中的患者,更新选中患者的信息
|
|
|
+ if (selectedPatient.value?.consultRecordId) {
|
|
|
+ const updatedPatient = patients.value.find((p) => p.consultRecordId === selectedPatient.value?.consultRecordId);
|
|
|
+ if (updatedPatient) {
|
|
|
+ // 更新选中患者的信息,确保响应式更新
|
|
|
+ const currentConsultRecordId = selectedPatient.value.consultRecordId;
|
|
|
+ Object.assign(selectedPatient.value, updatedPatient);
|
|
|
+ // 确保 consultRecordId 不变(防止丢失引用)
|
|
|
+ selectedPatient.value.consultRecordId = currentConsultRecordId;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ patients.value = [];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 判断是否已置顶
|
|
|
+const isPinned = computed(() => {
|
|
|
+ if (!selectedPatient.value) return false;
|
|
|
+ const isTop = selectedPatient.value.isTop;
|
|
|
+ if (isTop == null || isTop === '' || isTop === 'N') {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const result = isTop === 'Y';
|
|
|
+ return result;
|
|
|
+});
|
|
|
+
|
|
|
+// 置顶/取消置顶患者
|
|
|
+async function pinPatient(patient: ConsultantPeopleModel) {
|
|
|
+ try {
|
|
|
+ const isTop = patient.isTop;
|
|
|
+ let isCurrentlyPinned = false;
|
|
|
+ if (isTop != null && isTop !== '' && isTop !== 'N') {
|
|
|
+ isCurrentlyPinned = isTop === 'Y';
|
|
|
+ }
|
|
|
+ if (isCurrentlyPinned) {
|
|
|
+ // 当前已置顶,执行取消置顶
|
|
|
+ await cancelPinConsultantMethod(patient.consultRecordId as number);
|
|
|
+ message.success('取消置顶');
|
|
|
+ } else {
|
|
|
+ // 当前未置顶,执行置顶
|
|
|
+ await pinConsultantMethod(patient.consultRecordId as number);
|
|
|
+ message.success('置顶成功');
|
|
|
+ }
|
|
|
+ // 刷新患者列表
|
|
|
+ await refreshPatientList();
|
|
|
+ } catch (error: any) {
|
|
|
+ message.error(error.message);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 刷新页面
|
|
|
+async function handleRefresh(showMessage = true) {
|
|
|
+ try {
|
|
|
+ // 保存刷新前的 progress 状态
|
|
|
+ const oldProgress = previousProgress.value;
|
|
|
+
|
|
|
+ // 刷新患者列表
|
|
|
+ await refreshPatientList();
|
|
|
+ // 如果有选中的患者,判断是否需要刷新聊天记录
|
|
|
+ if (selectedPatient.value?.consultRecordId) {
|
|
|
+ const currentProgress = selectedPatient.value.progress;
|
|
|
+ // 检查咨询是否已结束:progress === 1 或 progress === '1' 表示已结束
|
|
|
+ const isConsultEnded = currentProgress == 1 || String(currentProgress) === '1';
|
|
|
+ // 检查上一次是否未结束
|
|
|
+ const wasConsultEnded = oldProgress == 1 || String(oldProgress) === '1';
|
|
|
+
|
|
|
+ // 需要刷新的情况:
|
|
|
+ // 1. 咨询未结束(正常刷新)
|
|
|
+ // 2. 咨询刚刚结束(从进行中变为已结束,需要刷新一次以获取结束消息)
|
|
|
+ const shouldRefresh = !isConsultEnded || (!wasConsultEnded && isConsultEnded);
|
|
|
+
|
|
|
+ if (shouldRefresh) {
|
|
|
+ // 重新加载聊天记录
|
|
|
+ await loadChatMessages(selectedPatient.value.consultRecordId);
|
|
|
+ // 刷新(获取最新消息)
|
|
|
+ await refreshConsultMethod(selectedPatient.value.consultRecordId.toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新保存的 progress 状态
|
|
|
+ previousProgress.value = currentProgress;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (showMessage) {
|
|
|
+ message.success('刷新成功');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (showMessage) {
|
|
|
+ message.error('刷新失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 上传图片
|
|
|
+/**
|
|
|
+ * 处理图片上传,将文件转换为 Data URL 并添加到预览列表。
|
|
|
+ * @param {File} file - 用户选择的文件对象。
|
|
|
+ * @return {boolean} 返回 false 以阻止默认的自动上传行为。
|
|
|
+ */
|
|
|
+function customUpload(e: any) {
|
|
|
+ // 检查是否可以发送消息
|
|
|
+ if (!canSendMessage.value) {
|
|
|
+ message.warning('咨询已结束或未转人工,无法发送消息');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ UploadIFile(e.file)
|
|
|
+ .then((res: any) => {
|
|
|
+ // 调用实例的成功方法通知组件该文件上传成功
|
|
|
+ e.onSuccess(res, e);
|
|
|
+ pendingImages.value.push(res?.url);
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ // 调用实例的失败方法通知组件该文件上传失败
|
|
|
+ e.onError(err);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 删除待发送的图片
|
|
|
+function removePendingImage(index: number) {
|
|
|
+ pendingImages.value.splice(index, 1);
|
|
|
+}
|
|
|
+
|
|
|
+// 发送消息
|
|
|
+async function sendMessage(content?: string, imageUrl?: string) {
|
|
|
+ if (!selectedPatient.value) {
|
|
|
+ message.warning('请先选择患者');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!content && !imageUrl) {
|
|
|
+ message.warning('请输入消息内容');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ // 确定消息内容:如果是图片就放URL,否则放文本内容
|
|
|
+ const messageContent = imageUrl || content || '';
|
|
|
+ // 确定消息类型:图片是2,文本是1
|
|
|
+ const messageType = imageUrl ? '2' : '1';
|
|
|
+ let query = {
|
|
|
+ consultRecordId: selectedPatient.value.consultRecordId,
|
|
|
+ messageType: messageType,
|
|
|
+ messageContent: messageContent,
|
|
|
+ };
|
|
|
+ // 发送消息
|
|
|
+ await sendConsultChatMethod(selectedPatient.value.patientId, query);
|
|
|
+
|
|
|
+ // 发送成功后,创建本地消息对象用于显示
|
|
|
+ const newMessage: ChatMessageModel = {
|
|
|
+ id: Date.now(),
|
|
|
+ consultRecordId: selectedPatient.value.consultRecordId,
|
|
|
+ sendTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+ sendType: '2', // 2-医生(人工身份)
|
|
|
+ messageType: messageType, // 1-文本 2-图片
|
|
|
+ messageContent: messageContent, // 图片就放URL,文本就放内容
|
|
|
+ };
|
|
|
+
|
|
|
+ // 直接将新消息添加到当前消息列表末尾,避免重新加载导致页面闪烁
|
|
|
+ chatMessages.value.push(newMessage);
|
|
|
+
|
|
|
+ // 更新患者最后一条消息
|
|
|
+ if (selectedPatient.value) {
|
|
|
+ selectedPatient.value.messageType = imageUrl ? 2 : 1;
|
|
|
+ selectedPatient.value.messageContent = messageContent;
|
|
|
+ selectedPatient.value.sendTime = dayjs().format('YYYY-MM-DD HH:mm');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动到底部,显示最新发送的消息
|
|
|
+ nextTick(() => {
|
|
|
+ scrollToBottom();
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('发送消息失败:', error);
|
|
|
+ // message.error('发送消息失败');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 发送按钮点击
|
|
|
+async function handleSend() {
|
|
|
+ if (!selectedPatient.value) {
|
|
|
+ message.warning('请先选择患者');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否可以发送消息
|
|
|
+ if (!canSendMessage.value) {
|
|
|
+ message.warning('咨询已结束或未转人工,无法发送消息');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasText = inputMessage.value.trim().length > 0;
|
|
|
+ const hasImages = pendingImages.value.length > 0;
|
|
|
+
|
|
|
+ if (!hasText && !hasImages) {
|
|
|
+ message.warning('请输入消息内容或选择图片');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 先发送所有待发送的图片(每条图片单独发送)
|
|
|
+ for (const imageUrl of pendingImages.value) {
|
|
|
+ await sendMessage('', imageUrl); // 正式发送
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果有文字内容,单独发送文字消息
|
|
|
+ if (hasText) {
|
|
|
+ const textContent = inputMessage.value.trim();
|
|
|
+ if (textContent.length > MAX_MESSAGE_LENGTH) {
|
|
|
+ message.warning(`消息内容不能超过${MAX_MESSAGE_LENGTH}字`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await sendMessage(textContent, undefined); // 正式发送
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清空输入和待发送图片
|
|
|
+ inputMessage.value = '';
|
|
|
+ pendingImages.value = [];
|
|
|
+}
|
|
|
+
|
|
|
+// 回车发送
|
|
|
+function handleKeyPress(e: KeyboardEvent) {
|
|
|
+ if (e.key === 'Enter' && !e.shiftKey) {
|
|
|
+ e.preventDefault();
|
|
|
+ // 如果禁用状态,不发送
|
|
|
+ if (!canSendMessage.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ handleSend();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 打开聊天记录弹窗
|
|
|
+function openChatHistory() {
|
|
|
+ VxeUI.modal.open({
|
|
|
+ title: '聊天记录',
|
|
|
+ height: window.innerHeight,
|
|
|
+ width: 900,
|
|
|
+ escClosable: true,
|
|
|
+ destroyOnClose: true,
|
|
|
+ id: 'chat-history-modal',
|
|
|
+ remember: true,
|
|
|
+ storage: true,
|
|
|
+ slots: {
|
|
|
+ default: () =>
|
|
|
+ h(ChatHistory, {
|
|
|
+ data: selectedPatient.value as any,
|
|
|
+ messages: chatMessages.value as any,
|
|
|
+ onClose: () => {
|
|
|
+ VxeUI.modal.close('chat-history-modal');
|
|
|
+ },
|
|
|
+ }),
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化时间
|
|
|
+function formatTime(time: string) {
|
|
|
+ return time;
|
|
|
+}
|
|
|
+
|
|
|
+// 格式化患者时间:当日显示时间,非当日显示日期
|
|
|
+function formatPatientTime(time: string) {
|
|
|
+ if (!time) return '';
|
|
|
+ const timeDate = dayjs(time);
|
|
|
+ const today = dayjs();
|
|
|
+ // 判断是否是同一天
|
|
|
+ if (timeDate.isSame(today, 'day')) {
|
|
|
+ // 当日显示时间 HH:mm
|
|
|
+ return timeDate.format('HH:mm');
|
|
|
+ } else {
|
|
|
+ // 非当日显示日期 YYYY-MM-DD
|
|
|
+ return timeDate.format('YYYY-MM-DD');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 获取性别图标
|
|
|
+function getGenderIcon(sex: string) {
|
|
|
+ // 1 女 其他 男
|
|
|
+ return sex === '1' ? '♀' : '♂';
|
|
|
+}
|
|
|
+
|
|
|
+// 定时刷新定时器
|
|
|
+let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
+
|
|
|
+// 初始化
|
|
|
+onMounted(() => {
|
|
|
+ // 每5秒刷新一次页面(静默刷新,不显示提示)
|
|
|
+ refreshTimer = setInterval(() => {
|
|
|
+ handleRefresh(false);
|
|
|
+ }, 5000);
|
|
|
+});
|
|
|
+
|
|
|
+// 清除定时器
|
|
|
+onUnmounted(() => {
|
|
|
+ if (refreshTimer) {
|
|
|
+ clearInterval(refreshTimer);
|
|
|
+ refreshTimer = null;
|
|
|
+ }
|
|
|
+});
|
|
|
+// 查看健康档案
|
|
|
+function openHistoryPreviewHandle(row: any) {
|
|
|
+ const patient = { id: row.patientId };
|
|
|
+ const report = { id: row.healthAnalysisReportId };
|
|
|
+ const id = `drawer:report-history:preview`;
|
|
|
+ const onDestroy = () => {
|
|
|
+ VxeUI.drawer.close(id);
|
|
|
+ };
|
|
|
+ onDestroy();
|
|
|
+ VxeUI.drawer.open({
|
|
|
+ id,
|
|
|
+ title: `健康档案`,
|
|
|
+ maskClosable: true,
|
|
|
+ escClosable: true,
|
|
|
+ padding: false,
|
|
|
+ width: window.innerWidth - 256,
|
|
|
+ slots: {
|
|
|
+ default() {
|
|
|
+ return h(PatientHealthRecordPreview, {
|
|
|
+ patient,
|
|
|
+ report,
|
|
|
+ onDestroy,
|
|
|
+ });
|
|
|
+ },
|
|
|
+ },
|
|
|
+ onHide() {
|
|
|
+ VxeUI.modal.close();
|
|
|
+ },
|
|
|
+ });
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="online-consult-container">
|
|
|
+ <!-- 左侧患者列表 -->
|
|
|
+ <div class="patient-list">
|
|
|
+ <!-- 搜索框 -->
|
|
|
+ <div class="search-bar">
|
|
|
+ <a-input v-model:value="searchKeyword" placeholder="搜索">
|
|
|
+ <template #prefix>
|
|
|
+ <SearchOutlined />
|
|
|
+ </template>
|
|
|
+ </a-input>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 患者列表 -->
|
|
|
+ <div class="patient-items">
|
|
|
+ <div
|
|
|
+ v-for="patient in patients"
|
|
|
+ :key="patient.id"
|
|
|
+ class="patient-item"
|
|
|
+ :class="{ active: selectedPatient?.consultRecordId === patient.consultRecordId }"
|
|
|
+ @click="selectPatient(patient)"
|
|
|
+ >
|
|
|
+ <div class="patient-info">
|
|
|
+ <div class="patient-header">
|
|
|
+ <div class="patient-name-wrapper">
|
|
|
+ <span class="gender-icon">{{ getGenderIcon(patient.sex) }}</span>
|
|
|
+ <span class="patient-name" v-if="patient.name || patient.age">{{ patient.name }} {{ patient.age }}岁</span>
|
|
|
+ </div>
|
|
|
+ <span class="patient-time">{{ formatPatientTime(patient.sendTime) }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="patient-meta">
|
|
|
+ <span class="last-message">
|
|
|
+ <template v-if="patient.messageType === '2'">
|
|
|
+ <PictureOutlined style="color: #1890ff; margin-right: 4px" />
|
|
|
+ <span style="color: #999">[图片]</span>
|
|
|
+ </template>
|
|
|
+ <template v-else>
|
|
|
+ {{ patient.messageContent }}
|
|
|
+ </template>
|
|
|
+ </span>
|
|
|
+ <span class="consult-status" :class="patient.progress === 1 ? 'ended' : 'consulting'">
|
|
|
+ <span v-if="patient.doctorUnreadCount > 0" class="status-dot"></span>
|
|
|
+ <span v-if="patient.progress === '1'" style="color: #52c41a">咨询结束</span>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 右侧聊天界面 -->
|
|
|
+ <div class="chat-container">
|
|
|
+ <!-- 顶部信息栏 -->
|
|
|
+ <div class="chat-header" v-if="selectedPatient">
|
|
|
+ <div class="patient-details">
|
|
|
+ <span class="gender-icon-large">{{ getGenderIcon(selectedPatient.sex) }}</span>
|
|
|
+ <span class="patient-name-large">{{ selectedPatient.name }} {{ selectedPatient.age }}岁</span>
|
|
|
+ <span class="patient-phone">{{ selectedPatient.phone }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="header-actions">
|
|
|
+ <a-button type="link" @click="openHistoryPreviewHandle(selectedPatient)">健康档案</a-button>
|
|
|
+ <a-button type="link" @click="pinPatient(selectedPatient)">
|
|
|
+ <template #icon><VerticalAlignTopOutlined /></template>
|
|
|
+ {{ isPinned ? '取消置顶' : '置顶' }}
|
|
|
+ </a-button>
|
|
|
+ <a-button type="link" @click="handleRefresh">
|
|
|
+ <template #icon><ReloadOutlined /></template>
|
|
|
+ </a-button>
|
|
|
+ <Badge :count="selectedPatient.unreadCount || 0" :offset="[-5, 5]">
|
|
|
+ <span></span>
|
|
|
+ </Badge>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 聊天消息区域 -->
|
|
|
+ <div class="chat-messages" v-if="selectedPatient" ref="chatMessagesRef">
|
|
|
+ <div v-if="chatMessages.length === 0" class="empty-state" style="padding: 24px; color: #999; text-align: center">暂无聊天记录</div>
|
|
|
+ <!-- 已发送的消息 -->
|
|
|
+ <div v-for="msg in chatMessages" :key="msg.id" class="message-item" :class="`send-type-${msg.sendType}`">
|
|
|
+ <div class="message-avatar" v-if="msg.sendType !== '3'">
|
|
|
+ <UserOutlined v-if="msg.sendType === '1'" />
|
|
|
+ <RobotOutlined v-else-if="msg.sendType === '4'" />
|
|
|
+ <CustomerServiceOutlined v-else-if="msg.sendType === '2'" />
|
|
|
+ </div>
|
|
|
+ <div class="message-content-wrapper" :class="`send-type-${msg.sendType}`">
|
|
|
+ <div class="message-time" v-if="msg.sendType == '3'">{{ formatTime(msg.sendTime) }}</div>
|
|
|
+ <div class="message-content" :class="[`send-type-${msg.sendType}`, { 'has-image': msg.messageType === '2' }]">
|
|
|
+ <Image
|
|
|
+ v-if="msg.messageType === '2'"
|
|
|
+ :src="msg.messageContent"
|
|
|
+ alt=""
|
|
|
+ style="width: 150px; height: 150px; border-radius: 10px"
|
|
|
+ class="message-image"
|
|
|
+ :preview="{
|
|
|
+ src: msg.messageContent,
|
|
|
+ }"
|
|
|
+ />
|
|
|
+ <div v-else class="message-text">{{ msg.messageContent }}</div>
|
|
|
+ </div>
|
|
|
+ <div class="message-time" v-if="msg.sendType !== '3'">{{ formatTime(msg.sendTime) }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 输入区域 -->
|
|
|
+ <div class="chat-input-area" v-if="selectedPatient">
|
|
|
+ <!-- 待发送的图片预览 -->
|
|
|
+ <div v-if="pendingImages.length > 0" class="pending-images-preview">
|
|
|
+ <div v-for="(imageUrl, index) in pendingImages" :key="`pending-image-${index}`" class="pending-image-item">
|
|
|
+ <div class="pending-image-wrapper">
|
|
|
+ <Image
|
|
|
+ :src="imageUrl"
|
|
|
+ alt="图片"
|
|
|
+ class="pending-message-image"
|
|
|
+ :preview="{
|
|
|
+ src: imageUrl,
|
|
|
+ }"
|
|
|
+ />
|
|
|
+ <div class="remove-image-btn" @click.stop="removePendingImage(index)">×</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="input-actions">
|
|
|
+ <Upload :customRequest="customUpload" :show-upload-list="false" accept="image/*" :disabled="!canSendMessage">
|
|
|
+ <a-button type="text" class="action-btn" :disabled="!canSendMessage">
|
|
|
+ <template #icon><PictureOutlined /></template>
|
|
|
+ 照片
|
|
|
+ </a-button>
|
|
|
+ </Upload>
|
|
|
+ <a-button type="text" class="action-btn" @click="openChatHistory">
|
|
|
+ <template #icon><HistoryOutlined /></template>
|
|
|
+ 记录
|
|
|
+ </a-button>
|
|
|
+ </div>
|
|
|
+ <div class="input-wrapper">
|
|
|
+ <div class="textarea-wrapper">
|
|
|
+ <a-textarea
|
|
|
+ v-model:value="inputMessage"
|
|
|
+ placeholder="请输入"
|
|
|
+ :auto-size="{ minRows: 3, maxRows: 4 }"
|
|
|
+ @keypress="handleKeyPress"
|
|
|
+ :maxlength="MAX_MESSAGE_LENGTH"
|
|
|
+ :disabled="!canSendMessage"
|
|
|
+ class="message-input"
|
|
|
+ />
|
|
|
+ <div class="char-count" :class="{ 'char-count-warning': currentMessageLength > MAX_MESSAGE_LENGTH * 0.8 }">{{ currentMessageLength }}/{{ MAX_MESSAGE_LENGTH }}</div>
|
|
|
+ </div>
|
|
|
+ <a-button type="primary" @click="handleSend" :disabled="!canSendMessage" class="send-btn"> 发送 (enter) </a-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 未选择患者时的提示 -->
|
|
|
+ <div v-else class="empty-state">
|
|
|
+ <p>请选择患者开始咨询</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+.online-consult-container {
|
|
|
+ display: flex;
|
|
|
+ height: calc(100vh - 64px);
|
|
|
+ max-height: calc(100vh - 64px);
|
|
|
+ background: #fff;
|
|
|
+ overflow: hidden;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+// 左侧患者列表
|
|
|
+.patient-list {
|
|
|
+ width: 320px;
|
|
|
+ border-right: 1px solid #e8e8e8;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #fafafa;
|
|
|
+}
|
|
|
+
|
|
|
+.search-bar {
|
|
|
+ padding: 16px;
|
|
|
+ border-bottom: 1px solid #e8e8e8;
|
|
|
+ background: #fff;
|
|
|
+}
|
|
|
+.patient-items {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.patient-item {
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-bottom: 1px solid #e8e8e8;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #fff;
|
|
|
+ transition: background-color 0.2s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f5f5f5;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background: #e6f7ff;
|
|
|
+ border-right: 3px solid #1890ff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.patient-info {
|
|
|
+ .patient-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ .patient-name-wrapper {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .gender-icon {
|
|
|
+ font-size: 16px;
|
|
|
+ color: #ff4d4f;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .patient-name {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #333;
|
|
|
+ }
|
|
|
+
|
|
|
+ .patient-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ flex-shrink: 0;
|
|
|
+ margin-left: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .patient-meta {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .last-message {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #666;
|
|
|
+ flex: 1;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+ margin-right: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .consult-status {
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ &.consulting {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+
|
|
|
+ .status-dot {
|
|
|
+ width: 8px;
|
|
|
+ height: 8px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: #ff4d4f;
|
|
|
+ display: inline-block;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.ended {
|
|
|
+ color: #52c41a;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 右侧聊天界面
|
|
|
+.chat-container {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ background: #fff;
|
|
|
+ min-width: 0;
|
|
|
+ height: 100%;
|
|
|
+ position: relative;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 16px 24px;
|
|
|
+ border-bottom: 1px solid #e8e8e8;
|
|
|
+ flex-shrink: 0;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ .patient-details {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ flex: 1;
|
|
|
+
|
|
|
+ .gender-icon-large {
|
|
|
+ font-size: 18px;
|
|
|
+ color: #ff4d4f;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .patient-name-large {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 500;
|
|
|
+ color: #333;
|
|
|
+ }
|
|
|
+
|
|
|
+ .patient-phone {
|
|
|
+ font-size: 14px;
|
|
|
+ color: #666;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .header-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16px;
|
|
|
+ flex-shrink: 0;
|
|
|
+
|
|
|
+ :deep(.ant-btn-link) {
|
|
|
+ padding: 0;
|
|
|
+ height: auto;
|
|
|
+ color: #000 !important;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ color: #1890ff !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.ant-badge) {
|
|
|
+ margin-left: 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.chat-messages {
|
|
|
+ flex: 1;
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+ padding: 24px;
|
|
|
+ background: #f5f5f5;
|
|
|
+ min-height: 0;
|
|
|
+ max-height: none;
|
|
|
+}
|
|
|
+
|
|
|
+.message-item {
|
|
|
+ display: flex;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ &.send-type-1 {
|
|
|
+ justify-content: flex-start;
|
|
|
+
|
|
|
+ .message-avatar {
|
|
|
+ order: 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-content-wrapper {
|
|
|
+ order: 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-content {
|
|
|
+ background: #fff;
|
|
|
+ color: #333;
|
|
|
+ border-radius: 8px 8px 8px 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.send-type-4,
|
|
|
+ &.send-type-2 {
|
|
|
+ justify-content: flex-end;
|
|
|
+
|
|
|
+ .message-avatar {
|
|
|
+ order: 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-content-wrapper {
|
|
|
+ order: 1;
|
|
|
+ align-items: flex-end;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-content {
|
|
|
+ background: #1890ff;
|
|
|
+ color: #fff;
|
|
|
+ border-radius: 8px 8px 0 8px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.send-type-3 {
|
|
|
+ justify-content: center;
|
|
|
+ margin: 8px 0;
|
|
|
+
|
|
|
+ .message-content {
|
|
|
+ background: transparent;
|
|
|
+ color: #999;
|
|
|
+ font-size: 12px;
|
|
|
+ padding: 4px 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-avatar {
|
|
|
+ width: 32px;
|
|
|
+ height: 32px;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f0f0f0;
|
|
|
+ margin: 0 8px;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.message-content-wrapper {
|
|
|
+ max-width: 60%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+
|
|
|
+ &.send-type-1 {
|
|
|
+ align-items: flex-start;
|
|
|
+
|
|
|
+ .message-time {
|
|
|
+ text-align: left;
|
|
|
+ padding-left: 4px;
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.send-type-4,
|
|
|
+ &.send-type-2 {
|
|
|
+ align-items: flex-end;
|
|
|
+
|
|
|
+ .message-time {
|
|
|
+ text-align: right;
|
|
|
+ padding-right: 4px;
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.send-type-3 {
|
|
|
+ align-items: center;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 100%;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-content {
|
|
|
+ padding: 8px 12px;
|
|
|
+ word-wrap: break-word;
|
|
|
+ word-break: break-word;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ overflow-wrap: break-word;
|
|
|
+ max-width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &.pending {
|
|
|
+ opacity: 0.85;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-text {
|
|
|
+ display: block;
|
|
|
+ word-wrap: break-word;
|
|
|
+ word-break: break-word;
|
|
|
+ white-space: pre-wrap;
|
|
|
+ overflow-wrap: break-word;
|
|
|
+ width: 100%;
|
|
|
+ line-height: 1.5;
|
|
|
+ // text-align: center;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-image {
|
|
|
+ width: 200px !important;
|
|
|
+ height: 200px !important;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: block;
|
|
|
+ cursor: pointer;
|
|
|
+ background: transparent !important;
|
|
|
+
|
|
|
+ :deep(img) {
|
|
|
+ width: 200px !important;
|
|
|
+ height: 200px !important;
|
|
|
+ // width: auto !important;
|
|
|
+ // height: auto !important;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: block;
|
|
|
+ object-fit: contain;
|
|
|
+ background: transparent !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 当消息内容是图片时,去掉背景色和padding
|
|
|
+ &.has-image {
|
|
|
+ background: transparent !important;
|
|
|
+ padding: 0 !important;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.message-time {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ padding: 0 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.message-pending-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ padding: 4px 4px 0;
|
|
|
+ font-style: italic;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-input-area {
|
|
|
+ padding: 16px 24px;
|
|
|
+ border-top: 1px solid #e8e8e8;
|
|
|
+ background: #fff;
|
|
|
+ flex-shrink: 0;
|
|
|
+ position: relative;
|
|
|
+ z-index: 100;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.pending-images-preview {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 0;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+
|
|
|
+ .pending-image-item {
|
|
|
+ position: relative;
|
|
|
+ flex-shrink: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .pending-image-wrapper {
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+ border-radius: 8px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f0f0f0;
|
|
|
+ width: 80px;
|
|
|
+ height: 80px;
|
|
|
+
|
|
|
+ .pending-message-image {
|
|
|
+ width: 80px;
|
|
|
+ height: 80px;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: block;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ :deep(img) {
|
|
|
+ width: 80px !important;
|
|
|
+ height: 80px !important;
|
|
|
+ border-radius: 8px;
|
|
|
+ display: block;
|
|
|
+ object-fit: cover;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .remove-image-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: -6px;
|
|
|
+ right: -6px;
|
|
|
+ width: 20px;
|
|
|
+ height: 20px;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(0, 0, 0, 0.6);
|
|
|
+ color: #fff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ font-size: 16px;
|
|
|
+ line-height: 1;
|
|
|
+ z-index: 10;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: background 0.2s;
|
|
|
+ font-weight: 300;
|
|
|
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: rgba(0, 0, 0, 0.8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.input-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 8px;
|
|
|
+
|
|
|
+ .action-btn {
|
|
|
+ padding: 4px 8px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.input-wrapper {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ .textarea-wrapper {
|
|
|
+ position: relative;
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ .message-input {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.ant-input) {
|
|
|
+ resize: none;
|
|
|
+ min-height: 80px;
|
|
|
+ line-height: 1.5;
|
|
|
+ font-size: 14px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ padding-bottom: 28px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(textarea.ant-input) {
|
|
|
+ resize: none;
|
|
|
+ min-height: 80px;
|
|
|
+ line-height: 1.5;
|
|
|
+ font-size: 14px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ padding-bottom: 28px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .char-count {
|
|
|
+ position: absolute;
|
|
|
+ bottom: 8px;
|
|
|
+ right: 12px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #999;
|
|
|
+ pointer-events: none;
|
|
|
+ background: rgba(255, 255, 255, 0.9);
|
|
|
+ padding: 2px 4px;
|
|
|
+ border-radius: 2px;
|
|
|
+
|
|
|
+ &.char-count-warning {
|
|
|
+ color: #ff9800;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .send-btn {
|
|
|
+ // height: 40px;
|
|
|
+ // padding: 0 24px;
|
|
|
+ white-space: nowrap;
|
|
|
+ align-self: flex-end;
|
|
|
+ min-width: 100px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.empty-state {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #999;
|
|
|
+}
|
|
|
+
|
|
|
+.chat-history-content {
|
|
|
+ max-height: 500px;
|
|
|
+ overflow-y: auto;
|
|
|
+ padding: 16px;
|
|
|
+}
|
|
|
+</style>
|