message-consult.ts 19 KB

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