ChatHistory.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613
  1. <script setup lang="ts">
  2. import { onMounted, onUnmounted, nextTick } from 'vue';
  3. import { Image, Input, Button } from 'ant-design-vue';
  4. import { UserOutlined, RobotOutlined, SearchOutlined, CustomerServiceOutlined } from '@ant-design/icons-vue';
  5. import dayjs from 'dayjs';
  6. import { getConsultRecordListMethod, getConsultChatListMethod } from '@/request/api/consult.api';
  7. import type { ChatRecordModel, ChatMessageModel, ConsultantPeopleModel } from '@/model/consult.model';
  8. import { watchDebounced } from '@vueuse/core';
  9. import { renderMarkdown } from '@/tools/markdown';
  10. defineOptions({ name: 'ChatHistory' });
  11. // 扩展的消息接口(用于添加开始/结束标记)
  12. type DisplayMessage = ChatMessageModel & {
  13. id: number | string; // 确保 id 可以是字符串(用于标记消息)
  14. isRecordStart?: boolean; // 是否是记录开始标记
  15. isRecordEnd?: boolean; // 是否是记录结束标记
  16. };
  17. interface Props {
  18. data?: ConsultantPeopleModel;
  19. messages?: ChatMessageModel[];
  20. onClose?: () => void;
  21. }
  22. const props = defineProps<Props>();
  23. const emits = defineEmits<{
  24. close: [];
  25. }>();
  26. // 搜索关键词(支持内容和时间搜索)
  27. const searchKeyword = ref('');
  28. // 分页相关
  29. const pageSize = 10; // 每页加载数量
  30. const currentPage = ref(1);
  31. const loading = ref(false);
  32. const hasMore = ref(true);
  33. const messagesContainerRef = ref<HTMLElement | null>(null);
  34. // 当前显示的聊天记录
  35. const displayedRecords = ref<ChatRecordModel[]>([]);
  36. // 搜索模式下的消息列表
  37. const searchMessages = ref<ChatMessageModel[]>([]);
  38. // 是否处于搜索模式
  39. const isSearchMode = ref(false);
  40. // 总记录数
  41. const totalRecords = ref(0);
  42. // 初始化数据(加载第一页)
  43. onMounted(async () => {
  44. // 重置状态
  45. currentPage.value = 1;
  46. displayedRecords.value = [];
  47. searchMessages.value = [];
  48. isSearchMode.value = false;
  49. hasMore.value = true;
  50. await getChatRecordList();
  51. // 等待DOM更新后添加滚动监听
  52. nextTick(() => {
  53. if (messagesContainerRef.value) {
  54. messagesContainerRef.value.addEventListener('scroll', handleScroll);
  55. }
  56. });
  57. });
  58. onUnmounted(() => {
  59. // 移除滚动监听
  60. if (messagesContainerRef.value) {
  61. messagesContainerRef.value.removeEventListener('scroll', handleScroll);
  62. }
  63. // 清理定时器
  64. if (scrollTimer) {
  65. clearTimeout(scrollTimer);
  66. scrollTimer = null;
  67. }
  68. });
  69. // 获取搜索聊天记录列表
  70. async function getSearchChatRecordList(keyword: string) {
  71. if (loading.value) return;
  72. loading.value = true;
  73. isSearchMode.value = true;
  74. try {
  75. const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
  76. if (!patientId) {
  77. console.error('缺少患者ID');
  78. hasMore.value = false;
  79. loading.value = false;
  80. return;
  81. }
  82. // 重置分页
  83. currentPage.value = 1;
  84. const res = await getConsultChatListMethod(currentPage.value, pageSize, patientId, {
  85. keyWord: keyword,
  86. });
  87. if (res && res.data && res.data.length > 0) {
  88. totalRecords.value = res?.total || 0;
  89. // 搜索模式下,直接用搜索结果替换所有记录
  90. searchMessages.value = res.data;
  91. // 检查是否还有更多数据
  92. if (searchMessages.value.length >= totalRecords.value || res.data.length < pageSize) {
  93. hasMore.value = false;
  94. } else {
  95. hasMore.value = true;
  96. }
  97. } else {
  98. searchMessages.value = [];
  99. hasMore.value = false;
  100. }
  101. } catch (error) {
  102. console.error('获取搜索聊天记录失败:', error);
  103. searchMessages.value = [];
  104. hasMore.value = false;
  105. } finally {
  106. loading.value = false;
  107. }
  108. }
  109. // 获取聊天记录列表
  110. async function getChatRecordList() {
  111. if (loading.value) return;
  112. loading.value = true;
  113. try {
  114. // 使用 patientId 或 id 作为患者ID
  115. const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
  116. if (!patientId) {
  117. console.error('缺少患者ID');
  118. hasMore.value = false;
  119. loading.value = false;
  120. return;
  121. }
  122. const res = await getConsultRecordListMethod(currentPage.value, pageSize, patientId);
  123. if (res && res.data && res.data.length > 0) {
  124. totalRecords.value = res?.total || 0;
  125. // 追加到数组末尾
  126. displayedRecords.value = [...displayedRecords.value, ...res.data];
  127. // 检查是否还有更多数据
  128. if (displayedRecords.value.length >= totalRecords.value || res.data.length < pageSize) {
  129. hasMore.value = false;
  130. }
  131. } else {
  132. hasMore.value = false;
  133. }
  134. } catch (error) {
  135. console.error('获取聊天记录失败:', error);
  136. hasMore.value = false;
  137. } finally {
  138. loading.value = false;
  139. }
  140. }
  141. // 加载更多记录
  142. async function loadMoreMessages() {
  143. if (loading.value || !hasMore.value) return;
  144. currentPage.value++;
  145. // 如果是搜索模式,加载更多搜索结果
  146. if (isSearchMode.value && searchKeyword.value) {
  147. await loadMoreSearchMessages();
  148. } else {
  149. await getChatRecordList();
  150. }
  151. }
  152. // 加载更多搜索结果
  153. async function loadMoreSearchMessages() {
  154. if (loading.value) return;
  155. loading.value = true;
  156. try {
  157. const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
  158. if (!patientId) {
  159. console.error('缺少患者ID');
  160. hasMore.value = false;
  161. loading.value = false;
  162. return;
  163. }
  164. const res = await getConsultChatListMethod(currentPage.value, pageSize, patientId, {
  165. keyWord: searchKeyword.value,
  166. });
  167. if (res && res.data && res.data.length > 0) {
  168. // 追加到搜索结果
  169. searchMessages.value = [...searchMessages.value, ...res.data];
  170. // 检查是否还有更多数据
  171. if (searchMessages.value.length >= totalRecords.value || res.data.length < pageSize) {
  172. hasMore.value = false;
  173. }
  174. } else {
  175. hasMore.value = false;
  176. }
  177. } catch (error) {
  178. console.error('加载更多搜索结果失败:', error);
  179. hasMore.value = false;
  180. } finally {
  181. loading.value = false;
  182. }
  183. }
  184. // 处理滚动事件(使用节流避免频繁触发)
  185. let scrollTimer: number | null = null;
  186. function handleScroll(e: Event) {
  187. // 节流处理,避免频繁触发
  188. if (scrollTimer) {
  189. clearTimeout(scrollTimer);
  190. }
  191. scrollTimer = window.setTimeout(() => {
  192. const target = e.target as HTMLElement;
  193. // 当滚动到底部附近时(距离底部100px内),加载更多消息
  194. const scrollTop = target.scrollTop;
  195. const scrollHeight = target.scrollHeight;
  196. const clientHeight = target.clientHeight;
  197. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  198. if (distanceToBottom <= 100 && hasMore.value && !loading.value) {
  199. loadMoreMessages();
  200. }
  201. scrollTimer = null;
  202. }, 100);
  203. }
  204. // 将所有记录展平为消息列表,并添加开始/结束标记
  205. const filteredMessages = computed(() => {
  206. // 如果是搜索模式,直接返回搜索消息列表
  207. if (isSearchMode.value && searchMessages.value.length > 0) {
  208. return searchMessages.value as DisplayMessage[];
  209. }
  210. // 正常模式:处理聊天记录
  211. const records = displayedRecords.value;
  212. if (!records || records.length === 0) {
  213. return [];
  214. }
  215. const messages: DisplayMessage[] = [];
  216. // 使用 for 循环替代 forEach,性能更好
  217. for (let i = 0; i < records.length; i++) {
  218. const record = records[i];
  219. const recordId = record.id || i;
  220. // 添加开始咨询标记(每条记录的开始)
  221. const startTime = (record as any).startTime;
  222. if (startTime) {
  223. messages.push({
  224. id: `start-${recordId}`,
  225. sendType: '3', // 系统消息
  226. messageType: '1', // 文本
  227. messageContent: `开始咨询 ${formatTime(startTime)}`,
  228. sendTime: startTime,
  229. consultRecordId: record.id,
  230. isRecordStart: true,
  231. } as DisplayMessage);
  232. }
  233. // 展平每条记录的 items,直接使用原始数据(避免扩展运算符,直接引用)
  234. const items = record.items;
  235. if (items && items.length > 0) {
  236. // 直接 push items,避免扩展运算符的开销
  237. for (let j = 0; j < items.length; j++) {
  238. messages.push(items[j] as DisplayMessage);
  239. }
  240. }
  241. // 添加结束咨询标记(每条记录的结束)
  242. if (record.endTime) {
  243. messages.push({
  244. id: `end-${recordId}`,
  245. sendType: '3', // 系统消息
  246. messageType: '1', // 文本
  247. messageContent: `咨询结束 ${formatTime(record.endTime)}`,
  248. sendTime: record.endTime,
  249. consultRecordId: record.id,
  250. isRecordEnd: true,
  251. } as DisplayMessage);
  252. }
  253. }
  254. return messages;
  255. });
  256. watchDebounced(
  257. searchKeyword,
  258. async (newVal: any) => {
  259. const patientId = (props.data as any)?.patientId || (props.data as any)?.id?.toString();
  260. if (!patientId) {
  261. console.error('缺少患者ID');
  262. return;
  263. }
  264. if (newVal && newVal.trim()) {
  265. // 有搜索关键词,执行搜索
  266. await getSearchChatRecordList(newVal.trim());
  267. } else {
  268. // 清空搜索,恢复正常模式
  269. isSearchMode.value = false;
  270. searchMessages.value = [];
  271. // 重置分页并重新加载正常记录
  272. currentPage.value = 1;
  273. displayedRecords.value = [];
  274. hasMore.value = true;
  275. await getChatRecordList();
  276. }
  277. },
  278. { debounce: 500 }
  279. );
  280. // 格式化时间
  281. function formatTime(time: string) {
  282. return dayjs(time).format('MM-DD HH:mm:ss');
  283. }
  284. // 关闭弹窗
  285. function handleClose() {
  286. emits('close');
  287. if (props.onClose) {
  288. props.onClose();
  289. }
  290. }
  291. </script>
  292. <template>
  293. <div class="chat-history-container">
  294. <!-- 搜索区域 -->
  295. <div class="search-container">
  296. <Input v-model:value="searchKeyword" placeholder="请输入搜索内容或时间" allow-clear>
  297. <template #prefix>
  298. <SearchOutlined />
  299. </template>
  300. </Input>
  301. </div>
  302. <!-- 聊天记录列表 -->
  303. <div class="messages-container" ref="messagesContainerRef">
  304. <div v-for="msg in filteredMessages" :key="msg.id" class="message-item" :class="msg.sendType">
  305. <div class="message-avatar" v-if="msg.sendType !== '3'">
  306. <UserOutlined v-if="msg.sendType === '1'" />
  307. <RobotOutlined v-else-if="msg.sendType === '4'" />
  308. <CustomerServiceOutlined v-else-if="msg.sendType === '2'" />
  309. </div>
  310. <div class="message-content-wrapper" :class="msg.sendType">
  311. <div class="message-content" :class="msg.sendType">
  312. <Image
  313. v-if="msg.messageType === '2'"
  314. :src="msg.messageContent"
  315. alt="图片"
  316. style="width: 200px; height: 200px"
  317. class="message-image"
  318. :preview="{
  319. src: msg.messageContent,
  320. }"
  321. />
  322. <div v-else class="message-text" v-html="renderMarkdown(msg.messageContent)"></div>
  323. </div>
  324. <div class="message-time" v-if="msg.sendType !== '3'">{{ formatTime(msg.sendTime) }}</div>
  325. </div>
  326. </div>
  327. <!-- 加载提示 -->
  328. <div v-if="loading" class="loading-more">加载中...</div>
  329. <div v-else-if="!hasMore && displayedRecords.length > 0" class="no-more">无更多数据</div>
  330. </div>
  331. <!-- 关闭按钮 -->
  332. <div class="close-button-container">
  333. <Button type="primary" @click="handleClose" block class="close-btn">关闭</Button>
  334. </div>
  335. </div>
  336. </template>
  337. <style scoped lang="scss">
  338. .chat-history-container {
  339. display: flex;
  340. flex-direction: column;
  341. height: 100%;
  342. padding: 20px;
  343. box-sizing: border-box;
  344. }
  345. .search-container {
  346. flex-shrink: 0;
  347. padding-bottom: 16px;
  348. :deep(.ant-input) {
  349. width: 100%;
  350. }
  351. }
  352. .messages-container {
  353. flex: 1;
  354. overflow-y: auto;
  355. padding: 16px 0;
  356. min-height: 0;
  357. display: flex;
  358. flex-direction: column;
  359. .loading-more,
  360. .no-more {
  361. text-align: center;
  362. padding: 12px;
  363. color: #999;
  364. font-size: 12px;
  365. flex-shrink: 0;
  366. }
  367. }
  368. .close-button-container {
  369. flex-shrink: 0;
  370. padding-top: 16px;
  371. margin-top: 16px;
  372. display: flex;
  373. align-items: center;
  374. justify-content: center;
  375. .close-btn {
  376. width: 10%;
  377. }
  378. }
  379. .message-item {
  380. display: flex;
  381. margin-bottom: 16px;
  382. width: 100%;
  383. // sendType: 1-患者
  384. &[class*=' 1'],
  385. &[class^='1 '],
  386. &[class$=' 1'],
  387. &[class='1'] {
  388. justify-content: flex-start;
  389. .message-avatar {
  390. order: 1;
  391. }
  392. .message-content-wrapper {
  393. order: 2;
  394. }
  395. .message-content {
  396. // background: #fff;
  397. background: #f0f0f0;
  398. color: #333;
  399. border-radius: 8px 8px 8px 0;
  400. }
  401. }
  402. // sendType: 2-医生 4-AI
  403. &[class*=' 2'],
  404. &[class^='2 '],
  405. &[class$=' 2'],
  406. &[class='2'],
  407. &[class*=' 4'],
  408. &[class^='4 '],
  409. &[class$=' 4'],
  410. &[class='4'] {
  411. justify-content: flex-end;
  412. .message-avatar {
  413. order: 2;
  414. }
  415. .message-content-wrapper {
  416. order: 1;
  417. align-items: flex-end;
  418. }
  419. .message-content {
  420. background: #1890ff;
  421. color: #fff;
  422. border-radius: 8px 8px 0 8px;
  423. }
  424. }
  425. // sendType: 3-系统
  426. &[class*=' 3'],
  427. &[class^='3 '],
  428. &[class$=' 3'],
  429. &[class='3'] {
  430. justify-content: center;
  431. margin: 8px 0;
  432. .message-content {
  433. background: transparent;
  434. color: #999;
  435. font-size: 12px;
  436. padding: 4px 0;
  437. }
  438. }
  439. }
  440. .message-avatar {
  441. width: 32px;
  442. height: 32px;
  443. border-radius: 50%;
  444. display: flex;
  445. align-items: center;
  446. justify-content: center;
  447. background: #f0f0f0;
  448. margin: 0 8px;
  449. flex-shrink: 0;
  450. }
  451. .message-content-wrapper {
  452. max-width: 60%;
  453. display: flex;
  454. flex-direction: column;
  455. // sendType: 1-患者
  456. &[class*=' 1'],
  457. &[class^='1 '],
  458. &[class$=' 1'],
  459. &[class='1'] {
  460. align-items: flex-start;
  461. .message-time {
  462. text-align: left;
  463. padding-left: 4px;
  464. margin-top: 4px;
  465. }
  466. }
  467. // sendType: 2-医生 4-AI
  468. &[class*=' 2'],
  469. &[class^='2 '],
  470. &[class$=' 2'],
  471. &[class='2'],
  472. &[class*=' 4'],
  473. &[class^='4 '],
  474. &[class$=' 4'],
  475. &[class='4'] {
  476. align-items: flex-end;
  477. .message-time {
  478. text-align: right;
  479. padding-right: 4px;
  480. margin-top: 4px;
  481. }
  482. }
  483. // sendType: 3-系统
  484. &[class*=' 3'],
  485. &[class^='3 '],
  486. &[class$=' 3'],
  487. &[class='3'] {
  488. align-items: center;
  489. width: 100%;
  490. max-width: 100%;
  491. }
  492. }
  493. .message-content {
  494. padding: 8px 12px;
  495. word-wrap: break-word;
  496. word-break: break-word;
  497. white-space: pre-wrap;
  498. overflow-wrap: break-word;
  499. max-width: 100%;
  500. box-sizing: border-box;
  501. .message-text {
  502. display: block;
  503. word-wrap: break-word;
  504. word-break: break-word;
  505. white-space: pre-wrap;
  506. overflow-wrap: break-word;
  507. width: 100%;
  508. line-height: 1.5;
  509. // Markdown 渲染样式
  510. :deep(strong) {
  511. font-weight: bold;
  512. }
  513. }
  514. .message-image {
  515. max-width: 120px;
  516. max-height: 120px;
  517. border-radius: 8px;
  518. display: block;
  519. cursor: pointer;
  520. :deep(img) {
  521. max-width: 120px !important;
  522. max-height: 120px !important;
  523. width: auto !important;
  524. height: auto !important;
  525. border-radius: 8px;
  526. display: block;
  527. object-fit: contain;
  528. }
  529. }
  530. }
  531. .message-time {
  532. font-size: 12px;
  533. color: #999;
  534. padding: 0 4px;
  535. }
  536. </style>