Merge branch 'temp221' into dev/1.0

This commit is contained in:
2026-10-10 09:04:48 +08:00

View File

@@ -532,9 +532,8 @@
<!-- 语音模式 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex), <!-- 语音模式 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex),
键盘图标在左、+ 在右、按住说话占中间 —— 不依赖绝对定位, 结构上保证单行 --> 键盘图标在左、+ 在右、按住说话占中间 —— 不依赖绝对定位, 结构上保证单行 -->
<view <view
v-if="voiceMode"
class="voice-mode-row" class="voice-mode-row"
:class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling }"> :class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling, 'hidden-row': !voiceMode }">
<!-- 左侧: 键盘图标 (点击回到文字输入) --> <!-- 左侧: 键盘图标 (点击回到文字输入) -->
<view class="voice-side-btn voice-side-left" @click="exitVoiceMode"> <view class="voice-side-btn voice-side-left" @click="exitVoiceMode">
<view class="emoji-icon-bg"></view> <view class="emoji-icon-bg"></view>
@@ -561,7 +560,7 @@
Vue 2 要求 v-if/v-else 紧邻, 否则 v-else 会错配到上一个 v-if (voiceHolding), Vue 2 要求 v-if/v-else 紧邻, 否则 v-else 会错配到上一个 v-if (voiceHolding),
导致 voiceMode=true 时 input-row-top 也被显示. 导致 voiceMode=true 时 input-row-top 也被显示.
改成显式 v-if="!voiceMode", 避免依赖 v-else 的隐式配对 --> 改成显式 v-if="!voiceMode", 避免依赖 v-else 的隐式配对 -->
<view v-if="!voiceMode" class="input-row-top"> <view class="input-row-top" :class="{ 'hidden-row': voiceMode }">
<!-- 默认态左侧: 点击进入语音(按住说话)模式 --> <!-- 默认态左侧: 点击进入语音(按住说话)模式 -->
<!-- PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 兄弟节点加 key, 防止 H5 下 Vue 无 key 复用 <!-- PR-FRONTEND-H5-INPUT-FOCUS (2026-09-17): 兄弟节点加 key, 防止 H5 下 Vue 无 key 复用
同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) --> 同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) -->
@@ -2973,6 +2972,65 @@ _handleStreamError(msg) {
const BQ_MARGIN = COMPACT ? '6rpx 0' : '8rpx 0' const BQ_MARGIN = COMPACT ? '6rpx 0' : '8rpx 0'
const BQ_LH = COMPACT ? '1.6' : '1.7' const BQ_LH = COMPACT ? '1.6' : '1.7'
// ===== HTML 白名单还原 (对齐后台预览 markedParse 的 WC_ALLOWED_TAGS / WC_ALLOWED_STYLE) =====
// 后台预览支持受限 HTML(染色/排版), 终端此前无条件转义所有 < >, 商家配置的
// <span style="..."> 在终端显示成字面文本。策略: 转义 → 识别白名单 → 只还原白名单。
// - 标签/属性/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) => {
// 转义后标签形如 &lt;span style="..."&gt;, 引号未被转义仍是字面 "
return escaped.replace(/&lt;(\/?)([a-zA-Z0-9]+)([\s\S]*?)&gt;/g, (m, slash, rawTag, attrs) => {
const tag = rawTag.toLowerCase()
if (WC_TAGS.indexOf(tag) === -1) return m // 非白名单: 保持转义, 显示为字面文本
if (slash) return WC_VOID[tag] ? '' : '</' + 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/电话"整体抽成占位符保护起来, 再做加粗/斜体等, // 思路: 先把"链接/图片/裸URL/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 href / 数字。 // 最后还原, 避免各规则互相串改对方的 href / 数字。
@@ -3037,6 +3095,9 @@ _handleStreamError(msg) {
return `%%CODEBLOCK_${idx}%%` return `%%CODEBLOCK_${idx}%%`
}) })
// 2.5 还原白名单内 HTML (放在代码块抽离之后: 代码块里的示例 HTML 保持字面文本)
html = wcRestoreHtml(html)
// 3. 按行处理,用 <br> 连接单换行,双换行分割段落块 // 3. 按行处理,用 <br> 连接单换行,双换行分割段落块
const lines = html.split('\n') const lines = html.split('\n')
const out = [] const out = []
@@ -3952,7 +4013,13 @@ _handleStreamError(msg) {
} }
if (isWelcome && html) { if (isWelcome && html) {
// 仅替换 img 的 margin(机器人图片), 不影响列表等其他元素的 margin:8rpx 0 // 仅替换 img 的 margin(机器人图片), 不影响列表等其他元素的 margin:8rpx 0
html = html.replace(/(<img[^>]*style="[^"]*?)margin:\s*8rpx 0;/, '$1margin:0 0 48rpx 0;') // 注意: 原始样式是 "margin:8rpx 0;" (8rpx 上下 / 0 左右), 正则必须匹配这个写法,
// 只改这里的 "0 0 0rpx 0" 最后一段调间距; 前面的搜索正则不要动, 改错会匹配失效
html = html.replace(/(<img[^>]*style="[^"]*?)margin:\s*8rpx 0;/, '$1margin:0 0 0rpx 0;')
// 图片是 display:block, 欢迎语里图片和标题之间有空行, parseMarkdown 会生成
// 连续多个 <br> (每个占一行行高), 之前只删一个导致间距看起来"没反应";
// 这里把 img 后面紧跟的所有 <br> 一次性清掉, 标题紧贴图片
html = html.replace(/(<img[^>]*(?:\/>|>))(?:\s*<br>)+/, '$1')
} }
return html return html
}, },
@@ -4146,6 +4213,8 @@ _handleStreamError(msg) {
this.voiceHolding = false this.voiceHolding = false
this.voiceHoldCancelling = false this.voiceHoldCancelling = false
this.inputFocused = false this.inputFocused = false
// 进入语音模式时清掉文字输入态, 避免白卡片背景 + 工具栏残留(切换时闪烁)
this.showTextMode = false
}, },
// 退出语音模式, 回到文字输入态 // 退出语音模式, 回到文字输入态
@@ -4796,12 +4865,8 @@ _handleStreamError(msg) {
background: #ffffff !important; background: #ffffff !important;
} }
// 欢迎语消息: 图片(机器人)与下方标题的间距加大, 对齐 WorkBuddy 参考稿 // 欢迎语消息: 图片间距由内容内联样式控制(getAIDisplayContent 里改成 0 0 8rpx 0),
// 图片是 v-html 插入的 <img>, 不带 scoped 的 data-v 属性, // 这里不再用 CSS 强制 48rpx —— 样式表 !important 会覆盖内联样式, 导致 H5 上调距无效
// 普通 img 选择器编译后会匹配不到, 必须用 ::v-deep 穿透
&.is-welcome .ai-message .message-bubble ::v-deep img {
margin-bottom: 48rpx !important;
}
// 欢迎语消息: 隐藏上方时间戳 // 欢迎语消息: 隐藏上方时间戳
&.is-welcome .ai-message .message-nickname.ai-nickname { &.is-welcome .ai-message .message-nickname.ai-nickname {
@@ -5811,10 +5876,10 @@ _handleStreamError(msg) {
} }
} }
/* 键盘垫高层:高度变化时平滑过渡 */ /* 键盘垫高层:高度变化不做过渡 —— 切换语音/文字时 hideKeyboard 触发高度变化,
若加 transition 会播放高度动画, 输入区整体跳动, 视觉上就是「频闪/看到上次样式」 */
.keyboard-spacer { .keyboard-spacer {
flex-shrink: 0; flex-shrink: 0;
transition: height 0.25s ease;
} }
/* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */ /* 输入区域 (新版: 顶部胶囊形输入条 + 底部 5 个图标均匀分布) */
@@ -5840,6 +5905,9 @@ _handleStreamError(msg) {
justify-content: space-between; justify-content: space-between;
background: #ffffff; background: #ffffff;
border-radius: 999rpx; 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)保持一致: /* 高度与语音模式胶囊(.voice-mode-row: height 96rpx)保持一致:
内部图标容器 76rpx + 上下 padding 10rpx*2 = 96rpx */ 内部图标容器 76rpx + 上下 padding 10rpx*2 = 96rpx */
padding: 10rpx 14rpx 10rpx 32rpx; 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布局,不依赖绝对定位) /* 语音模式单行 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex布局,不依赖绝对定位)
键盘图标在左 / 按住说话占中间 / + 在右, 与参考稿一致 */ 键盘图标在左 / 按住说话占中间 / + 在右, 与参考稿一致 */
.voice-mode-row { .voice-mode-row {
position: relative; /* 「上滑取消」提示 .voice-cancel-hint 锚定到这里 */ position: relative; /* 「上滑取消」提示 .voice-cancel-hint 锚定到这里 */
display: flex; display: flex;
align-items: center; align-items: center;
/* 与 .input-row-top 一致用 border-box, 保证两者 96rpx 计算口径相同, 高度一致 */
box-sizing: border-box;
height: 96rpx; height: 96rpx;
margin: 4rpx 0 8rpx; /* 上下 margin 为 0: 与默认输入胶囊(.input-row-top margin 0)整块高度一致,
否则语音态多占 12rpx, 切换时聊天列表被顶/拉 12rpx 产生"看到上次样式"的频闪 */
margin: 0;
padding: 0 10rpx 0 24rpx; padding: 0 10rpx 0 24rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: #ffffff; background: #ffffff;
@@ -6542,11 +6621,21 @@ _handleStreamError(msg) {
.input-area { .input-area {
padding: 10rpx 16rpx 12rpx; padding: 10rpx 16rpx 12rpx;
.input-card { /* 卡片内边距/圆角只对文字输入态(白卡片)生效; 默认/语音胶囊保持 flush,
否则默认胶囊会多吃 12+10rpx, 与语音胶囊整块高度差 22rpx, 切换时跳动频闪 */
.input-card.is-text-mode {
padding: 12rpx 14rpx 10rpx; padding: 12rpx 14rpx 10rpx;
border-radius: 18rpx; border-radius: 18rpx;
} }
/* 语音模式胶囊: 与默认输入胶囊左右对齐。
默认胶囊在 .input-card 内(左右各收进 14rpx), 而语音模式卡片 padding 被
.is-voice-mode 强制为 0, 胶囊全宽 —— 用同样的水平 margin 补齐, 两端长短一致。
上下 margin 保持 0, 与默认胶囊整块等高, 避免切换时聊天列表跳动 */
.voice-mode-row {
margin: 0 14rpx;
}
.input-row-top { .input-row-top {
min-height: 64rpx; min-height: 64rpx;