Przeglądaj źródła

在线咨询优化

张田田 5 miesięcy temu
rodzic
commit
32f8f495b3

+ 1 - 0
@types/typed-router.d.ts

@@ -33,6 +33,7 @@ declare module 'vue-router/auto-routes' {
     '//follow/assessment': RouteRecordInfo<'//follow/assessment', '/follow/assessment', Record<never, never>, Record<never, never>>,
     '//follow/plan': RouteRecordInfo<'//follow/plan', '/follow/plan', Record<never, never>, Record<never, never>>,
     '//follow/task': RouteRecordInfo<'//follow/task', '/follow/task', Record<never, never>, Record<never, never>>,
+    '//online/onlineConsult': RouteRecordInfo<'//online/onlineConsult', '/online/onlineConsult', Record<never, never>, Record<never, never>>,
     '//patient/history': RouteRecordInfo<'//patient/history', '/patient/history', Record<never, never>, Record<never, never>>,
     '//patient/room': RouteRecordInfo<'//patient/room', '/patient/room', Record<never, never>, Record<never, never>>,
     '//system/role': RouteRecordInfo<'//system/role', '/system/role', Record<never, never>, Record<never, never>>,

+ 1 - 1
src/App.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import theme from '@/themes';
+import  theme from '@/themes';
 import zhCN  from 'ant-design-vue/es/locale/zh_CN';
 import dayjs from 'dayjs';
 import 'dayjs/locale/zh-cn';

+ 607 - 0
src/components/ChatHistory.vue

@@ -0,0 +1,607 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, nextTick } from 'vue';
+import { Image, Input, Button } from 'ant-design-vue';
+import { UserOutlined, RobotOutlined, SearchOutlined, CustomerServiceOutlined } from '@ant-design/icons-vue';
+import dayjs from 'dayjs';
+import { getConsultRecordListMethod, getConsultChatListMethod } from '@/request/api/consult.api';
+import type { ChatRecordModel, ChatMessageModel, ConsultantPeopleModel } from '@/model/consult.model';
+import { watchDebounced } from '@vueuse/core';
+
+defineOptions({ name: 'ChatHistory' });
+
+// 扩展的消息接口(用于添加开始/结束标记)
+type DisplayMessage = ChatMessageModel & {
+  id: number | string; // 确保 id 可以是字符串(用于标记消息)
+  isRecordStart?: boolean; // 是否是记录开始标记
+  isRecordEnd?: boolean; // 是否是记录结束标记
+};
+
+interface Props {
+  data?: ConsultantPeopleModel;
+  messages?: ChatMessageModel[];
+  onClose?: () => void;
+}
+
+const props = defineProps<Props>();
+
+const emits = defineEmits<{
+  close: [];
+}>();
+
+// 搜索关键词(支持内容和时间搜索)
+const searchKeyword = ref('');
+
+// 分页相关
+const pageSize = 10; // 每页加载数量
+const currentPage = ref(1);
+const loading = ref(false);
+const hasMore = ref(true);
+const messagesContainerRef = ref<HTMLElement | null>(null);
+// 当前显示的聊天记录
+const displayedRecords = ref<ChatRecordModel[]>([]);
+
+// 搜索模式下的消息列表
+const searchMessages = ref<ChatMessageModel[]>([]);
+
+// 是否处于搜索模式
+const isSearchMode = ref(false);
+
+// 总记录数
+const totalRecords = ref(0);
+
+// 初始化数据(加载第一页)
+onMounted(async () => {
+  // 重置状态
+  currentPage.value = 1;
+  displayedRecords.value = [];
+  searchMessages.value = [];
+  isSearchMode.value = false;
+  hasMore.value = true;
+  await getChatRecordList();
+
+  // 等待DOM更新后添加滚动监听
+  nextTick(() => {
+    if (messagesContainerRef.value) {
+      messagesContainerRef.value.addEventListener('scroll', handleScroll);
+    }
+  });
+});
+
+onUnmounted(() => {
+  // 移除滚动监听
+  if (messagesContainerRef.value) {
+    messagesContainerRef.value.removeEventListener('scroll', handleScroll);
+  }
+  // 清理定时器
+  if (scrollTimer) {
+    clearTimeout(scrollTimer);
+    scrollTimer = null;
+  }
+});
+// 获取搜索聊天记录列表
+async function getSearchChatRecordList(keyword: string) {
+  if (loading.value) return;
+  
+  loading.value = true;
+  isSearchMode.value = true;
+  
+  try {
+    const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
+    if (!patientId) {
+      console.error('缺少患者ID');
+      hasMore.value = false;
+      loading.value = false;
+      return;
+    }
+    
+    // 重置分页
+    currentPage.value = 1;
+    
+    const res = await getConsultChatListMethod(currentPage.value, pageSize, patientId, {
+      keyWord: keyword,
+    });
+    
+    if (res && res.data && res.data.length > 0) {
+      totalRecords.value = res?.total || 0;
+      // 搜索模式下,直接用搜索结果替换所有记录
+      searchMessages.value = res.data;
+      
+      // 检查是否还有更多数据
+      if (searchMessages.value.length >= totalRecords.value || res.data.length < pageSize) {
+        hasMore.value = false;
+      } else {
+        hasMore.value = true;
+      }
+    } else {
+      searchMessages.value = [];
+      hasMore.value = false;
+    }
+  } catch (error) {
+    console.error('获取搜索聊天记录失败:', error);
+    searchMessages.value = [];
+    hasMore.value = false;
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 获取聊天记录列表
+async function getChatRecordList() {
+  if (loading.value) return;
+  loading.value = true;
+  try {
+    // 使用 patientId 或 id 作为患者ID
+    const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
+    if (!patientId) {
+      console.error('缺少患者ID');
+      hasMore.value = false;
+      loading.value = false;
+      return;
+    }
+    const res = await getConsultRecordListMethod(currentPage.value, pageSize, patientId);
+    if (res && res.data && res.data.length > 0) {
+      totalRecords.value = res?.total || 0;
+      // 追加到数组末尾
+      displayedRecords.value = [...displayedRecords.value, ...res.data];
+      // 检查是否还有更多数据
+      if (displayedRecords.value.length >= totalRecords.value || res.data.length < pageSize) {
+        hasMore.value = false;
+      }
+    } else {
+      hasMore.value = false;
+    }
+  } catch (error) {
+    console.error('获取聊天记录失败:', error);
+    hasMore.value = false;
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 加载更多记录
+async function loadMoreMessages() {
+  if (loading.value || !hasMore.value) return;
+
+  currentPage.value++;
+  
+  // 如果是搜索模式,加载更多搜索结果
+  if (isSearchMode.value && searchKeyword.value) {
+    await loadMoreSearchMessages();
+  } else {
+    await getChatRecordList();
+  }
+}
+
+// 加载更多搜索结果
+async function loadMoreSearchMessages() {
+  if (loading.value) return;
+  
+  loading.value = true;
+  
+  try {
+    const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
+    if (!patientId) {
+      console.error('缺少患者ID');
+      hasMore.value = false;
+      loading.value = false;
+      return;
+    }
+    
+    const res = await getConsultChatListMethod(currentPage.value, pageSize, patientId, {
+      keyWord: searchKeyword.value,
+    });
+    
+    if (res && res.data && res.data.length > 0) {
+      // 追加到搜索结果
+      searchMessages.value = [...searchMessages.value, ...res.data];
+      
+      // 检查是否还有更多数据
+      if (searchMessages.value.length >= totalRecords.value || res.data.length < pageSize) {
+        hasMore.value = false;
+      }
+    } else {
+      hasMore.value = false;
+    }
+  } catch (error) {
+    console.error('加载更多搜索结果失败:', error);
+    hasMore.value = false;
+  } finally {
+    loading.value = false;
+  }
+}
+
+// 处理滚动事件(使用节流避免频繁触发)
+let scrollTimer: number | null = null;
+function handleScroll(e: Event) {
+  // 节流处理,避免频繁触发
+  if (scrollTimer) {
+    clearTimeout(scrollTimer);
+  }
+
+  scrollTimer = window.setTimeout(() => {
+    const target = e.target as HTMLElement;
+    // 当滚动到底部附近时(距离底部100px内),加载更多消息
+    const scrollTop = target.scrollTop;
+    const scrollHeight = target.scrollHeight;
+    const clientHeight = target.clientHeight;
+    const distanceToBottom = scrollHeight - scrollTop - clientHeight;
+
+    if (distanceToBottom <= 100 && hasMore.value && !loading.value) {
+      loadMoreMessages();
+    }
+    scrollTimer = null;
+  }, 100);
+}
+
+// 将所有记录展平为消息列表,并添加开始/结束标记
+const filteredMessages = computed(() => {
+  // 如果是搜索模式,直接返回搜索消息列表
+  if (isSearchMode.value && searchMessages.value.length > 0) {
+    return searchMessages.value as DisplayMessage[];
+  }
+
+  // 正常模式:处理聊天记录
+  const records = displayedRecords.value;
+  if (!records || records.length === 0) {
+    return [];
+  }
+
+  const messages: DisplayMessage[] = [];
+
+  // 使用 for 循环替代 forEach,性能更好
+  for (let i = 0; i < records.length; i++) {
+    const record = records[i];
+    const recordId = record.id || i;
+
+    // 添加开始咨询标记(每条记录的开始)
+    const startTime = (record as any).startTime;
+    if (startTime) {
+      messages.push({
+        id: `start-${recordId}`,
+        sendType: '3', // 系统消息
+        messageType: '1', // 文本
+        messageContent: `开始咨询 ${formatTime(startTime)}`,
+        sendTime: startTime,
+        consultRecordId: record.id,
+        isRecordStart: true,
+      } as DisplayMessage);
+    }
+
+    // 展平每条记录的 items,直接使用原始数据(避免扩展运算符,直接引用)
+    const items = record.items;
+    if (items && items.length > 0) {
+      // 直接 push items,避免扩展运算符的开销
+      for (let j = 0; j < items.length; j++) {
+        messages.push(items[j] as DisplayMessage);
+      }
+    }
+
+    // 添加结束咨询标记(每条记录的结束)
+    if (record.endTime) {
+      messages.push({
+        id: `end-${recordId}`,
+        sendType: '3', // 系统消息
+        messageType: '1', // 文本
+        messageContent: `咨询结束 ${formatTime(record.endTime)}`,
+        sendTime: record.endTime,
+        consultRecordId: record.id,
+        isRecordEnd: true,
+      } as DisplayMessage);
+    }
+  }
+
+  return messages;
+});
+watchDebounced(
+  searchKeyword,
+  async (newVal: any) => {
+    const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
+    if (!patientId) {
+      console.error('缺少患者ID');
+      return;
+    }
+    
+    if (newVal && newVal.trim()) {
+      // 有搜索关键词,执行搜索
+      await getSearchChatRecordList(newVal.trim());
+    } else {
+      // 清空搜索,恢复正常模式
+      isSearchMode.value = false;
+      searchMessages.value = [];
+      // 重置分页并重新加载正常记录
+      currentPage.value = 1;
+      displayedRecords.value = [];
+      hasMore.value = true;
+      await getChatRecordList();
+    }
+  },
+  { debounce: 500 }
+);
+
+// 格式化时间
+function formatTime(time: string) {
+  return dayjs(time).format('MM-DD HH:mm:ss');
+}
+
+// 关闭弹窗
+function handleClose() {
+  emits('close');
+  if (props.onClose) {
+    props.onClose();
+  }
+}
+</script>
+
+<template>
+  <div class="chat-history-container">
+    <!-- 搜索区域 -->
+    <div class="search-container">
+      <Input v-model:value="searchKeyword" placeholder="请输入搜索内容或时间" allow-clear>
+        <template #prefix>
+          <SearchOutlined />
+        </template>
+      </Input>
+    </div>
+
+    <!-- 聊天记录列表 -->
+    <div class="messages-container" ref="messagesContainerRef">
+      <div v-for="msg in filteredMessages" :key="msg.id" class="message-item" :class="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="msg.sendType">
+          <div class="message-content" :class="msg.sendType">
+            <Image
+              v-if="msg.messageType === '2'"
+              :src="msg.messageContent"
+              alt="图片"
+              style="width: 200px; height: 200px"
+              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 v-if="loading" class="loading-more">加载中...</div>
+      <div v-else-if="!hasMore && displayedRecords.length > 0" class="no-more">无更多数据</div>
+    </div>
+
+    <!-- 关闭按钮 -->
+    <div class="close-button-container">
+      <Button type="primary" @click="handleClose" block class="close-btn">关闭</Button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.chat-history-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 20px;
+  box-sizing: border-box;
+}
+
+.search-container {
+  flex-shrink: 0;
+  padding-bottom: 16px;
+
+  :deep(.ant-input) {
+    width: 100%;
+  }
+}
+
+.messages-container {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+
+  .loading-more,
+  .no-more {
+    text-align: center;
+    padding: 12px;
+    color: #999;
+    font-size: 12px;
+    flex-shrink: 0;
+  }
+}
+
+.close-button-container {
+  flex-shrink: 0;
+  padding-top: 16px;
+  margin-top: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  .close-btn {
+    width: 10%;
+  }
+}
+
+.message-item {
+  display: flex;
+  margin-bottom: 16px;
+  width: 100%;
+
+  // sendType: 1-患者
+  &[class*=' 1'],
+  &[class^='1 '],
+  &[class$=' 1'],
+  &[class='1'] {
+    justify-content: flex-start;
+
+    .message-avatar {
+      order: 1;
+    }
+
+    .message-content-wrapper {
+      order: 2;
+    }
+
+    .message-content {
+      // background: #fff;
+      background: #f0f0f0;
+      color: #333;
+      border-radius: 8px 8px 8px 0;
+    }
+  }
+
+  // sendType: 2-医生 4-AI
+  &[class*=' 2'],
+  &[class^='2 '],
+  &[class$=' 2'],
+  &[class='2'],
+  &[class*=' 4'],
+  &[class^='4 '],
+  &[class$=' 4'],
+  &[class='4'] {
+    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;
+    }
+  }
+
+  // sendType: 3-系统
+  &[class*=' 3'],
+  &[class^='3 '],
+  &[class$=' 3'],
+  &[class='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;
+
+  // sendType: 1-患者
+  &[class*=' 1'],
+  &[class^='1 '],
+  &[class$=' 1'],
+  &[class='1'] {
+    align-items: flex-start;
+
+    .message-time {
+      text-align: left;
+      padding-left: 4px;
+      margin-top: 4px;
+    }
+  }
+
+  // sendType: 2-医生 4-AI
+  &[class*=' 2'],
+  &[class^='2 '],
+  &[class$=' 2'],
+  &[class='2'],
+  &[class*=' 4'],
+  &[class^='4 '],
+  &[class$=' 4'],
+  &[class='4'] {
+    align-items: flex-end;
+
+    .message-time {
+      text-align: right;
+      padding-right: 4px;
+      margin-top: 4px;
+    }
+  }
+
+  // sendType: 3-系统
+  &[class*=' 3'],
+  &[class^='3 '],
+  &[class$=' 3'],
+  &[class='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;
+
+  .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;
+  }
+
+  .message-image {
+    max-width: 120px;
+    max-height: 120px;
+    border-radius: 8px;
+    display: block;
+    cursor: pointer;
+
+    :deep(img) {
+      max-width: 120px !important;
+      max-height: 120px !important;
+      width: auto !important;
+      height: auto !important;
+      border-radius: 8px;
+      display: block;
+      object-fit: contain;
+    }
+  }
+}
+
+.message-time {
+  font-size: 12px;
+  color: #999;
+  padding: 0 4px;
+}
+</style>

+ 455 - 0
src/components/CurrentDayChatHistory.vue

@@ -0,0 +1,455 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, watch } from 'vue';
+import { Image, Input, Button } from 'ant-design-vue';
+import { UserOutlined, RobotOutlined, SearchOutlined, CustomerServiceOutlined } from '@ant-design/icons-vue';
+import dayjs from 'dayjs';
+import type { ChatMessageModel } from '@/model/consult.model';
+import { watchDebounced } from '@vueuse/core';
+
+defineOptions({ name: 'CurrentDayChatHistory' });
+
+// 扩展的消息接口(用于添加开始/结束标记)
+type DisplayMessage = ChatMessageModel & {
+  id: number | string; // 确保 id 可以是字符串(用于标记消息)
+  isRecordStart?: boolean; // 是否是记录开始标记
+  isRecordEnd?: boolean; // 是否是记录结束标记
+};
+
+// 传入的聊天记录对象结构
+interface ChatRecordData {
+  startTime?: string; // 开始咨询时间
+  endTime?: string; // 结束咨询时间
+  items?: ChatMessageModel[]; // 聊天记录列表
+  id?: number; // 记录ID
+}
+
+interface Props {
+  row?: ChatRecordData; // 传入的聊天记录对象
+  onClose?: () => void;
+}
+
+const props = defineProps<Props>();
+
+const emits = defineEmits<{
+  close: [];
+}>();
+
+// 搜索关键词(支持内容和时间搜索)
+const searchKeyword = ref('');
+
+
+// 搜索模式下的消息列表
+const searchMessages = ref<DisplayMessage[]>([]);
+
+// 是否处于搜索模式
+const isSearchMode = ref(false);
+
+// 本地消息列表(从传入的 row 中提取)
+const localMessages = ref<DisplayMessage[]>([]);
+
+// 初始化数据
+onMounted(() => {
+  console.log('props.row===传入的聊天记录', props);
+  // 处理传入的聊天记录
+  if (props.row) {
+    processChatRecord(props.row);
+  }
+});
+
+// 监听传入的 row 变化
+watch(
+  () => props.row,
+  (newRow) => {
+    if (newRow) {
+      processChatRecord(newRow);
+      // 重置搜索状态
+      isSearchMode.value = false;
+      searchMessages.value = [];
+      searchKeyword.value = '';
+    }
+  },
+  { deep: true }
+);
+
+onUnmounted(() => {
+  // 清理工作(如果需要)
+});
+
+// 处理传入的聊天记录对象
+function processChatRecord(record: ChatRecordData) {
+  const messages: DisplayMessage[] = [];
+  const recordId = record.id || 0;
+
+  // 添加开始咨询标记
+  if (record.startTime) {
+    messages.push({
+      id: `start-${recordId}`,
+      sendType: '3', // 系统消息
+      messageType: '1', // 文本
+      messageContent: `开始咨询 ${formatTime(record.startTime)}`,
+      sendTime: record.startTime,
+      consultRecordId: record.id,
+      isRecordStart: true,
+    } as unknown as DisplayMessage);
+  }
+
+  // 添加聊天记录 items
+  if (record.items && record.items.length > 0) {
+    for (let i = 0; i < record.items.length; i++) {
+      messages.push(record.items[i] as DisplayMessage);
+    }
+  }
+
+  // 添加结束咨询标记
+  if (record.endTime) {
+    messages.push({
+      id: `end-${recordId}`,
+      sendType: '3', // 系统消息
+      messageType: '1', // 文本
+      messageContent: `咨询结束 ${formatTime(record.endTime)}`,
+      sendTime: record.endTime,
+      consultRecordId: record.id,
+      isRecordEnd: true,
+    } as unknown as DisplayMessage);
+  }
+
+  localMessages.value = messages;
+}
+
+// 本地搜索功能
+function performLocalSearch(keyword: string) {
+  if (!keyword || !keyword.trim()) {
+    isSearchMode.value = false;
+    searchMessages.value = [];
+    return;
+  }
+
+  isSearchMode.value = true;
+  const keywordLower = keyword.toLowerCase();
+  
+  searchMessages.value = localMessages.value.filter((msg) => {
+    // 排除图片消息,只搜索文本消息
+    if (msg.messageType === '2') {
+      return false;
+    }
+    // 搜索消息内容
+    if (msg.messageContent && msg.messageContent.toLowerCase().includes(keywordLower)) {
+      return true;
+    }
+    // 搜索时间
+    if (msg.sendTime && formatTime(msg.sendTime).includes(keyword)) {
+      return true;
+    }
+    return false;
+  });
+}
+
+// 将所有记录展平为消息列表,并添加开始/结束标记
+const filteredMessages = computed(() => {
+  // 如果是搜索模式,直接返回搜索消息列表
+  if (isSearchMode.value && searchMessages.value.length > 0) {
+    return searchMessages.value;
+  }
+
+  // 正常模式:返回本地消息列表
+  return localMessages.value;
+});
+watchDebounced(
+  searchKeyword,
+  (newVal: any) => {
+    if (newVal && newVal.trim()) {
+      // 有搜索关键词,执行本地搜索
+      performLocalSearch(newVal.trim());
+    } else {
+      // 清空搜索,恢复正常模式
+      isSearchMode.value = false;
+      searchMessages.value = [];
+    }
+  },
+  { debounce: 500 }
+);
+
+// 格式化时间
+function formatTime(time: string) {
+  return dayjs(time).format('MM-DD HH:mm:ss');
+}
+
+// 关闭弹窗
+function handleClose() {
+  emits('close');
+  if (props.onClose) {
+    props.onClose();
+  }
+}
+</script>
+
+<template>
+  <div class="chat-history-container">
+    <!-- 搜索区域 -->
+    <div class="search-container">
+      <Input v-model:value="searchKeyword" placeholder="请输入搜索内容或时间" allow-clear>
+        <template #prefix>
+          <SearchOutlined />
+        </template>
+      </Input>
+    </div>
+
+    <!-- 聊天记录列表 -->
+    <div class="messages-container">
+      <div v-for="msg in filteredMessages" :key="msg.id" class="message-item" :class="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="msg.sendType">
+          <div class="message-content" :class="msg.sendType">
+            <Image
+              v-if="msg.messageType === '2'"
+              :src="msg.messageContent"
+              alt="图片"
+              style="width: 200px; height: 200px"
+              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 v-if="filteredMessages.length === 0" class="no-more">暂无聊天记录</div>
+    </div>
+    <!-- 关闭按钮 -->
+    <div class="close-button-container">
+      <Button type="primary" @click="handleClose" block class="close-btn">关闭</Button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.chat-history-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 20px;
+  box-sizing: border-box;
+}
+
+.search-container {
+  flex-shrink: 0;
+  padding-bottom: 16px;
+
+  :deep(.ant-input) {
+    width: 100%;
+  }
+}
+
+.messages-container {
+  flex: 1;
+  overflow-y: auto;
+  padding: 16px 0;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+
+  .loading-more,
+  .no-more {
+    text-align: center;
+    padding: 12px;
+    color: #999;
+    font-size: 12px;
+    flex-shrink: 0;
+  }
+}
+
+.close-button-container {
+  flex-shrink: 0;
+  padding-top: 16px;
+  margin-top: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  .close-btn {
+    width: 10%;
+  }
+}
+
+.message-item {
+  display: flex;
+  margin-bottom: 16px;
+  width: 100%;
+
+  // sendType: 1-患者
+  &[class*=' 1'],
+  &[class^='1 '],
+  &[class$=' 1'],
+  &[class='1'] {
+    justify-content: flex-start;
+
+    .message-avatar {
+      order: 1;
+    }
+
+    .message-content-wrapper {
+      order: 2;
+    }
+
+    .message-content {
+      // background: #fff;
+      background: #f0f0f0;
+      color: #333;
+      border-radius: 8px 8px 8px 0;
+    }
+  }
+
+  // sendType: 2-医生 4-AI
+  &[class*=' 2'],
+  &[class^='2 '],
+  &[class$=' 2'],
+  &[class='2'],
+  &[class*=' 4'],
+  &[class^='4 '],
+  &[class$=' 4'],
+  &[class='4'] {
+    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;
+    }
+  }
+
+  // sendType: 3-系统
+  &[class*=' 3'],
+  &[class^='3 '],
+  &[class$=' 3'],
+  &[class='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;
+
+  // sendType: 1-患者
+  &[class*=' 1'],
+  &[class^='1 '],
+  &[class$=' 1'],
+  &[class='1'] {
+    align-items: flex-start;
+
+    .message-time {
+      text-align: left;
+      padding-left: 4px;
+      margin-top: 4px;
+    }
+  }
+
+  // sendType: 2-医生 4-AI
+  &[class*=' 2'],
+  &[class^='2 '],
+  &[class$=' 2'],
+  &[class='2'],
+  &[class*=' 4'],
+  &[class^='4 '],
+  &[class$=' 4'],
+  &[class='4'] {
+    align-items: flex-end;
+
+    .message-time {
+      text-align: right;
+      padding-right: 4px;
+      margin-top: 4px;
+    }
+  }
+
+  // sendType: 3-系统
+  &[class*=' 3'],
+  &[class^='3 '],
+  &[class$=' 3'],
+  &[class='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;
+
+  .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;
+  }
+
+  .message-image {
+    max-width: 120px;
+    max-height: 120px;
+    border-radius: 8px;
+    display: block;
+    cursor: pointer;
+
+    :deep(img) {
+      max-width: 120px !important;
+      max-height: 120px !important;
+      width: auto !important;
+      height: auto !important;
+      border-radius: 8px;
+      display: block;
+      object-fit: contain;
+    }
+  }
+}
+
+.message-time {
+  font-size: 12px;
+  color: #999;
+  padding: 0 4px;
+}
+</style>

+ 43 - 0
src/model/consult.model.ts

@@ -0,0 +1,43 @@
+export interface ConsultantPeopleModel {
+  patientId: number; //患者id
+  name: string; //姓名
+  sex?: '0' | '1'; //1 女 0 男
+  age?: number;
+  phone?: number;
+  healthAnalysisReportId?: number; //健康分析报告id
+  consultRecordId?: number; //咨询记录id
+  manualTime?: string; //转人工的时间
+  isTop?: string; //是否置顶
+  progress?: number; //进度 0 未开始 1 进行中 2 咨询结束
+  doctorUnreadCount?: number; //医生未读消息数
+  sendTimeStr?: string; //发送时间
+  sendTime?: string; //最后一条消息发送时间
+  messageType?: number; //1 文本 2 图片
+  messageContent?: string; //最后一条消息内容
+  isCanSend?: boolean; //是否可以发送消息
+}
+
+export interface ChatMessageModel {
+  id?: number; //咨询记录详情ID
+  consultRecordId?: number; //咨询记录ID
+  sendTime?: string; //发送时间
+  sendType?: string; //发送类型 1-患者 2-医生 3-系统 4-AI
+  messageType?: string; //消息类型 1-文本 2-图片
+  messageContent?: string; //消息内容
+  isRead?: string; //是否已读
+}
+export interface ChatRecordModel {
+  id?: number; //咨询记录ID
+  sendTime?: string; //发送时间
+  endTime?: string; //结束时间
+  manualTime?: string; //转人工时间
+  items:{
+    id?: number; //咨询记录详情ID
+    consultRecordId?: number; //咨询记录ID
+    sendTime?: string; //发送时间
+    sendType?: string; //发送类型 1-患者 2-医生 3-系统 4-AI
+    messageType?: string; //消息类型 1-文本 2-图片
+    messageContent?: string; //消息内容
+    isRead?: string; //是否已读
+  }[];
+}

+ 1 - 1
src/pages/index/care/conditioningRecord.vue

@@ -103,7 +103,7 @@ const gridOptions = reactive<VxeGridProps<ConditioningRecordListModel>>({
       field: 'action',
       title: '操作',
       align: 'center',
-      width: 120,
+      width: 200,
       cellRender: {
         name: 'VxeButtonGroup',
         props: {

+ 1 - 1
src/pages/index/equipment/reportManagement.vue

@@ -223,7 +223,7 @@ onMounted(() => {
 });
 
 // 查看健康分析报告
-function openHistoryPreviewHandle(row: DeviceReportModel) {
+function openHistoryPreviewHandle(row: any) {
   console.log(row, '健康分析报告');
   const component = defineAsyncComponent(() => import('@/components/ReportPreview.vue'));
   const id = `drawer:report:preview`;

+ 1136 - 0
src/pages/index/online/onlineConsult.vue

@@ -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>

+ 14 - 11
src/request/api/account.api.ts

@@ -69,17 +69,20 @@ export function getMenusMethod(account: AccountModel) {
   return request.Get<AccountModel, any[]>(`/system/menu/getRouters`, {
     headers: { Authorization: account.token },
     transform(data) {
-    // data[5]?.children?.push({
-    //     path: 'configured',
-    //     meta: { title: '辨识仪配置' }
-    //   },
-    //   {
-    //     path: 'reportManagement',
-    //     meta: { title: '报告管理' }
-    //   },
-    // );
-    //   console.log(data, 'push之后的data', transformMenus(data));
+      // console.log(data, 'data');
+      // data.push({
+      //   path: '/',
+      //   meta: { title: '在线咨询' },
+      //   children: [
+      //     {
+      //       path: 'online/onlineConsult',
+      //       name: 'online/onlineConsult',
+      //       meta: { title: '在线咨询' },
+      //     },
+      //   ],
+      // });
+      console.log(data, 'push之后的data', transformMenus(data));
       return { ...account, menus: transformMenus(data) };
-    }
+    },
   });
 }

+ 68 - 0
src/request/api/consult.api.ts

@@ -0,0 +1,68 @@
+import type { List } from '@/model';
+import request from '@/request/alova';
+import type { ConsultantPeopleModel, ChatMessageModel } from '@/model/consult.model';
+
+// 获取当前用户所在组织下的咨询人列表
+export function getConsultantListMethod(name?: string) {
+  return request.Post<List<ConsultantPeopleModel>>(
+    '/fdhb-pc/consultManage/getConsultPats',
+    {},
+    {
+      hitSource: /consult$/, // 匹配失效源
+      params: { name },
+    }
+  );
+}
+// 获取某个咨询记录下的所有消息
+export function getAllConsultChatListMethod(id: number) {
+  return request.Post<List<ConsultantPeopleModel>>(`/fdhb-pc/consultManage/getAllMsgs/${id}`);
+}
+// 发送咨询信息
+export function sendConsultChatMethod(patientId: number, query?: Record<string, any>) {
+  return request.Post<void>(
+    `/fdhb-pc/consultManage/sendConsultMsg/${patientId}`,
+    {
+      consultRecordId: query?.consultRecordId,
+      messageType: query?.messageType,
+      messageContent: query?.messageContent,
+    },
+    {
+      hitSource: /consultChat$/, // 匹配失效源
+    }
+  );
+}
+// 置顶患者
+export function pinConsultantMethod(id: number) {
+  return request.Post<void>(`/fdhb-pc/consultManage/top/${id}`);
+}
+// 取消置顶
+export function cancelPinConsultantMethod(id: number) {
+  return request.Post<void>(`/fdhb-pc/consultManage/topCancel/${id}`);
+} 
+// 获取患者聊天分页列表
+export function getConsultRecordListMethod(page: number, size: number, patientId: string) {
+  return request.Post<List<ChatMessageModel>>(
+    `/fdhb-pc/consultManage/pageConsult/${patientId}`,
+    {},
+    {
+      params: { pageNum: page, pageSize: size },
+    }
+  );
+}
+// 获取搜索患者聊天记录分页列表
+export function getConsultChatListMethod(page: number, size: number, patientId: string, query?: Record<string, any>) {
+  return request.Get<List<ChatMessageModel>>(`/fdhb-pc/consultManage/pageConsultMsg/${patientId}`, {
+    hitSource: /consultChat$/, // 匹配失效源
+    params: {
+      keyWord: query?.keyWord,
+      pageNum: page,
+      pageSize: size,
+    },
+  });
+}
+// 刷新当前聊天框
+export function refreshConsultMethod(id: string) {
+  return request.Post<void>(`/fdhb-pc/consultManage/getLatestMsgs/${id}`, {
+    hitSource: /consultRefresh$/, // 匹配失效源
+  });
+}

+ 5 - 0
src/router/index.ts

@@ -61,6 +61,11 @@ const router = createRouter({
         
           ],
         },
+        {
+          path: 'online', children: [
+            { path: 'onlineConsult', component: () => import(`@/pages/index/online/onlineConsult.vue`) },
+          ],
+        },
       ],
       beforeEnter(to, from, next) {
         if ( useAccountStore(pinia).token ) {

+ 53 - 1
src/service/CareProgress.vue

@@ -16,7 +16,6 @@ import { GridComponent, LegendComponent, MarkLineComponent, TitleComponent, Tool
 import { use } from 'echarts/core';
 import { CanvasRenderer } from 'echarts/renderers';
 import VChart from 'vue-echarts';
-
 use([CanvasRenderer, LineChart, MarkLineComponent, GridComponent, VisualMapComponent, TitleComponent, TooltipComponent, LegendComponent]);
 
 const type = ref('careProgress');
@@ -58,6 +57,33 @@ onMounted(async () => {
     await getCareProgress();
   }
 });
+function openChatRecord(row: any) {
+  console.log('row===聊天记录', row);
+  const component = defineAsyncComponent(() => import('@/components/CurrentDayChatHistory.vue'));
+  const id = `drawer:chat-record:preview`;
+  const onClose = () => {
+    VxeUI.drawer.close(id);
+  };
+  onClose();
+  VxeUI.drawer.open({
+    id,
+    mask: true,
+    lockView: false,
+    padding: false,
+    width: window.innerWidth - 256,
+    escClosable: true,
+    maskClosable: true,
+    title: `聊天记录`,
+    slots: {
+      default() {
+        return h(component, {
+          row: row,
+          onClose,
+        });
+      },
+    },
+  });
+};
 function openIndicatorRecordsPreview() {
   const component = defineAsyncComponent(() => import('@/components/RecordsIndicatorPreview.vue'));
   const id = `modal:record-indicator:preview`;
@@ -293,6 +319,32 @@ const progressTextMap: Record<string, string> = {
           <div class="mb-1">预定频率:每{{ item.frequencyType }}天{{ item.frequencyMeasure }}{{ item?.conditioningProgramDetail?.cpFixedPricingRule?.convertUnit || '次' }}</div>
           <div v-if="item.remark">操作指南:{{ item.remark }}</div>
         </div>
+      </div>
+        <!-- 健康咨询 -->
+        <div class="project-section mb-3 project-card" v-if="item?.consults">
+        <div class="project-title">
+          <span style="font-size: 14px; font-weight: bold; color: black">◇ {{ item?.conditioningProgramDetail?.name }}</span>
+          <span class="stat">数量:{{ item?.totalMeasure }} {{ item?.conditioningProgramDetail?.cpFixedPricingRule?.pricingUnit || '次' }}</span>
+          <span class="stat">还剩:{{ item?.remainCount }} {{ item?.conditioningProgramDetail?.cpFixedPricingRule?.pricingUnit || '次' }}</span>
+          <span class="stat">已核销:{{ item?.finishCount }} {{ item?.conditioningProgramDetail?.cpFixedPricingRule?.pricingUnit || '次' }}</span>
+        </div>
+        <vxe-table :data="item?.consults" border>
+          <vxe-column type="seq" title="序号" width="80" />
+          <vxe-column field="startTime" title="操作时间" />
+          <vxe-column field="items" title="聊天记录">
+            <template #default="{ row }">
+              <a-button type="link" @click="openChatRecord(row)">查看</a-button>
+            </template>
+          </vxe-column>
+        </vxe-table>
+        <div class="mt-3">
+          <div class="mb-1">
+            预定频率:
+            <span v-if="item?.frequencyType === '不限'">不限</span>
+            <span v-else>每{{ item?.frequencyType }}天{{ item?.frequencyMeasure }}{{ item?.conditioningProgramDetail?.cpFixedPricingRule?.convertUnit || '次' }} </span>
+          </div>
+          <div v-if="item?.remark">操作指南:{{ item?.remark }}</div>
+        </div>
       </div>
       <!-- 线上 -->
       <div class="yuanqi-tea" v-if="item?.patientConditioningOnlines">