message-consult.ts 18 KB

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