message-consult.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868
  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. // sendType映射: 1-患者 2-医生 3-系统 4-AI
  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. // 判断消息是否为真正的咨询结束消息(排除30分钟提醒消息)
  32. function isConsultEndMessage(msg: ConsultMessage): boolean {
  33. if (msg.sendType !== "3" || !msg.messageContent) {
  34. return false;
  35. }
  36. const content = msg.messageContent;
  37. // 排除30分钟提醒消息(包含"30分钟"和"5分钟后")
  38. const isReminder =
  39. content.includes("30分钟") &&
  40. (content.includes("5分钟后") || content.includes("将在"));
  41. // 判断是否为真正的咨询结束消息
  42. const isRealEnd = content.includes("咨询结束") && !isReminder;
  43. return isRealEnd;
  44. }
  45. // 转义 HTML 特殊字符
  46. function escapeHtml(text: string): string {
  47. return text
  48. .replace(/&/g, "&amp;")
  49. .replace(/</g, "&lt;")
  50. .replace(/>/g, "&gt;")
  51. .replace(/"/g, "&quot;")
  52. .replace(/'/g, "&#39;");
  53. }
  54. // 将 Markdown 格式文本转换为 HTML(支持基本的 Markdown 语法)
  55. function parseMarkdown(text: string): string {
  56. if (!text) return "";
  57. // 先处理代码块(三个反引号),提取出来避免被其他规则匹配
  58. const codeBlocks: string[] = [];
  59. let codeBlockIndex = 0;
  60. let html = text.replace(/```([\s\S]*?)```/g, (_, code) => {
  61. const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`;
  62. codeBlocks[codeBlockIndex] = escapeHtml(code);
  63. codeBlockIndex++;
  64. return placeholder;
  65. });
  66. // 处理行内代码(一个反引号),也要先提取出来
  67. const inlineCodes: string[] = [];
  68. let inlineCodeIndex = 0;
  69. html = html.replace(/`([^`\n]+)`/g, (_, code) => {
  70. const placeholder = `__INLINE_CODE_${inlineCodeIndex}__`;
  71. inlineCodes[inlineCodeIndex] = escapeHtml(code);
  72. inlineCodeIndex++;
  73. return placeholder;
  74. });
  75. // 先处理列表(在处理加粗之前,避免 split 时分割 strong 标签)
  76. // 处理列表:支持无序列表(以 - 或 * 开头的行)
  77. const lines = html.split("\n");
  78. let inList = false;
  79. let result: string[] = [];
  80. for (let i = 0; i < lines.length; i++) {
  81. const line = lines[i];
  82. // 匹配以空格+减号或星号开头的行(列表项)
  83. const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
  84. if (listMatch) {
  85. if (!inList) {
  86. result.push("<ul>");
  87. inList = true;
  88. }
  89. result.push(`<li>${listMatch[2]}</li>`);
  90. } else {
  91. if (inList) {
  92. result.push("</ul>");
  93. inList = false;
  94. }
  95. if (line.trim()) {
  96. result.push(line);
  97. } else {
  98. result.push("<br>");
  99. }
  100. }
  101. }
  102. if (inList) {
  103. result.push("</ul>");
  104. }
  105. html = result.join("\n");
  106. // 处理加粗:两个※※(优先处理,避免与单个※冲突)
  107. // 使用 span 标签配合 style,确保是行内元素
  108. html = html.replace(/※※([^\n]+?)※※/g, (_, content) => {
  109. return `<span style="font-weight:bold;display:inline;">${escapeHtml(content.trim())}</span>`;
  110. });
  111. // 处理加粗:两个星号 **
  112. html = html.replace(/\*\*([^\n]+?)\*\*/g, (_, content) => {
  113. return `<span style="font-weight:bold;display:inline;">${escapeHtml(content.trim())}</span>`;
  114. });
  115. // 处理斜体:一个※(但要排除已经是加粗标记的)
  116. html = html.replace(/(?<!※)※(?!※)([^※\n]+?)※(?!※)/g, (_, content) => {
  117. return `<span style="font-style:italic;display:inline;">${escapeHtml(content.trim())}</span>`;
  118. });
  119. // 处理斜体:一个星号 *(但要排除已经是加粗标记的)
  120. html = html.replace(/(?<!\*)\*(?!\*)([^\*\n]+?)\*(?!\*)/g, (_, content) => {
  121. return `<span style="font-style:italic;display:inline;">${escapeHtml(content.trim())}</span>`;
  122. });
  123. // 恢复行内代码
  124. for (let i = 0; i < inlineCodes.length; i++) {
  125. html = html.replace(`__INLINE_CODE_${i}__`, `<code>${inlineCodes[i]}</code>`);
  126. }
  127. // 恢复代码块
  128. for (let i = 0; i < codeBlocks.length; i++) {
  129. html = html.replace(`__CODE_BLOCK_${i}__`, `<pre><code>${codeBlocks[i]}</code></pre>`);
  130. }
  131. // 转义剩余文本中的 HTML 特殊字符(但保留已经生成的 HTML 标签)
  132. const tagPlaceholders: string[] = [];
  133. let tagIndex = 0;
  134. // 现在使用 span 标签,所以需要匹配 span 和 code、pre、ul、li、br
  135. html = html.replace(/<(\/?)(span|code|pre|ul|li|br)[^>]*>/gi, (match) => {
  136. const placeholder = `__TAG_${tagIndex}__`;
  137. tagPlaceholders[tagIndex] = match;
  138. tagIndex++;
  139. return placeholder;
  140. });
  141. // 转义剩余的 HTML 特殊字符
  142. html = escapeHtml(html);
  143. // 恢复 HTML 标签
  144. for (let i = 0; i < tagPlaceholders.length; i++) {
  145. html = html.replace(`__TAG_${i}__`, tagPlaceholders[i]);
  146. }
  147. // 统一清理所有 HTML 标签之间的空白字符
  148. // 关键:加粗标签后绝对不能有任何空白字符,包括换行符、空格、制表符等
  149. // 第一步:清理所有标签之间的空白
  150. html = html.replace(/>[\s\n\r\t]+</g, "><");
  151. // 第二步:特别处理加粗标签 - 移除后面所有空白字符(包括换行符)
  152. html = html.replace(/(<\/span[^>]*style="[^"]*font-weight:bold[^"]*"[^>]*>)[\s\n\r\t\u00A0\u2000-\u200B\u2028\u2029\u3000]+/g, "$1");
  153. // 第三步:清理其他 span 标签前后的空白
  154. html = html.replace(/(<\/span[^>]*>)[\s\n\r\t]+/g, "$1");
  155. html = html.replace(/[\s\n\r\t]+(<span[^>]*>)/g, "$1");
  156. // 第四步:清理其他标签前后的空白
  157. html = html.replace(/>[\s\n\r\t]+/g, ">");
  158. html = html.replace(/[\s\n\r\t]+</g, "<");
  159. // 第五步:将换行符转换为 <br>(加粗标签后的换行已经被移除了)
  160. html = html.replace(/\n/g, "<br>");
  161. // 第六步:最后再次确保加粗标签后没有任何空白字符
  162. html = html.replace(/(<\/span[^>]*style="[^"]*font-weight:bold[^"]*"[^>]*>)[\s\u00A0\u2000-\u200B\u2028\u2029\u3000]+/g, "$1");
  163. return html;
  164. }
  165. // 获取的聊天消息为ConsultMessage格式 提取为一个公共的方法
  166. function transformMessage(item: AnyObject): ConsultMessage {
  167. const sender = sendTypeMap[item.sendType];
  168. return {
  169. id: `msg-${item.id}`,
  170. consultRecordId: item.consultRecordId,
  171. sender,
  172. sendTime: item.sendTime || "",
  173. sendType: item.sendType,
  174. messageType: item.messageType as "1" | "2",
  175. messageContent: item.messageContent || "",
  176. };
  177. }
  178. Component({
  179. properties: {},
  180. data: {
  181. messages: [] as ConsultMessage[],
  182. inputText: "",
  183. inputFocus: true,
  184. inputBoxBottom: 0,
  185. baseInputBottom: 0,
  186. keepFocus: true,
  187. _keyboardHeight: 0, // 当前键盘高度
  188. isTransferredToHuman: false, // 是否已转人工
  189. consultEnded: false, // 是否已结束咨询
  190. _pollTimer: 0 as any, // 5秒轮询最新消息定时器
  191. textareaHeight: 80, // textarea 高度(rpx),初始值与 min-height 一致
  192. },
  193. lifetimes: {
  194. async attached() {
  195. const safeBottomRpx = calculateSafeBottomRpx();
  196. const tabBarHeight = 100; // rpx
  197. const baseBottom = safeBottomRpx + tabBarHeight;
  198. console.log("baseBottom==输入框的位置", baseBottom);
  199. // 获取咨询中的id
  200. const consultId = wx.getStorageSync("consultId");
  201. let messages: ConsultMessage[] = [];
  202. if (consultId) {
  203. try {
  204. // 获取所有消息的数据
  205. const res = await Post(`/consultManage/getAllMsgs/${consultId}`);
  206. if (res.data && res.data.length > 0) {
  207. messages = res.data.map((item: AnyObject) => {
  208. const msg = transformMessage(item);
  209. // 对 AI 和医生的文本消息进行 Markdown 解析
  210. if (
  211. (msg.sender === "agent" || msg.sender === "human") &&
  212. msg.messageType === "1" &&
  213. msg.messageContent
  214. ) {
  215. msg.messageContent = parseMarkdown(msg.messageContent);
  216. }
  217. return msg;
  218. });
  219. }
  220. } catch (error: any) {
  221. wx.showToast({
  222. title: error?.errMsg || "获取历史消息失败",
  223. icon: "none",
  224. });
  225. }
  226. }
  227. // 检查历史消息中是否有真正的咨询结束消息(排除30分钟提醒消息)
  228. const hasEndMessage = messages.some((msg: ConsultMessage) =>
  229. isConsultEndMessage(msg)
  230. );
  231. const consultEnded = hasEndMessage || wx.getStorageSync("consultEnded");
  232. console.log(
  233. "安全区的距离",
  234. safeBottomRpx,
  235. "consultEnded==是否已结束咨询",
  236. consultEnded
  237. );
  238. this.setData({
  239. baseInputBottom: baseBottom,
  240. inputBoxBottom: baseBottom,
  241. messages,
  242. consultEnded: !!consultEnded,
  243. });
  244. console.log("baseBottom==输入框的位置", baseBottom);
  245. this.triggerEvent("boxBottom", { inputBoxBottom: baseBottom });
  246. // 监听键盘高度变化事件
  247. // 每次键盘高度变化时直接更新位置
  248. const kbHandler = (res: any) => {
  249. const height = res?.height ?? 0;
  250. // 键盘收起时,直接更新位置
  251. if (height === 0) {
  252. this._updateInputPosition(0);
  253. return;
  254. }
  255. // 键盘弹起时的处理:
  256. // 1. 如果输入框已聚焦,直接更新位置
  257. // 2. 如果输入框未聚焦但 keepFocus 为 true,说明正在等待聚焦(第一次打开的情况),也应该更新位置
  258. // 这样可以解决鸿蒙系统第一次打开时键盘先弹起但输入框未聚焦的问题
  259. if (this.data.inputFocus || this.data.keepFocus) {
  260. // 直接更新位置,每次键盘高度变化时都更新
  261. this._updateInputPosition(height);
  262. }
  263. };
  264. wx.onKeyboardHeightChange?.(kbHandler);
  265. (this as any)._kbHandler = kbHandler;
  266. // 渲染完成后再触发一次聚焦,确保键盘弹起
  267. this._ensureFocus();
  268. // 鸿蒙系统兼容:延迟检查键盘高度,确保第一次打开时能正确更新位置
  269. // 在 _ensureFocus 延迟聚焦之后,再延迟一点时间检查键盘高度
  270. setTimeout(() => {
  271. // 如果此时键盘高度已记录但位置未更新,强制更新一次
  272. if (this.data._keyboardHeight > 0 && this.data.keepFocus) {
  273. this._updateInputPosition(this.data._keyboardHeight);
  274. }
  275. }, 300);
  276. // 如果咨询未结束,启动轮询最新消息
  277. if (!consultEnded) {
  278. this._startPolling();
  279. }
  280. },
  281. detached() {
  282. // 清理监听
  283. if ((this as any)._kbHandler) {
  284. wx.offKeyboardHeightChange?.((this as any)._kbHandler);
  285. }
  286. // 清理轮询定时器
  287. this._stopPolling();
  288. },
  289. },
  290. methods: {
  291. // 滚动到底部-
  292. _scrollToBottom() {
  293. this.triggerEvent("scroll", { id: "bottom" });
  294. },
  295. // 收起键盘并更新位置
  296. _hideKeyboardAndUpdatePosition() {
  297. wx.hideKeyboard?.();
  298. this.setData({
  299. inputFocus: false,
  300. keepFocus: false,
  301. });
  302. this._updateInputPosition(0);
  303. },
  304. // 统一的位置更新方法
  305. _updateInputPosition(keyboardHeight: number) {
  306. const systemInfo = wx.getSystemInfoSync();
  307. const rpx2px = systemInfo.windowWidth / 750;
  308. // 键盘高度是 px,转换为 rpx
  309. const keyboardHeightRpx =
  310. keyboardHeight > 0 ? keyboardHeight / rpx2px : 0;
  311. // 计算输入框底部位置
  312. // 键盘展开时:面板紧贴键盘(bottom = 键盘高度),确保没有空隙
  313. // 键盘收起时:保留 tabbar 距离(baseInputBottom 已包含安全区 + 100rpx tabbar)
  314. const nextBottom =
  315. keyboardHeight > 0 ? keyboardHeightRpx : this.data.baseInputBottom;
  316. // 避免重复更新相同位置(容差1rpx)
  317. if (Math.abs(nextBottom - this.data.inputBoxBottom) < 1) {
  318. // 位置已经正确,只更新键盘高度记录
  319. if (keyboardHeight !== this.data._keyboardHeight) {
  320. this.setData({ _keyboardHeight: keyboardHeight });
  321. }
  322. return;
  323. }
  324. // 更新位置和键盘高度
  325. this.setData({
  326. inputBoxBottom: nextBottom,
  327. _keyboardHeight: keyboardHeight,
  328. });
  329. // 通知父组件更新底部 padding
  330. this.triggerEvent("boxBottom", { inputBoxBottom: nextBottom });
  331. // 键盘弹出时平滑滚动到底部
  332. if (keyboardHeight > 0) {
  333. this.triggerEvent("scroll", { id: "bottom" });
  334. }
  335. },
  336. _ensureFocus() {
  337. if (!this.data.keepFocus) return;
  338. this.setData({ inputFocus: false });
  339. wx.nextTick?.(() => {
  340. setTimeout(() => {
  341. if (this.data.keepFocus) {
  342. this.setData({ inputFocus: true });
  343. // 鸿蒙系统兼容:在聚焦后延迟检查键盘高度并更新位置
  344. // 这样可以确保即使键盘在聚焦前弹起,也能在聚焦后正确更新位置
  345. setTimeout(() => {
  346. if (this.data._keyboardHeight > 0 && this.data.inputFocus) {
  347. this._updateInputPosition(this.data._keyboardHeight);
  348. }
  349. }, 150);
  350. }
  351. }, 120);
  352. });
  353. },
  354. tapPanel() {
  355. if (!this.data.inputFocus && this.data.keepFocus) {
  356. this._ensureFocus();
  357. }
  358. },
  359. endConsult() {
  360. // 收起键盘并更新位置
  361. this._hideKeyboardAndUpdatePosition();
  362. wx.showModal({
  363. title: "",
  364. content: "确定要结束本次咨询?",
  365. cancelText: "继续咨询",
  366. confirmText: "结束",
  367. }).then((res: any) => {
  368. if (res.confirm) {
  369. // 确认结束
  370. this._endConsult();
  371. } else {
  372. // 继续咨询,恢复聚焦
  373. this.setData({ keepFocus: true });
  374. this._ensureFocus();
  375. }
  376. });
  377. },
  378. async _endConsult() {
  379. // 格式化日期时间,格式:MM-DD HH:mm:ss(与系统消息格式一致)
  380. const endDate = dayjs().format("MM-DD HH:mm:ss");
  381. // 手动添加系统消息样式的结束时间
  382. const consultId = wx.getStorageSync("consultId");
  383. this._appendMessage({
  384. id: `end-time-${Date.now()}`,
  385. consultRecordId: consultId || 0,
  386. sender: "system",
  387. sendType: "3",
  388. messageType: "1",
  389. messageContent: "咨询结束",
  390. sendTime: endDate,
  391. });
  392. // 调用结束咨询接口
  393. if (consultId) {
  394. try {
  395. await Post(`/consultManage/end/${consultId}`);
  396. } catch (error: any) {
  397. wx.showToast({
  398. title: error?.errMsg || "结束咨询失败",
  399. icon: "none",
  400. });
  401. }
  402. }
  403. // 设置结束状态
  404. this.setData({ consultEnded: true });
  405. // 更新本地存储:标记咨询已结束
  406. wx.setStorageSync("consultEnded", true);
  407. //咨询结束之后 需要清除咨询id
  408. wx.removeStorageSync("consultId");
  409. // 停止轮询最新消息
  410. this._stopPolling();
  411. // 收起键盘
  412. wx.hideKeyboard?.();
  413. // 重置底部位置为正常值(tabbar 高度 + 安全区高度)
  414. const safeBottomRpx = calculateSafeBottomRpx();
  415. const tabBarHeight = 100; // rpx
  416. const normalBottom = tabBarHeight + safeBottomRpx;
  417. // 通知父组件重置 paddingBottom,避免菜单下方有大的距离
  418. this.triggerEvent("boxBottom", { inputBoxBottom: normalBottom });
  419. // 通知父组件显示guide菜单组件
  420. this.triggerEvent("consultEvent", { type: "end" });
  421. // 滚动到底部
  422. this._scrollToBottom();
  423. },
  424. handleInput(event: any) {
  425. const value = event.detail.value;
  426. this.setData({ inputText: value });
  427. // 内容为空时,立即重置为最小高度
  428. if (!value || value.trim() === "") {
  429. if (this.data.textareaHeight !== 80) {
  430. this.setData({ textareaHeight: 80 });
  431. }
  432. }
  433. },
  434. onLineChange(event: any) {
  435. const minHeight = 80; // 最小高度(rpx)
  436. const maxHeight = 200; // 最大高度(rpx)
  437. const lineCount = event.detail.lineCount || 1;
  438. // 如果输入框为空,直接设置为最小高度
  439. if (!this.data.inputText || this.data.inputText.trim() === "") {
  440. if (this.data.textareaHeight !== minHeight) {
  441. this.setData({ textareaHeight: minHeight });
  442. }
  443. return;
  444. }
  445. // 单行时保持最小高度,不更新
  446. if (lineCount === 1) {
  447. // 只有当当前高度不是最小高度时才更新
  448. if (this.data.textareaHeight !== minHeight) {
  449. this.setData({ textareaHeight: minHeight });
  450. }
  451. return;
  452. }
  453. // 多行时才动态计算高度
  454. // 字体大小 28rpx,行高 1.9,所以每行实际高度约为 28 * 1.9 ≈ 53rpx
  455. const lineHeight = 53; // 每行高度(rpx)
  456. const padding = 24; // 上下 padding 总和(12rpx * 2 = 24rpx)
  457. // 计算高度:行数 * 行高 + 上下 padding
  458. const calculatedHeight = lineCount * lineHeight + padding;
  459. // 限制在 minHeight 和 maxHeight 之间
  460. const finalHeight = Math.max(
  461. minHeight,
  462. Math.min(maxHeight, calculatedHeight)
  463. );
  464. // 只在高度确实需要变化时更新(阈值设为 3rpx,减少频繁更新和跳动)
  465. if (Math.abs(this.data.textareaHeight - finalHeight) > 3) {
  466. this.setData({ textareaHeight: finalHeight });
  467. }
  468. },
  469. onInputFocus(event: any) {
  470. const keyboardHeight = event.detail.height ?? 0;
  471. // 设置 focus 状态
  472. this.setData({
  473. inputFocus: true,
  474. keepFocus: true,
  475. });
  476. // 如果从 focus 事件获取到有效的键盘高度,立即更新位置
  477. if (keyboardHeight > 0) {
  478. this._updateInputPosition(keyboardHeight);
  479. } else {
  480. // 如果键盘高度为0,但之前已经记录了键盘高度(可能是鸿蒙系统的问题)
  481. // 尝试使用已记录的键盘高度来更新位置
  482. if (this.data._keyboardHeight > 0) {
  483. // 延迟一点时间,等待键盘完全弹起后再更新
  484. setTimeout(() => {
  485. if (this.data.inputFocus && this.data._keyboardHeight > 0) {
  486. this._updateInputPosition(this.data._keyboardHeight);
  487. }
  488. }, 100);
  489. }
  490. }
  491. },
  492. onInputBlur() {
  493. console.log("onInputBlur==输入框失焦");
  494. // 设置 focus 状态
  495. this.setData({
  496. inputFocus: false,
  497. });
  498. // 延迟恢复位置,确保键盘完全收起
  499. setTimeout(() => {
  500. // 只有当前没有聚焦时,才恢复位置(避免在快速切换时出现问题)
  501. if (!this.data.inputFocus) {
  502. this._updateInputPosition(0);
  503. }
  504. }, 100);
  505. },
  506. // 启动轮询最新消息
  507. _startPolling() {
  508. // 如果咨询已结束,不启动轮询
  509. if (this.data.consultEnded) return;
  510. // 清除之前的轮询定时器
  511. this._stopPolling();
  512. // 每5秒轮询一次
  513. const timer = setInterval(() => {
  514. // 如果咨询已结束,停止轮询
  515. if (this.data.consultEnded) {
  516. this._stopPolling();
  517. return;
  518. }
  519. this._getLatestMessages();
  520. }, 5000);
  521. this.setData({ _pollTimer: timer });
  522. },
  523. // 停止轮询最新消息
  524. _stopPolling() {
  525. if (this.data._pollTimer) {
  526. clearInterval(this.data._pollTimer);
  527. this.setData({ _pollTimer: 0 });
  528. }
  529. },
  530. // 获取最新消息
  531. async _getLatestMessages() {
  532. const consultId = wx.getStorageSync("consultId");
  533. if (!consultId) return;
  534. try {
  535. // 获取最新消息
  536. const res = await Post(`/consultManage/getLatestMsgs/${consultId}`);
  537. // 如果有最新消息要追加到消息列表
  538. if (res.data && Array.isArray(res.data) && res.data.length > 0) {
  539. const newMessages = res.data.map((item: AnyObject) => {
  540. const msg = transformMessage(item);
  541. // 对 AI 和医生的文本消息进行 Markdown 解析
  542. if (
  543. (msg.sender === "agent" || msg.sender === "human") &&
  544. msg.messageType === "1" &&
  545. msg.messageContent
  546. ) {
  547. msg.messageContent = parseMarkdown(msg.messageContent);
  548. }
  549. return msg;
  550. });
  551. const allMessages = [...this.data.messages, ...newMessages];
  552. this.setData({ messages: allMessages });
  553. // 检查是否有真正的咨询结束消息(排除30分钟提醒消息)
  554. const hasEndMessage = newMessages.some((msg: ConsultMessage) =>
  555. isConsultEndMessage(msg)
  556. );
  557. // 如果收到真正的咨询结束消息,更新状态并停止轮询
  558. // 注意:30分钟提醒消息不会触发此逻辑,会继续轮询等待真正的结束消息
  559. if (hasEndMessage && !this.data.consultEnded) {
  560. this.setData({ consultEnded: true });
  561. wx.setStorageSync("consultEnded", true);
  562. this._stopPolling();
  563. // 收起键盘
  564. wx.hideKeyboard?.();
  565. // 重置底部位置为正常值(tabbar 高度 + 安全区高度)
  566. const safeBottomRpx = calculateSafeBottomRpx();
  567. const tabBarHeight = 100; // rpx
  568. const normalBottom = tabBarHeight + safeBottomRpx;
  569. // 通知父组件重置 paddingBottom
  570. this.triggerEvent("boxBottom", { inputBoxBottom: normalBottom });
  571. // 通知父组件显示guide菜单组件
  572. this.triggerEvent("consultEvent", { type: "end" });
  573. }
  574. //如果有最新消息要滚动到底部
  575. this._scrollToBottom();
  576. }
  577. } catch (error: any) {
  578. wx.showToast({
  579. title: error?.errMsg || "获取最新消息失败",
  580. icon: "none",
  581. });
  582. }
  583. },
  584. // 发送消息到后端
  585. async _sendMessage(messageType: "1" | "2", messageContent: string) {
  586. const consultId = wx.getStorageSync("consultId");
  587. if (!consultId) {
  588. wx.showToast({ title: "咨询ID不存在", icon: "none" });
  589. return;
  590. }
  591. try {
  592. await Post(`/consultManage/sendConsultMsg`, {
  593. consultRecordId: consultId,
  594. messageType,
  595. messageContent,
  596. }).then(() => {
  597. // 发送成功后获取最新消息
  598. // 重置轮询定时器,避免短时间内重复调用
  599. this._stopPolling();
  600. this._getLatestMessages();
  601. // 重新启动轮询,避免重复调用
  602. setTimeout(() => {
  603. if (!this.data.consultEnded) {
  604. this._startPolling();
  605. }
  606. }, 5000);
  607. });
  608. } catch (error: any) {
  609. wx.showToast({
  610. title: error?.errMsg || "发送失败,请重试",
  611. icon: "none",
  612. });
  613. }
  614. },
  615. async sendText() {
  616. const text = this.data.inputText.trim();
  617. // 至少要有一个内容才能发送
  618. if (!text) {
  619. wx.showToast({ title: "发送内容不能为空", icon: "none" });
  620. // 如果键盘未展开(inputFocus 为 false),不应该聚焦输入框和展开键盘
  621. // 只显示提示即可
  622. return;
  623. }
  624. const consultId = wx.getStorageSync("consultId");
  625. // 先添加用户消息到界面
  626. const messageId = `user-text-${Date.now()}`;
  627. this._appendMessage({
  628. id: messageId,
  629. consultRecordId: consultId || 0,
  630. sender: "user",
  631. sendType: "1",
  632. messageType: "1",
  633. messageContent: text,
  634. });
  635. // 平滑滚动到底部,确保新消息可见
  636. this._scrollToBottom();
  637. // 发送信息
  638. this._sendMessage("1", text);
  639. // 保存当前键盘状态
  640. const wasFocused = this.data.inputFocus;
  641. const currentKeyboardHeight = this.data._keyboardHeight;
  642. // 清空输入框,保持键盘展开状态(只有在键盘已展开时才保持)
  643. // 同时重置高度为最小高度,确保立即恢复
  644. this.setData({
  645. inputText: "",
  646. textareaHeight: 80, // 重置为最小高度
  647. });
  648. // 如果之前键盘是展开的,保持键盘展开状态;如果键盘是收起的,不要聚焦
  649. if (!wasFocused) {
  650. // 键盘未展开,不需要聚焦,保持当前状态即可
  651. this.setData({
  652. inputFocus: false,
  653. keepFocus: false,
  654. });
  655. } else {
  656. // 键盘已展开,保持聚焦状态
  657. // 但不需要手动触发聚焦,因为已经聚焦了
  658. // 只需要确保键盘位置正确
  659. if (currentKeyboardHeight > 0) {
  660. this._updateInputPosition(currentKeyboardHeight);
  661. }
  662. }
  663. },
  664. async chooseImage() {
  665. // 保存当前焦点状态,选择图片后恢复
  666. const wasFocused = this.data.inputFocus;
  667. const currentKeyboardHeight = this.data._keyboardHeight;
  668. // 临时收起键盘,选择图片需要系统界面
  669. this._hideKeyboardAndUpdatePosition();
  670. try {
  671. const res = await wx.chooseMedia({
  672. count: 1,
  673. mediaType: ["image"],
  674. sourceType: ["album", "camera"],
  675. });
  676. const files = res.tempFiles ?? [];
  677. const file = files[0];
  678. if (!file?.tempFilePath) return;
  679. const imagePath = file.tempFilePath;
  680. // 直接发送图片
  681. await this._sendImageMessage(imagePath);
  682. // 如果之前键盘是展开的,选择图片后恢复键盘
  683. if (wasFocused) {
  684. wx.nextTick?.(() => {
  685. setTimeout(() => {
  686. this.setData({
  687. inputFocus: true,
  688. keepFocus: true,
  689. });
  690. // 恢复键盘位置
  691. if (currentKeyboardHeight > 0) {
  692. this._updateInputPosition(currentKeyboardHeight);
  693. }
  694. }, 200);
  695. });
  696. }
  697. } catch (error: any) {
  698. // 用户取消选择图片时不提示错误
  699. if (error.errMsg && !error.errMsg.includes("cancel")) {
  700. console.error("选择图片失败", error);
  701. }
  702. // 取消时也恢复键盘(如果之前是展开的)
  703. if (wasFocused) {
  704. wx.nextTick?.(() => {
  705. setTimeout(() => {
  706. this.setData({
  707. inputFocus: true,
  708. keepFocus: true,
  709. });
  710. // 恢复键盘位置
  711. if (currentKeyboardHeight > 0) {
  712. this._updateInputPosition(currentKeyboardHeight);
  713. }
  714. }, 200);
  715. });
  716. }
  717. }
  718. },
  719. // 发送图片
  720. async _sendImageMessage(imagePath: string) {
  721. const consultId = wx.getStorageSync("consultId");
  722. try {
  723. // 先添加图片消息到界面(显示本地路径)
  724. const messageId = `user-image-${Date.now()}`;
  725. this._appendMessage({
  726. id: messageId,
  727. consultRecordId: consultId || 0,
  728. sender: "user",
  729. sendType: "1",
  730. messageType: "2",
  731. messageContent: imagePath, // 先用本地路径,上传后更新
  732. });
  733. // 上传图片
  734. const imageUrl = await upload({
  735. params: { name: "file", file: imagePath },
  736. transform({ data }: any) {
  737. return data?.url || data;
  738. },
  739. });
  740. // 发送图片消息
  741. await this._sendMessage("2", imageUrl);
  742. // 更新消息中的图片URL(从本地路径更新为服务器URL)
  743. const messages = this.data.messages;
  744. const messageIndex = messages.findIndex(
  745. (msg: ConsultMessage) => msg.id === messageId
  746. );
  747. if (messageIndex !== -1) {
  748. messages[messageIndex].messageContent = imageUrl;
  749. this.setData({ messages });
  750. }
  751. // 平滑滚动到底部,确保新消息可见
  752. this._scrollToBottom();
  753. } catch (error: any) {
  754. wx.showToast({
  755. title: error?.errMsg || "图片上传失败",
  756. icon: "none",
  757. });
  758. }
  759. },
  760. // 预览图片
  761. previewImage(e: any) {
  762. const currentUrl = e.currentTarget.dataset.url;
  763. // 获取所有图片消息的 URL 列表
  764. const urls = this.data.messages
  765. .filter(
  766. (msg: ConsultMessage) => msg.messageType === "2" && msg.messageContent
  767. )
  768. .map((msg: ConsultMessage) => msg.messageContent!);
  769. wx.previewImage({
  770. current: currentUrl, // 当前显示图片的链接
  771. urls: urls.length > 0 ? urls : [currentUrl], // 需要预览的图片链接列表
  772. fail: (err) => {
  773. wx.showToast({
  774. title: err?.errMsg || "预览图片失败",
  775. icon: "none",
  776. });
  777. },
  778. });
  779. },
  780. _appendMessage(message: ConsultMessage) {
  781. // 把获取的最新的消息追加到所有消息后面
  782. const messages = [...this.data.messages, message];
  783. this.setData({ messages });
  784. },
  785. },
  786. });