message-consult.ts 19 KB

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