diff --git a/pages_tool/ai-chat/ai-chat-message.vue b/pages_tool/ai-chat/ai-chat-message.vue index fe0ee3b..dc7eaae 100644 --- a/pages_tool/ai-chat/ai-chat-message.vue +++ b/pages_tool/ai-chat/ai-chat-message.vue @@ -532,9 +532,8 @@ + :class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling, 'hidden-row': !voiceMode }"> @@ -561,7 +560,7 @@ Vue 2 要求 v-if/v-else 紧邻, 否则 v-else 会错配到上一个 v-if (voiceHolding), 导致 voiceMode=true 时 input-row-top 也被显示. 改成显式 v-if="!voiceMode", 避免依赖 v-else 的隐式配对 --> - + @@ -2973,6 +2972,65 @@ _handleStreamError(msg) { const BQ_MARGIN = COMPACT ? '6rpx 0' : '8rpx 0' const BQ_LH = COMPACT ? '1.6' : '1.7' + // ===== HTML 白名单还原 (对齐后台预览 markedParse 的 WC_ALLOWED_TAGS / WC_ALLOWED_STYLE) ===== + // 后台预览支持受限 HTML(染色/排版), 终端此前无条件转义所有 < >, 商家配置的 + // 在终端显示成字面文本。策略: 转义 → 识别白名单 → 只还原白名单。 + // - 标签/属性/CSS 属性三张白名单, 其余(含 on* 事件、position、url())一律丢弃 + // - 非白名单标签保持转义态, 照常显示为字面文本 + const WC_TAGS = ['span', 'div', 'b', 'i', 'u', 's', 'em', 'strong', 'mark', 'small', 'sub', 'sup', 'br', 'hr', 'p', 'blockquote', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code'] + const WC_VOID = { br: true, hr: true } // 自闭合标签, 还原时不带结束标签 + const WC_STYLES = { + color: 1, 'background-color': 1, background: 1, + 'font-size': 1, 'font-weight': 1, 'font-style': 1, 'font-family': 1, + 'text-align': 1, 'text-decoration': 1, 'text-indent': 1, 'text-transform': 1, + 'line-height': 1, 'letter-spacing': 1, 'vertical-align': 1, 'white-space': 1, + 'list-style-type': 1, 'word-break': 1, 'word-wrap': 1, + margin: 1, 'margin-top': 1, 'margin-bottom': 1, 'margin-left': 1, 'margin-right': 1, + padding: 1, 'padding-top': 1, 'padding-bottom': 1, 'padding-left': 1, 'padding-right': 1, + 'border-color': 1, 'border-style': 1, 'border-width': 1, 'border-radius': 1, + width: 1, height: 1, 'min-width': 1, 'max-width': 1, + display: 1, opacity: 1, 'text-shadow': 1 + } + const WC_STYLE_BAD = /expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@import|url\s*\(/i + const wcFilterStyle = (s) => { + return s.split(';').map((p) => { + const i = p.indexOf(':') + if (i === -1) return '' + const prop = p.slice(0, i).trim().toLowerCase() + const val = p.slice(i + 1).trim() + // 值里不允许引号; 命中黑名单(expression/javascript/url( 等)整条丢弃 + if (!WC_STYLES[prop] || WC_STYLE_BAD.test(val) || val.indexOf('"') !== -1) return '' + return prop + ': ' + val + }).filter(Boolean).join('; ') + } + const wcRestoreHtml = (escaped) => { + // 转义后标签形如 <span style="...">, 引号未被转义仍是字面 " + return escaped.replace(/<(\/?)([a-zA-Z0-9]+)([\s\S]*?)>/g, (m, slash, rawTag, attrs) => { + const tag = rawTag.toLowerCase() + if (WC_TAGS.indexOf(tag) === -1) return m // 非白名单: 保持转义, 显示为字面文本 + if (slash) return WC_VOID[tag] ? '' : '' + if (WC_VOID[tag]) return '<' + tag + '>' + const kept = [] + const attrRe = /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g + let am + while ((am = attrRe.exec(attrs)) !== null) { + const name = am[1].toLowerCase() + const val = am[2] !== undefined ? am[2] : am[3] + if (name === 'style') { + const fs = wcFilterStyle(val) + if (fs) kept.push('style="' + fs + '"') + } else if (name === 'href' || name === 'src') { + // 协议白名单: 挡 javascript:/vbscript:/data: 等 + if (/^(https?:|mailto:|tel:|\/|#)/i.test(val)) kept.push(name + '="' + val + '"') + } else if (name === 'alt' || name === 'title' || name === 'colspan' || name === 'rowspan') { + kept.push(name + '="' + val + '"') + } + // 其余属性(含 on* 事件)一律丢弃 + } + return '<' + tag + (kept.length ? ' ' + kept.join(' ') : '') + '>' + }) + } + // 辅助:内联格式处理 // 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等, // 最后还原, 避免各规则互相串改对方的 href / 数字。 @@ -3037,6 +3095,9 @@ _handleStreamError(msg) { return `%%CODEBLOCK_${idx}%%` }) + // 2.5 还原白名单内 HTML (放在代码块抽离之后: 代码块里的示例 HTML 保持字面文本) + html = wcRestoreHtml(html) + // 3. 按行处理,用
连接单换行,双换行分割段落块 const lines = html.split('\n') const out = [] @@ -3952,7 +4013,13 @@ _handleStreamError(msg) { } if (isWelcome && html) { // 仅替换 img 的 margin(机器人图片), 不影响列表等其他元素的 margin:8rpx 0 - html = html.replace(/(]*style="[^"]*?)margin:\s*8rpx 0;/, '$1margin:0 0 48rpx 0;') + // 注意: 原始样式是 "margin:8rpx 0;" (8rpx 上下 / 0 左右), 正则必须匹配这个写法, + // 只改这里的 "0 0 0rpx 0" 最后一段调间距; 前面的搜索正则不要动, 改错会匹配失效 + html = html.replace(/(]*style="[^"]*?)margin:\s*8rpx 0;/, '$1margin:0 0 0rpx 0;') + // 图片是 display:block, 欢迎语里图片和标题之间有空行, parseMarkdown 会生成 + // 连续多个
(每个占一行行高), 之前只删一个导致间距看起来"没反应"; + // 这里把 img 后面紧跟的所有
一次性清掉, 标题紧贴图片 + html = html.replace(/(]*(?:\/>|>))(?:\s*
)+/, '$1') } return html }, @@ -4146,6 +4213,8 @@ _handleStreamError(msg) { this.voiceHolding = false this.voiceHoldCancelling = false this.inputFocused = false + // 进入语音模式时清掉文字输入态, 避免白卡片背景 + 工具栏残留(切换时闪烁) + this.showTextMode = false }, // 退出语音模式, 回到文字输入态 @@ -4796,12 +4865,8 @@ _handleStreamError(msg) { background: #ffffff !important; } - // 欢迎语消息: 图片(机器人)与下方标题的间距加大, 对齐 WorkBuddy 参考稿 - // 图片是 v-html 插入的 , 不带 scoped 的 data-v 属性, - // 普通 img 选择器编译后会匹配不到, 必须用 ::v-deep 穿透 - &.is-welcome .ai-message .message-bubble ::v-deep img { - margin-bottom: 48rpx !important; - } + // 欢迎语消息: 图片间距由内容内联样式控制(getAIDisplayContent 里改成 0 0 8rpx 0), + // 这里不再用 CSS 强制 48rpx —— 样式表 !important 会覆盖内联样式, 导致 H5 上调距无效 // 欢迎语消息: 隐藏上方时间戳 &.is-welcome .ai-message .message-nickname.ai-nickname { @@ -5811,10 +5876,10 @@ _handleStreamError(msg) { } } - /* 键盘垫高层:高度变化时平滑过渡 */ + /* 键盘垫高层:高度变化不做过渡 —— 切换语音/文字时 hideKeyboard 触发高度变化, + 若加 transition 会播放高度动画, 输入区整体跳动, 视觉上就是「频闪/看到上次样式」 */ .keyboard-spacer { flex-shrink: 0; - transition: height 0.25s ease; } /* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */ @@ -5840,6 +5905,9 @@ _handleStreamError(msg) { justify-content: space-between; background: #ffffff; border-radius: 999rpx; + /* border-box: 让 min-height 把上下 padding(10rpx*2)算进去, 否则 content-box 下 + 实际高度 = 96+20 = 116rpx, 与语音胶囊(96rpx)差 20rpx → 小程序里上下高度不一致 */ + box-sizing: border-box; /* 高度与语音模式胶囊(.voice-mode-row: height 96rpx)保持一致: 内部图标容器 76rpx + 上下 padding 10rpx*2 = 96rpx */ padding: 10rpx 14rpx 10rpx 32rpx; @@ -6004,14 +6072,25 @@ _handleStreamError(msg) { } } + /* 语音/文字两态胶囊始终在 DOM 中, 用显隐切换而非 v-if 重建节点。 + v-if 在微信小程序会触发节点卸载/重建, 切换瞬间旧节点那帧会闪(看到上次样式)。 + !important 用于盖过 .voice-mode-row / .input-row-top 自身的 display:flex */ + .hidden-row { + display: none !important; + } + /* 语音模式单行 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex布局,不依赖绝对定位) 键盘图标在左 / 按住说话占中间 / + 在右, 与参考稿一致 */ .voice-mode-row { position: relative; /* 「上滑取消」提示 .voice-cancel-hint 锚定到这里 */ display: flex; align-items: center; + /* 与 .input-row-top 一致用 border-box, 保证两者 96rpx 计算口径相同, 高度一致 */ + box-sizing: border-box; height: 96rpx; - margin: 4rpx 0 8rpx; + /* 上下 margin 为 0: 与默认输入胶囊(.input-row-top margin 0)整块高度一致, + 否则语音态多占 12rpx, 切换时聊天列表被顶/拉 12rpx 产生"看到上次样式"的频闪 */ + margin: 0; padding: 0 10rpx 0 24rpx; border-radius: 999rpx; background: #ffffff; @@ -6542,11 +6621,21 @@ _handleStreamError(msg) { .input-area { padding: 10rpx 16rpx 12rpx; - .input-card { + /* 卡片内边距/圆角只对文字输入态(白卡片)生效; 默认/语音胶囊保持 flush, + 否则默认胶囊会多吃 12+10rpx, 与语音胶囊整块高度差 22rpx, 切换时跳动频闪 */ + .input-card.is-text-mode { padding: 12rpx 14rpx 10rpx; border-radius: 18rpx; } + /* 语音模式胶囊: 与默认输入胶囊左右对齐。 + 默认胶囊在 .input-card 内(左右各收进 14rpx), 而语音模式卡片 padding 被 + .is-voice-mode 强制为 0, 胶囊全宽 —— 用同样的水平 margin 补齐, 两端长短一致。 + 上下 margin 保持 0, 与默认胶囊整块等高, 避免切换时聊天列表跳动 */ + .voice-mode-row { + margin: 0 14rpx; + } + .input-row-top { min-height: 64rpx;