message-consult.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  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. console.log("baseBottom==输入框的位置", baseBottom);
  75. // 获取咨询中的id
  76. const consultId = wx.getStorageSync("consultId");
  77. let messages: ConsultMessage[] = [];
  78. if (consultId) {
  79. try {
  80. // 获取所有消息的数据
  81. const res = await Post(`/consultManage/getAllMsgs/${consultId}`);
  82. if (res.data && res.data.length > 0) {
  83. messages = res.data.map((item: AnyObject) => {
  84. const msg = transformMessage(item);
  85. return msg;
  86. });
  87. }
  88. } catch (error: any) {
  89. wx.showToast({
  90. title: error?.errMsg || "获取历史消息失败",
  91. icon: "none",
  92. });
  93. }
  94. }
  95. // 检查历史消息中是否有真正的咨询结束消息
  96. const hasEndMessage = messages.some((msg: ConsultMessage) =>
  97. isConsultEndMessage(msg)
  98. );
  99. const consultEnded = hasEndMessage || wx.getStorageSync("consultEnded");
  100. console.log(
  101. "安全区的距离",
  102. safeBottomRpx,
  103. "consultEnded==是否已结束咨询",
  104. consultEnded
  105. );
  106. this.setData({
  107. baseInputBottom: baseBottom,
  108. inputBoxBottom: baseBottom,
  109. messages,
  110. consultEnded: !!consultEnded,
  111. });
  112. console.log("baseBottom==输入框的位置", baseBottom);
  113. this.triggerEvent("boxBottom", { inputBoxBottom: baseBottom });
  114. const kbHandler = (res: any) => {
  115. const height = res?.height ?? 0;
  116. // 键盘收起时,直接更新位置
  117. if (height === 0) {
  118. this._updateInputPosition(0);
  119. return;
  120. }
  121. if (this.data.inputFocus || this.data.keepFocus) {
  122. this._updateInputPosition(height);
  123. }
  124. };
  125. wx.onKeyboardHeightChange?.(kbHandler);
  126. (this as any)._kbHandler = kbHandler;
  127. this._ensureFocus();
  128. const systemInfo = wx.getSystemInfoSync();
  129. const isHarmonyOS =
  130. systemInfo.system &&
  131. systemInfo.system.toLowerCase().includes("harmony");
  132. console.log("isHarmonyOS==是否是鸿蒙系统", isHarmonyOS);
  133. const delayTime = isHarmonyOS ? 300 : 0;
  134. if (delayTime > 0) {
  135. setTimeout(() => {
  136. if (this.data._keyboardHeight > 0 && this.data.keepFocus) {
  137. this._updateInputPosition(this.data._keyboardHeight);
  138. }
  139. }, delayTime);
  140. }
  141. // 如果咨询未结束,启动轮询最新消息
  142. if (!consultEnded) {
  143. this._startPolling();
  144. }
  145. },
  146. detached() {
  147. // 清理监听
  148. if ((this as any)._kbHandler) {
  149. wx.offKeyboardHeightChange?.((this as any)._kbHandler);
  150. }
  151. this._stopPolling();
  152. },
  153. },
  154. methods: {
  155. _scrollToBottom() {
  156. this.triggerEvent("scroll", { id: "bottom" });
  157. },
  158. _hideKeyboardAndUpdatePosition() {
  159. wx.hideKeyboard?.();
  160. this.setData({
  161. inputFocus: false,
  162. keepFocus: false,
  163. });
  164. this._updateInputPosition(0);
  165. },
  166. _updateInputPosition(keyboardHeight: number) {
  167. const systemInfo = wx.getSystemInfoSync();
  168. const rpx2px = systemInfo.windowWidth / 750;
  169. const keyboardHeightRpx =
  170. keyboardHeight > 0 ? keyboardHeight / rpx2px : 0;
  171. const nextBottom =
  172. keyboardHeight > 0 ? keyboardHeightRpx : this.data.baseInputBottom;
  173. // 避免重复更新相同位置(容差1rpx)
  174. if (Math.abs(nextBottom - this.data.inputBoxBottom) < 1) {
  175. if (keyboardHeight !== this.data._keyboardHeight) {
  176. this.setData({ _keyboardHeight: keyboardHeight });
  177. }
  178. return;
  179. }
  180. this.setData({
  181. inputBoxBottom: nextBottom,
  182. _keyboardHeight: keyboardHeight,
  183. });
  184. this.triggerEvent("boxBottom", { inputBoxBottom: nextBottom });
  185. if (keyboardHeight > 0) {
  186. this.triggerEvent("scroll", { id: "bottom" });
  187. }
  188. },
  189. _ensureFocus() {
  190. if (!this.data.keepFocus) return;
  191. this.setData({ inputFocus: false });
  192. wx.nextTick?.(() => {
  193. setTimeout(() => {
  194. if (this.data.keepFocus) {
  195. this.setData({ inputFocus: true });
  196. setTimeout(() => {
  197. if (this.data._keyboardHeight > 0 && this.data.inputFocus) {
  198. this._updateInputPosition(this.data._keyboardHeight);
  199. }
  200. }, 150);
  201. }
  202. }, 120);
  203. });
  204. },
  205. tapPanel() {
  206. if (!this.data.inputFocus && this.data.keepFocus) {
  207. this._ensureFocus();
  208. }
  209. },
  210. endConsult() {
  211. // 收起键盘并更新位置
  212. this._hideKeyboardAndUpdatePosition();
  213. wx.showModal({
  214. title: "",
  215. content: "确定要结束本次咨询?",
  216. cancelText: "继续咨询",
  217. confirmText: "结束",
  218. }).then((res: any) => {
  219. if (res.confirm) {
  220. // 确认结束
  221. this._endConsult();
  222. } else {
  223. // 继续咨询,恢复聚焦
  224. this.setData({ keepFocus: true });
  225. this._ensureFocus();
  226. }
  227. });
  228. },
  229. async _endConsult() {
  230. const endDate = dayjs().format("MM-DD HH:mm:ss");
  231. const consultId = wx.getStorageSync("consultId");
  232. this._appendMessage({
  233. id: `end-time-${Date.now()}`,
  234. consultRecordId: consultId || 0,
  235. sender: "system",
  236. sendType: "3",
  237. messageType: "1",
  238. messageContent: "咨询结束",
  239. sendTime: endDate,
  240. });
  241. // 调用结束咨询接口
  242. if (consultId) {
  243. try {
  244. await Post(`/consultManage/end/${consultId}`);
  245. } catch (error: any) {
  246. wx.showToast({
  247. title: error?.errMsg || "结束咨询失败",
  248. icon: "none",
  249. });
  250. }
  251. }
  252. // 设置结束状态
  253. this.setData({ consultEnded: true });
  254. wx.setStorageSync("consultEnded", true);
  255. wx.removeStorageSync("consultId");
  256. this._stopPolling();
  257. // 收起键盘
  258. wx.hideKeyboard?.();
  259. // 重置底部位置为正常值(tabbar 高度 + 安全区高度)
  260. const safeBottomRpx = calculateSafeBottomRpx();
  261. const tabBarHeight = 100; // rpx
  262. const normalBottom = tabBarHeight + safeBottomRpx;
  263. this.triggerEvent("boxBottom", { inputBoxBottom: normalBottom });
  264. // 通知父组件显示guide菜单组件
  265. this.triggerEvent("consultEvent", { type: "end" });
  266. // 滚动到底部
  267. this._scrollToBottom();
  268. },
  269. handleInput(event: any) {
  270. const value = event.detail.value;
  271. this.setData({ inputText: value });
  272. // 内容为空时,立即重置为最小高度
  273. if (!value || value.trim() === "") {
  274. if (this.data.textareaHeight !== 80) {
  275. this.setData({ textareaHeight: 80 });
  276. }
  277. }
  278. },
  279. onLineChange(event: any) {
  280. const minHeight = 80; // 最小高度(rpx)
  281. const maxHeight = 200; // 最大高度(rpx)
  282. const lineCount = event.detail.lineCount || 1;
  283. // 如果输入框为空,直接设置为最小高度
  284. if (!this.data.inputText || this.data.inputText.trim() === "") {
  285. if (this.data.textareaHeight !== minHeight) {
  286. this.setData({ textareaHeight: minHeight });
  287. }
  288. return;
  289. }
  290. if (lineCount === 1) {
  291. if (this.data.textareaHeight !== minHeight) {
  292. this.setData({ textareaHeight: minHeight });
  293. }
  294. return;
  295. }
  296. const lineHeight = 53; // 每行高度(rpx)
  297. const padding = 24;
  298. const calculatedHeight = lineCount * lineHeight + padding;
  299. const finalHeight = Math.max(
  300. minHeight,
  301. Math.min(maxHeight, calculatedHeight)
  302. );
  303. if (Math.abs(this.data.textareaHeight - finalHeight) > 3) {
  304. this.setData({ textareaHeight: finalHeight });
  305. }
  306. },
  307. onInputFocus(event: any) {
  308. const keyboardHeight = event.detail.height ?? 0;
  309. // 设置 focus 状态
  310. this.setData({
  311. inputFocus: true,
  312. keepFocus: true,
  313. });
  314. if (keyboardHeight > 0) {
  315. this._updateInputPosition(keyboardHeight);
  316. } else {
  317. if (this.data._keyboardHeight > 0) {
  318. // 延迟一点时间,等待键盘完全弹起后再更新
  319. setTimeout(() => {
  320. if (this.data.inputFocus && this.data._keyboardHeight > 0) {
  321. this._updateInputPosition(this.data._keyboardHeight);
  322. }
  323. }, 100);
  324. }
  325. }
  326. },
  327. onInputBlur() {
  328. console.log("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: "咨询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(从本地路径更新为服务器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. });