|
|
@@ -46,6 +46,150 @@ function isConsultEndMessage(msg: ConsultMessage): boolean {
|
|
|
return isRealEnd;
|
|
|
}
|
|
|
|
|
|
+// 转义 HTML 特殊字符
|
|
|
+function escapeHtml(text: string): string {
|
|
|
+ return text
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">")
|
|
|
+ .replace(/"/g, """)
|
|
|
+ .replace(/'/g, "'");
|
|
|
+}
|
|
|
+
|
|
|
+// 将 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], // 需要预览的图片链接列表
|