message-consult.ts 18 KB

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