message-consult.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. interface ConsultMessage {
  2. id: string; // 咨询记录详情ID
  3. consultRecordId: number; //咨询记录ID
  4. sender: "user" | "agent" | "human" | "system";
  5. sendType: string; // 发送类型 1-患者 2-医生 3-系统 4-AI
  6. messageType: "1" | "2"; // 1文本 2 图片
  7. messageContent?: string; // 消息内容
  8. sendTime?: string; // 发送消息的时间
  9. }
  10. import I18nBehavior from "../../../../i18n/behavior";
  11. import { Post } from "../../../../lib/request/method";
  12. import { upload } from "../../../../lib/request/upload";
  13. import { getFullImageUrl } from "../../../../utils/util";
  14. import dayjs from "dayjs";
  15. const sendTypeMap: Record<string, "user" | "agent" | "human" | "system"> = {
  16. "1": "user", // 患者
  17. "2": "human", // 医生
  18. "3": "system", // 系统
  19. "4": "agent", // AI
  20. };
  21. // 计算底部安全区位置(rpx)
  22. function calculateSafeBottomRpx(): number {
  23. const systemInfo = wx.getSystemInfoSync();
  24. // 获取窗口的高度
  25. const windowHeight = systemInfo.windowHeight;
  26. // 获取安全区底部的高度
  27. const safeAreaBottom = systemInfo.safeArea?.bottom ?? windowHeight;
  28. const safeBottom = windowHeight - safeAreaBottom;
  29. //将px转为rpx
  30. return (750 / systemInfo.windowWidth) * safeBottom;
  31. }
  32. function isConsultEndMessage(msg: ConsultMessage): boolean {
  33. if (msg.sendType !== "3" || !msg.messageContent) {
  34. return false;
  35. }
  36. const content = msg.messageContent;
  37. const isReminder =
  38. content.includes("30分钟") &&
  39. (content.includes("5分钟后") || content.includes("将在"));
  40. const isRealEnd = content.includes("咨询结束") && !isReminder;
  41. return isRealEnd;
  42. }
  43. // 获取的聊天消息为ConsultMessage格式
  44. function transformMessage(item: AnyObject): ConsultMessage {
  45. const sender = sendTypeMap[item.sendType];
  46. const messageType = item.messageType as "1" | "2";
  47. const messageContent = messageType === "2"
  48. ? getFullImageUrl(item.messageContent)
  49. : (item.messageContent || "");
  50. return {
  51. id: `msg-${item.id}`,
  52. consultRecordId: item.consultRecordId,
  53. sender,
  54. sendTime: item.sendTime || "",
  55. sendType: item.sendType,
  56. messageType,
  57. messageContent,
  58. };
  59. }
  60. Component({
  61. behaviors: [I18nBehavior],
  62. properties: {},
  63. data: {
  64. title: '',
  65. i18n: {
  66. consultChat: { _: '聊天' }
  67. },
  68. messages: [] as ConsultMessage[],
  69. inputText: "",
  70. inputFocus: true,
  71. inputBoxBottom: 0,
  72. baseInputBottom: 0,
  73. keepFocus: true,
  74. _keyboardHeight: 0, // 当前键盘高度
  75. isTransferredToHuman: false, // 是否已转人工
  76. consultEnded: false, // 是否已结束咨询
  77. _pollTimer: 0 as any, // 5秒轮询最新消息定时器
  78. textareaHeight: 80, // textarea 高度(rpx),初始值与 min-height 一致
  79. },
  80. observers: {
  81. 'i18n.consultChat._'(this: any, title: string) {
  82. this.setData({ title });
  83. },
  84. },
  85. lifetimes: {
  86. async attached() {
  87. const safeBottomRpx = calculateSafeBottomRpx();
  88. const tabBarHeight = 100; // rpx
  89. const baseBottom = safeBottomRpx + tabBarHeight;
  90. // 获取咨询中的id
  91. const consultId = wx.getStorageSync("consultId");
  92. let messages: ConsultMessage[] = [];
  93. if (consultId) {
  94. try {
  95. // 获取所有消息的数据
  96. const res = await Post(`/consultManage/getAllMsgs/${consultId}`);
  97. if (res.data && res.data.length > 0) {
  98. messages = res.data.map((item: AnyObject) => {
  99. const msg = transformMessage(item);
  100. return msg;
  101. });
  102. }
  103. } catch (error: any) {
  104. wx.showToast({
  105. title: error?.errMsg || "获取历史消息失败",
  106. icon: "none",
  107. });
  108. }
  109. }
  110. const hasEndMessage = messages.some((msg: ConsultMessage) =>
  111. isConsultEndMessage(msg)
  112. );
  113. const consultEnded = hasEndMessage || wx.getStorageSync("consultEnded");
  114. this.setData({
  115. baseInputBottom: baseBottom,
  116. inputBoxBottom: baseBottom,
  117. messages,
  118. consultEnded: !!consultEnded,
  119. });
  120. this.triggerEvent("boxBottom", { inputBoxBottom: baseBottom });
  121. const kbHandler = (res: any) => {
  122. const height = res?.height ?? 0;
  123. // 键盘收起时,直接更新位置
  124. if (height === 0) {
  125. this._updateInputPosition(0);
  126. return;
  127. }
  128. if (this.data.inputFocus || this.data.keepFocus) {
  129. this._updateInputPosition(height);
  130. }
  131. };
  132. wx.onKeyboardHeightChange?.(kbHandler);
  133. (this as any)._kbHandler = kbHandler;
  134. this._ensureFocus();
  135. const systemInfo = wx.getSystemInfoSync();
  136. const isHarmonyOS =
  137. systemInfo.system &&
  138. systemInfo.system.toLowerCase().includes("harmony");
  139. const delayTime = isHarmonyOS ? 300 : 0;
  140. if (delayTime > 0) {
  141. setTimeout(() => {
  142. if (this.data._keyboardHeight > 0 && this.data.keepFocus) {
  143. this._updateInputPosition(this.data._keyboardHeight);
  144. }
  145. }, delayTime);
  146. }
  147. // 如果咨询未结束,启动轮询最新消息
  148. if (!consultEnded) {
  149. this._startPolling();
  150. }
  151. },
  152. detached() {
  153. // 清理监听
  154. if ((this as any)._kbHandler) {
  155. wx.offKeyboardHeightChange?.((this as any)._kbHandler);
  156. }
  157. this._stopPolling();
  158. },
  159. },
  160. methods: {
  161. _scrollToBottom() {
  162. this.triggerEvent("scroll", { id: "bottom" });
  163. },
  164. _hideKeyboardAndUpdatePosition() {
  165. wx.hideKeyboard?.();
  166. this.setData({
  167. inputFocus: false,
  168. keepFocus: false,
  169. });
  170. this._updateInputPosition(0);
  171. },
  172. _updateInputPosition(keyboardHeight: number) {
  173. const systemInfo = wx.getSystemInfoSync();
  174. const rpx2px = systemInfo.windowWidth / 750;
  175. const keyboardHeightRpx =
  176. keyboardHeight > 0 ? keyboardHeight / rpx2px : 0;
  177. const nextBottom =
  178. keyboardHeight > 0 ? keyboardHeightRpx : this.data.baseInputBottom;
  179. // 避免重复更新相同位置(容差1rpx)
  180. if (Math.abs(nextBottom - this.data.inputBoxBottom) < 1) {
  181. if (keyboardHeight !== this.data._keyboardHeight) {
  182. this.setData({ _keyboardHeight: keyboardHeight });
  183. }
  184. return;
  185. }
  186. this.setData({
  187. inputBoxBottom: nextBottom,
  188. _keyboardHeight: keyboardHeight,
  189. });
  190. this.triggerEvent("boxBottom", { inputBoxBottom: nextBottom });
  191. if (keyboardHeight > 0) {
  192. this.triggerEvent("scroll", { id: "bottom" });
  193. }
  194. },
  195. _ensureFocus() {
  196. if (!this.data.keepFocus) return;
  197. this.setData({ inputFocus: false });
  198. wx.nextTick?.(() => {
  199. setTimeout(() => {
  200. if (this.data.keepFocus) {
  201. this.setData({ inputFocus: true });
  202. setTimeout(() => {
  203. if (this.data._keyboardHeight > 0 && this.data.inputFocus) {
  204. this._updateInputPosition(this.data._keyboardHeight);
  205. }
  206. }, 150);
  207. }
  208. }, 120);
  209. });
  210. },
  211. tapPanel() {
  212. if (!this.data.inputFocus && this.data.keepFocus) {
  213. this._ensureFocus();
  214. }
  215. },
  216. endConsult() {
  217. // 收起键盘并更新位置
  218. this._hideKeyboardAndUpdatePosition();
  219. wx.showModal({
  220. title: "",
  221. content: `确定要结束本次?${this.data.title}`,
  222. cancelText: `继续${this.data.title}`,
  223. confirmText: "结束",
  224. }).then((res: any) => {
  225. if (res.confirm) {
  226. // 确认结束
  227. this._endConsult();
  228. } else {
  229. // 继续咨询,恢复聚焦
  230. this.setData({ keepFocus: true });
  231. this._ensureFocus();
  232. }
  233. });
  234. },
  235. async _endConsult() {
  236. const endDate = dayjs().format("MM-DD HH:mm:ss");
  237. const consultId = wx.getStorageSync("consultId");
  238. this._appendMessage({
  239. id: `end-time-${Date.now()}`,
  240. consultRecordId: consultId || 0,
  241. sender: "system",
  242. sendType: "3",
  243. messageType: "1",
  244. messageContent: `${this.data.title}结束`,
  245. sendTime: endDate,
  246. });
  247. // 调用结束咨询接口
  248. if (consultId) {
  249. try {
  250. await Post(`/consultManage/end/${consultId}`);
  251. } catch (error: any) {
  252. wx.showToast({
  253. title: error?.errMsg || `结束${this.data.title}失败`,
  254. icon: "none",
  255. });
  256. }
  257. }
  258. // 设置结束状态
  259. this.setData({ consultEnded: true });
  260. wx.setStorageSync("consultEnded", true);
  261. wx.removeStorageSync("consultId");
  262. this._stopPolling();
  263. // 收起键盘
  264. wx.hideKeyboard?.();
  265. // 重置底部位置为正常值(tabbar 高度 + 安全区高度)
  266. const safeBottomRpx = calculateSafeBottomRpx();
  267. const tabBarHeight = 100; // rpx
  268. const normalBottom = tabBarHeight + safeBottomRpx;
  269. this.triggerEvent("boxBottom", { inputBoxBottom: normalBottom });
  270. // 通知父组件显示guide菜单组件
  271. this.triggerEvent("consultEvent", { type: "end" });
  272. // 滚动到底部
  273. this._scrollToBottom();
  274. },
  275. handleInput(event: any) {
  276. const value = event.detail.value;
  277. this.setData({ inputText: value });
  278. // 内容为空时,立即重置为最小高度
  279. if (!value || value.trim() === "") {
  280. if (this.data.textareaHeight !== 80) {
  281. this.setData({ textareaHeight: 80 });
  282. }
  283. }
  284. },
  285. onLineChange(event: any) {
  286. const minHeight = 80; // 最小高度(rpx)
  287. const maxHeight = 200; // 最大高度(rpx)
  288. const lineCount = event.detail.lineCount || 1;
  289. // 如果输入框为空,直接设置为最小高度
  290. if (!this.data.inputText || this.data.inputText.trim() === "") {
  291. if (this.data.textareaHeight !== minHeight) {
  292. this.setData({ textareaHeight: minHeight });
  293. }
  294. return;
  295. }
  296. if (lineCount === 1) {
  297. if (this.data.textareaHeight !== minHeight) {
  298. this.setData({ textareaHeight: minHeight });
  299. }
  300. return;
  301. }
  302. const lineHeight = 53; // 每行高度(rpx)
  303. const padding = 24;
  304. const calculatedHeight = lineCount * lineHeight + padding;
  305. const finalHeight = Math.max(
  306. minHeight,
  307. Math.min(maxHeight, calculatedHeight)
  308. );
  309. if (Math.abs(this.data.textareaHeight - finalHeight) > 3) {
  310. this.setData({ textareaHeight: finalHeight });
  311. }
  312. },
  313. onInputFocus(event: any) {
  314. const keyboardHeight = event.detail.height ?? 0;
  315. // 设置 focus 状态
  316. this.setData({
  317. inputFocus: true,
  318. keepFocus: true,
  319. });
  320. if (keyboardHeight > 0) {
  321. this._updateInputPosition(keyboardHeight);
  322. } else {
  323. if (this.data._keyboardHeight > 0) {
  324. // 延迟一点时间,等待键盘完全弹起后再更新
  325. setTimeout(() => {
  326. if (this.data.inputFocus && this.data._keyboardHeight > 0) {
  327. this._updateInputPosition(this.data._keyboardHeight);
  328. }
  329. }, 100);
  330. }
  331. }
  332. },
  333. onInputBlur() {
  334. // 设置 focus 状态
  335. this.setData({
  336. inputFocus: false,
  337. });
  338. setTimeout(() => {
  339. if (!this.data.inputFocus) {
  340. this._updateInputPosition(0);
  341. }
  342. }, 100);
  343. },
  344. // 启动轮询最新消息
  345. _startPolling() {
  346. if (this.data.consultEnded) return;
  347. this._stopPolling();
  348. const timer = setInterval(() => {
  349. if (this.data.consultEnded) {
  350. this._stopPolling();
  351. return;
  352. }
  353. this._getLatestMessages();
  354. }, 5000);
  355. this.setData({ _pollTimer: timer });
  356. },
  357. _stopPolling() {
  358. if (this.data._pollTimer) {
  359. clearInterval(this.data._pollTimer);
  360. this.setData({ _pollTimer: 0 });
  361. }
  362. },
  363. // 获取最新消息
  364. async _getLatestMessages() {
  365. const consultId = wx.getStorageSync("consultId");
  366. if (!consultId) return;
  367. try {
  368. // 获取最新消息
  369. const res = await Post(`/consultManage/getLatestMsgs/${consultId}`);
  370. if (res.data && Array.isArray(res.data) && res.data.length > 0) {
  371. const newMessages = res.data.map((item: AnyObject) => {
  372. const msg = transformMessage(item);
  373. return msg;
  374. });
  375. const allMessages = [...this.data.messages, ...newMessages];
  376. this.setData({ messages: allMessages });
  377. const hasEndMessage = newMessages.some((msg: ConsultMessage) =>
  378. isConsultEndMessage(msg)
  379. );
  380. if (hasEndMessage && !this.data.consultEnded) {
  381. this.setData({ consultEnded: true });
  382. wx.setStorageSync("consultEnded", true);
  383. this._stopPolling();
  384. // 收起键盘
  385. wx.hideKeyboard?.();
  386. const safeBottomRpx = calculateSafeBottomRpx();
  387. const tabBarHeight = 100; // rpx
  388. const normalBottom = tabBarHeight + safeBottomRpx;
  389. this.triggerEvent("boxBottom", { inputBoxBottom: normalBottom });
  390. // 通知父组件显示guide菜单组件
  391. this.triggerEvent("consultEvent", { type: "end" });
  392. }
  393. this._scrollToBottom();
  394. }
  395. } catch (error: any) {
  396. wx.showToast({
  397. title: error?.errMsg || "获取最新消息失败",
  398. icon: "none",
  399. });
  400. }
  401. },
  402. // 发送消息到后端
  403. async _sendMessage(messageType: "1" | "2", messageContent: string) {
  404. const consultId = wx.getStorageSync("consultId");
  405. if (!consultId) {
  406. wx.showToast({ title: `${this.data.title}ID不存在`, icon: "none" });
  407. return;
  408. }
  409. try {
  410. await Post(`/consultManage/sendConsultMsg`, {
  411. consultRecordId: consultId,
  412. messageType,
  413. messageContent,
  414. }).then(() => {
  415. this._stopPolling();
  416. this._getLatestMessages();
  417. setTimeout(() => {
  418. if (!this.data.consultEnded) {
  419. this._startPolling();
  420. }
  421. }, 5000);
  422. });
  423. } catch (error: any) {
  424. wx.showToast({
  425. title: error?.errMsg || "发送失败,请重试",
  426. icon: "none",
  427. });
  428. }
  429. },
  430. async sendText() {
  431. const text = this.data.inputText.trim();
  432. if (!text) {
  433. wx.showToast({ title: "发送内容不能为空", icon: "none" });
  434. return;
  435. }
  436. const consultId = wx.getStorageSync("consultId");
  437. // 先添加用户消息到界面
  438. const messageId = `user-text-${Date.now()}`;
  439. this._appendMessage({
  440. id: messageId,
  441. consultRecordId: consultId || 0,
  442. sender: "user",
  443. sendType: "1",
  444. messageType: "1",
  445. messageContent: text,
  446. });
  447. this._scrollToBottom();
  448. // 发送信息
  449. this._sendMessage("1", text);
  450. // 保存当前键盘状态
  451. const wasFocused = this.data.inputFocus;
  452. const currentKeyboardHeight = this.data._keyboardHeight;
  453. this.setData({
  454. inputText: "",
  455. textareaHeight: 80,
  456. });
  457. if (!wasFocused) {
  458. this.setData({
  459. inputFocus: false,
  460. keepFocus: false,
  461. });
  462. } else {
  463. if (currentKeyboardHeight > 0) {
  464. this._updateInputPosition(currentKeyboardHeight);
  465. }
  466. }
  467. },
  468. async chooseImage() {
  469. const wasFocused = this.data.inputFocus;
  470. const currentKeyboardHeight = this.data._keyboardHeight;
  471. this._hideKeyboardAndUpdatePosition();
  472. try {
  473. const res = await wx.chooseMedia({
  474. count: 1,
  475. mediaType: ["image"],
  476. sourceType: ["album", "camera"],
  477. });
  478. const files = res.tempFiles ?? [];
  479. const file = files[0];
  480. if (!file?.tempFilePath) return;
  481. const imagePath = file.tempFilePath;
  482. // 直接发送图片
  483. await this._sendImageMessage(imagePath);
  484. if (wasFocused) {
  485. wx.nextTick?.(() => {
  486. setTimeout(() => {
  487. this.setData({
  488. inputFocus: true,
  489. keepFocus: true,
  490. });
  491. // 恢复键盘位置
  492. if (currentKeyboardHeight > 0) {
  493. this._updateInputPosition(currentKeyboardHeight);
  494. }
  495. }, 200);
  496. });
  497. }
  498. } catch (error: any) {
  499. if (error.errMsg && !error.errMsg.includes("cancel")) {
  500. console.error("选择图片失败", error);
  501. }
  502. if (wasFocused) {
  503. wx.nextTick?.(() => {
  504. setTimeout(() => {
  505. this.setData({
  506. inputFocus: true,
  507. keepFocus: true,
  508. });
  509. // 恢复键盘位置
  510. if (currentKeyboardHeight > 0) {
  511. this._updateInputPosition(currentKeyboardHeight);
  512. }
  513. }, 200);
  514. });
  515. }
  516. }
  517. },
  518. // 发送图片
  519. async _sendImageMessage(imagePath: string) {
  520. const consultId = wx.getStorageSync("consultId");
  521. try {
  522. const messageId = `user-image-${Date.now()}`;
  523. this._appendMessage({
  524. id: messageId,
  525. consultRecordId: consultId || 0,
  526. sender: "user",
  527. sendType: "1",
  528. messageType: "2",
  529. messageContent: imagePath,
  530. });
  531. // 上传图片
  532. const imageUrl = await upload({
  533. params: { name: "file", file: imagePath },
  534. transform({ data }: any) {
  535. return data?.url || data;
  536. },
  537. });
  538. // 发送图片消息
  539. await this._sendMessage("2", imageUrl);
  540. // 更新消息中的图片URL
  541. const messages = this.data.messages;
  542. const messageIndex = messages.findIndex(
  543. (msg: ConsultMessage) => msg.id === messageId
  544. );
  545. if (messageIndex !== -1) {
  546. messages[messageIndex].messageContent = imageUrl;
  547. this.setData({ messages });
  548. }
  549. this._scrollToBottom();
  550. } catch (error: any) {
  551. wx.showToast({
  552. title: error?.errMsg || "图片上传失败",
  553. icon: "none",
  554. });
  555. }
  556. },
  557. // 预览图片
  558. previewImage(e: any) {
  559. const currentUrl = e.currentTarget.dataset.url;
  560. const urls = this.data.messages
  561. .filter(
  562. (msg: ConsultMessage) => msg.messageType === "2" && msg.messageContent
  563. )
  564. .map((msg: ConsultMessage) => msg.messageContent!);
  565. wx.previewImage({
  566. current: currentUrl,
  567. urls: urls.length > 0 ? urls : [currentUrl],
  568. fail: (err) => {
  569. wx.showToast({
  570. title: err?.errMsg || "预览图片失败",
  571. icon: "none",
  572. });
  573. },
  574. });
  575. },
  576. _appendMessage(message: ConsultMessage) {
  577. // 把获取的最新的消息追加到所有消息后面
  578. const messages = [...this.data.messages, message];
  579. this.setData({ messages });
  580. },
  581. },
  582. });