Pārlūkot izejas kodu

优化在线咨询 以及支持ai和医生返回的信息支持md格式

张田田 4 mēneši atpakaļ
vecāks
revīzija
3030ed07cf

+ 67 - 1
miniprogram/module/chats/components/message-consult/message-consult.scss

@@ -12,10 +12,76 @@
 .message-content {
   display: flex;
   justify-content: center;
-  align-items: center;
+  align-items: flex-start; // 改为 flex-start,避免影响内部元素
   min-height: 72rpx;
   font-size: 28rpx;
   color: #333;
+  
+  // Markdown 内容样式
+  .markdown-content {
+    width: 100%;
+    word-wrap: break-word;
+    word-break: break-all;
+    white-space: normal;
+    line-height: 1.5;
+    font-size: 28rpx;
+    display: block; // 确保是块级元素
+    
+    // 加粗和斜体样式 - 现在使用 span 标签
+    // span 标签默认就是行内元素,不需要额外设置
+    :deep(span) {
+      display: inline !important;
+      white-space: normal !important;
+      line-height: inherit !important;
+      margin: 0 !important;
+      padding: 0 !important;
+      vertical-align: baseline;
+      float: none !important;
+      clear: none !important;
+    }
+    
+    // 代码块样式
+    :deep(pre) {
+      background-color: #f5f5f5;
+      padding: 16rpx;
+      border-radius: 8rpx;
+      overflow-x: auto;
+      margin: 16rpx 0;
+    }
+    
+    :deep(code) {
+      background-color: #f5f5f5;
+      padding: 4rpx 8rpx;
+      border-radius: 4rpx;
+      font-family: 'Courier New', monospace;
+      font-size: 24rpx;
+    }
+    
+    :deep(pre code) {
+      background-color: transparent;
+      padding: 0;
+    }
+    
+    // 列表样式
+    :deep(ul) {
+      margin: 16rpx 0;
+      padding-left: 40rpx;
+    }
+    
+    :deep(li) {
+      margin: 8rpx 0;
+      list-style-type: disc;
+      // 确保列表项内的加粗文字不会换行
+      :deep(span) {
+        display: inline !important;
+      }
+    }
+    
+    // 换行处理
+    :deep(br) {
+      line-height: 1.5;
+    }
+  }
 }
 
 .message-image {

+ 219 - 51
miniprogram/module/chats/components/message-consult/message-consult.ts

@@ -46,6 +46,150 @@ function isConsultEndMessage(msg: ConsultMessage): boolean {
   return isRealEnd;
 }
 
+// 转义 HTML 特殊字符
+function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, "&")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+    .replace(/'/g, "&#39;");
+}
+
+// 将 Markdown 格式文本转换为 HTML(支持基本的 Markdown 语法)
+function parseMarkdown(text: string): string {
+  if (!text) return "";
+  
+  // 先处理代码块(三个反引号),提取出来避免被其他规则匹配
+  const codeBlocks: string[] = [];
+  let codeBlockIndex = 0;
+  let html = text.replace(/```([\s\S]*?)```/g, (_, code) => {
+    const placeholder = `__CODE_BLOCK_${codeBlockIndex}__`;
+    codeBlocks[codeBlockIndex] = escapeHtml(code);
+    codeBlockIndex++;
+    return placeholder;
+  });
+  
+  // 处理行内代码(一个反引号),也要先提取出来
+  const inlineCodes: string[] = [];
+  let inlineCodeIndex = 0;
+  html = html.replace(/`([^`\n]+)`/g, (_, code) => {
+    const placeholder = `__INLINE_CODE_${inlineCodeIndex}__`;
+    inlineCodes[inlineCodeIndex] = escapeHtml(code);
+    inlineCodeIndex++;
+    return placeholder;
+  });
+  
+  // 先处理列表(在处理加粗之前,避免 split 时分割 strong 标签)
+  // 处理列表:支持无序列表(以 - 或 * 开头的行)
+  const lines = html.split("\n");
+  let inList = false;
+  let result: string[] = [];
+  
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i];
+    // 匹配以空格+减号或星号开头的行(列表项)
+    const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
+    
+    if (listMatch) {
+      if (!inList) {
+        result.push("<ul>");
+        inList = true;
+      }
+      result.push(`<li>${listMatch[2]}</li>`);
+    } else {
+      if (inList) {
+        result.push("</ul>");
+        inList = false;
+      }
+      if (line.trim()) {
+        result.push(line);
+      } else {
+        result.push("<br>");
+      }
+    }
+  }
+  
+  if (inList) {
+    result.push("</ul>");
+  }
+  
+  html = result.join("\n");
+  
+  // 处理加粗:两个※※(优先处理,避免与单个※冲突)
+  // 使用 span 标签配合 style,确保是行内元素
+  html = html.replace(/※※([^\n]+?)※※/g, (_, content) => {
+    return `<span style="font-weight:bold;display:inline;">${escapeHtml(content.trim())}</span>`;
+  });
+  // 处理加粗:两个星号 **
+  html = html.replace(/\*\*([^\n]+?)\*\*/g, (_, content) => {
+    return `<span style="font-weight:bold;display:inline;">${escapeHtml(content.trim())}</span>`;
+  });
+  
+  // 处理斜体:一个※(但要排除已经是加粗标记的)
+  html = html.replace(/(?<!※)※(?!※)([^※\n]+?)※(?!※)/g, (_, content) => {
+    return `<span style="font-style:italic;display:inline;">${escapeHtml(content.trim())}</span>`;
+  });
+  // 处理斜体:一个星号 *(但要排除已经是加粗标记的)
+  html = html.replace(/(?<!\*)\*(?!\*)([^\*\n]+?)\*(?!\*)/g, (_, content) => {
+    return `<span style="font-style:italic;display:inline;">${escapeHtml(content.trim())}</span>`;
+  });
+  
+  // 恢复行内代码
+  for (let i = 0; i < inlineCodes.length; i++) {
+    html = html.replace(`__INLINE_CODE_${i}__`, `<code>${inlineCodes[i]}</code>`);
+  }
+  
+  // 恢复代码块
+  for (let i = 0; i < codeBlocks.length; i++) {
+    html = html.replace(`__CODE_BLOCK_${i}__`, `<pre><code>${codeBlocks[i]}</code></pre>`);
+  }
+  
+  // 转义剩余文本中的 HTML 特殊字符(但保留已经生成的 HTML 标签)
+  const tagPlaceholders: string[] = [];
+  let tagIndex = 0;
+  // 现在使用 span 标签,所以需要匹配 span 和 code、pre、ul、li、br
+  html = html.replace(/<(\/?)(span|code|pre|ul|li|br)[^>]*>/gi, (match) => {
+    const placeholder = `__TAG_${tagIndex}__`;
+    tagPlaceholders[tagIndex] = match;
+    tagIndex++;
+    return placeholder;
+  });
+  
+  // 转义剩余的 HTML 特殊字符
+  html = escapeHtml(html);
+  
+  // 恢复 HTML 标签
+  for (let i = 0; i < tagPlaceholders.length; i++) {
+    html = html.replace(`__TAG_${i}__`, tagPlaceholders[i]);
+  }
+  
+  // 统一清理所有 HTML 标签之间的空白字符
+  // 关键:加粗标签后绝对不能有任何空白字符,包括换行符、空格、制表符等
+  
+  // 第一步:清理所有标签之间的空白
+  html = html.replace(/>[\s\n\r\t]+</g, "><");
+  
+  // 第二步:特别处理加粗标签 - 移除后面所有空白字符(包括换行符)
+  html = html.replace(/(<\/span[^>]*style="[^"]*font-weight:bold[^"]*"[^>]*>)[\s\n\r\t\u00A0\u2000-\u200B\u2028\u2029\u3000]+/g, "$1");
+  
+  // 第三步:清理其他 span 标签前后的空白
+  html = html.replace(/(<\/span[^>]*>)[\s\n\r\t]+/g, "$1");
+  html = html.replace(/[\s\n\r\t]+(<span[^>]*>)/g, "$1");
+  
+  // 第四步:清理其他标签前后的空白
+  html = html.replace(/>[\s\n\r\t]+/g, ">");
+  html = html.replace(/[\s\n\r\t]+</g, "<");
+  
+  // 第五步:将换行符转换为 <br>(加粗标签后的换行已经被移除了)
+  html = html.replace(/\n/g, "<br>");
+  
+  // 第六步:最后再次确保加粗标签后没有任何空白字符
+  html = html.replace(/(<\/span[^>]*style="[^"]*font-weight:bold[^"]*"[^>]*>)[\s\u00A0\u2000-\u200B\u2028\u2029\u3000]+/g, "$1");
+  
+  return html;
+}
+
 // 获取的聊天消息为ConsultMessage格式 提取为一个公共的方法
 function transformMessage(item: AnyObject): ConsultMessage {
   const sender = sendTypeMap[item.sendType];
@@ -69,7 +213,6 @@ Component({
     inputBoxBottom: 0,
     baseInputBottom: 0,
     keepFocus: true,
-    _kbTimer: 0 as any,
     _keyboardHeight: 0, // 当前键盘高度
     isTransferredToHuman: false, // 是否已转人工
     consultEnded: false, // 是否已结束咨询
@@ -92,9 +235,18 @@ Component({
           // 获取所有消息的数据
           const res = await Post(`/consultManage/getAllMsgs/${consultId}`);
           if (res.data && res.data.length > 0) {
-            messages = res.data.map((item: AnyObject) =>
-              transformMessage(item)
-            );
+            messages = res.data.map((item: AnyObject) => {
+              const msg = transformMessage(item);
+              // 对 AI 和医生的文本消息进行 Markdown 解析
+              if (
+                (msg.sender === "agent" || msg.sender === "human") &&
+                msg.messageType === "1" &&
+                msg.messageContent
+              ) {
+                msg.messageContent = parseMarkdown(msg.messageContent);
+              }
+              return msg;
+            });
           }
         } catch (error: any) {
           wx.showToast({
@@ -125,34 +277,22 @@ Component({
       console.log("baseBottom==输入框的位置", baseBottom);
       this.triggerEvent("boxBottom", { inputBoxBottom: baseBottom });
       // 监听键盘高度变化事件
-      let lastHeight = 0;
-
+      // 每次键盘高度变化时直接更新位置
       const kbHandler = (res: any) => {
         const height = res?.height ?? 0;
-        if (!this.data.inputFocus) {
-          lastHeight = 0;
-        } else {
-          lastHeight = Math.max(lastHeight, height); // 记录键盘最大高度
-        }
-        console.log("height==键盘高度", height, "lastHeight=====", lastHeight);
-
-        // 如果键盘高度没有实际变化,不更新位置
-        if (this.data._keyboardHeight && lastHeight) {
+        
+        // 键盘收起时,直接更新位置
+        if (height === 0) {
+          this._updateInputPosition(0);
           return;
         }
-
-        // 清除之前的定时器
-        if (this.data._kbTimer) {
-          clearTimeout(this.data._kbTimer);
+        
+        // 键盘弹起时,只有当输入框聚焦时才更新位置
+        // 如果输入框未聚焦,说明键盘可能是其他原因弹起的,忽略
+        if (this.data.inputFocus) {
+          // 直接更新位置,每次键盘高度变化时都更新
+          this._updateInputPosition(height);
         }
-
-        // 键盘展开时立即更新位置,确保没有空隙
-        // 键盘收起时稍微延迟,避免与输入框高度变化冲突
-        let _kbTimer = setTimeout(() => {
-          console.log("lastHeight==键盘高度 zx", lastHeight);
-          this._updateInputPosition(lastHeight);
-        }, 300);
-        this.setData({ _kbTimer: _kbTimer });
       };
 
       wx.onKeyboardHeightChange?.(kbHandler);
@@ -167,13 +307,10 @@ Component({
       }
     },
     detached() {
-      // 清理监听和定时器
+      // 清理监听
       if ((this as any)._kbHandler) {
         wx.offKeyboardHeightChange?.((this as any)._kbHandler);
       }
-      if (this.data._kbTimer) {
-        clearTimeout(this.data._kbTimer);
-      }
       // 清理轮询定时器
       this._stopPolling();
     },
@@ -194,15 +331,6 @@ Component({
     },
     // 统一的位置更新方法
     _updateInputPosition(keyboardHeight: number) {
-      // 避免重复更新相同高度(10px 容差)
-      if (
-        Math.abs(keyboardHeight - this.data._keyboardHeight) < 10 &&
-        Math.abs(this.data.inputBoxBottom - this.data.baseInputBottom) < 10 &&
-        keyboardHeight === 0
-      ) {
-        return;
-      }
-
       const systemInfo = wx.getSystemInfoSync();
       const rpx2px = systemInfo.windowWidth / 750;
 
@@ -216,12 +344,16 @@ Component({
       const nextBottom =
         keyboardHeight > 0 ? keyboardHeightRpx : this.data.baseInputBottom;
 
-      // 避免重复更新相同位置(1rpx 容差,更精确
+      // 避免重复更新相同位置(容差1rpx)
       if (Math.abs(nextBottom - this.data.inputBoxBottom) < 1) {
+        // 位置已经正确,只更新键盘高度记录
+        if (keyboardHeight !== this.data._keyboardHeight) {
+          this.setData({ _keyboardHeight: keyboardHeight });
+        }
         return;
       }
 
-      // 更新位置
+      // 更新位置和键盘高度
       this.setData({
         inputBoxBottom: nextBottom,
         _keyboardHeight: keyboardHeight,
@@ -383,8 +515,11 @@ Component({
         keepFocus: true,
       });
 
-      // 立即更新位置
-      this._updateInputPosition(keyboardHeight);
+      // 如果从 focus 事件获取到有效的键盘高度,立即更新位置
+      // 如果键盘高度为0,说明键盘还没完全弹起,等待 kbHandler 来更新
+      if (keyboardHeight > 0) {
+        this._updateInputPosition(keyboardHeight);
+      }
     },
     onInputBlur() {
       console.log("onInputBlur==输入框失焦");
@@ -393,10 +528,13 @@ Component({
       this.setData({
         inputFocus: false,
       });
-      clearTimeout(this.data._kbTimer);
+      
       // 延迟恢复位置,确保键盘完全收起
       setTimeout(() => {
-        this._updateInputPosition(0);
+        // 只有当前没有聚焦时,才恢复位置(避免在快速切换时出现问题)
+        if (!this.data.inputFocus) {
+          this._updateInputPosition(0);
+        }
       }, 100);
     },
 
@@ -433,9 +571,18 @@ Component({
         const res = await Post(`/consultManage/getLatestMsgs/${consultId}`);
         // 如果有最新消息要追加到消息列表
         if (res.data && Array.isArray(res.data) && res.data.length > 0) {
-          const newMessages = res.data.map((item: AnyObject) =>
-            transformMessage(item)
-          );
+          const newMessages = res.data.map((item: AnyObject) => {
+            const msg = transformMessage(item);
+            // 对 AI 和医生的文本消息进行 Markdown 解析
+            if (
+              (msg.sender === "agent" || msg.sender === "human") &&
+              msg.messageType === "1" &&
+              msg.messageContent
+            ) {
+              msg.messageContent = parseMarkdown(msg.messageContent);
+            }
+            return msg;
+          });
           const allMessages = [...this.data.messages, ...newMessages];
           this.setData({ messages: allMessages });
 
@@ -512,6 +659,8 @@ Component({
       // 至少要有一个内容才能发送
       if (!text) {
         wx.showToast({ title: "发送内容不能为空", icon: "none" });
+        // 如果键盘未展开(inputFocus 为 false),不应该聚焦输入框和展开键盘
+        // 只显示提示即可
         return;
       }
       const consultId = wx.getStorageSync("consultId");
@@ -530,12 +679,32 @@ Component({
       // 发送信息
       this._sendMessage("1", text);
 
-      // 清空输入框,保持键盘展开状态
+      // 保存当前键盘状态
+      const wasFocused = this.data.inputFocus;
+      const currentKeyboardHeight = this.data._keyboardHeight;
+
+      // 清空输入框,保持键盘展开状态(只有在键盘已展开时才保持)
       // 同时重置高度为最小高度,确保立即恢复
       this.setData({
         inputText: "",
         textareaHeight: 80, // 重置为最小高度
       });
+      
+      // 如果之前键盘是展开的,保持键盘展开状态;如果键盘是收起的,不要聚焦
+      if (!wasFocused) {
+        // 键盘未展开,不需要聚焦,保持当前状态即可
+        this.setData({
+          inputFocus: false,
+          keepFocus: false,
+        });
+      } else {
+        // 键盘已展开,保持聚焦状态
+        // 但不需要手动触发聚焦,因为已经聚焦了
+        // 只需要确保键盘位置正确
+        if (currentKeyboardHeight > 0) {
+          this._updateInputPosition(currentKeyboardHeight);
+        }
+      }
     },
     async chooseImage() {
       // 保存当前焦点状态,选择图片后恢复
@@ -649,7 +818,6 @@ Component({
           (msg: ConsultMessage) => msg.messageType === "2" && msg.messageContent
         )
         .map((msg: ConsultMessage) => msg.messageContent!);
-    
       wx.previewImage({
         current: currentUrl, // 当前显示图片的链接
         urls: urls.length > 0 ? urls : [currentUrl], // 需要预览的图片链接列表

+ 11 - 1
miniprogram/module/chats/components/message-consult/message-consult.wxml

@@ -8,6 +8,13 @@
   module.exports.isSystem = function (sender) {
     return sender === 'system'
   }
+  module.exports.isAgent = function (sender) {
+    return sender === 'agent'
+  }
+  // 判断是否需要 Markdown 渲染(AI 和医生的消息)
+  module.exports.needMarkdown = function (sender) {
+    return sender === 'agent' || sender === 'human'
+  }
 </wxs>
 <!--module/chats/components/message-consult/message-consult.wxml-->
 <view class="consult-wrapper {{consultEnded ? 'consult-ended' : ''}}">
@@ -28,7 +35,10 @@
         <block wx:if="{{item.messageType === '1'}}">
           <t-cell t-class="cell-border-gradient" bordered="{{false}}" class="chat-box">
             <view slot="title" class="message-content">
-              <text>{{item.messageContent}}</text>
+              <!-- AI 和医生的消息使用 rich-text 渲染 Markdown -->
+              <rich-text wx:if="{{_.needMarkdown(item.sender)}}" nodes="{{item.messageContent}}" class="markdown-content"></rich-text>
+              <!-- 用户消息使用普通文本 -->
+              <text wx:else>{{item.messageContent}}</text>
             </view>
           </t-cell>
         </block>