Merge branch 'temp221' into dev/1.0
This commit is contained in:
@@ -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) => {
|
||||||
|
// 转义后标签形如 <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] ? '' : '</' + 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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user