@@ -234,10 +234,10 @@
<!-- #ifdef MP-WEIXIN -->
<!-- PR-CHAT-SELECTABLE (2026-09-23): rich-text 不支持文本选中, 改用 mp-html + selectable
开启原生长按选中(剪切/复制/粘贴/全选); force 同时兼容微信 iOS -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content)" selectable="force" />
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content, message.isWelcome )" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="ai-message-text selectable-md" v-html="getAIDisplayContent(message.content)"></view>
<view class="ai-message-text selectable-md" v-html="getAIDisplayContent(message.content, message.isWelcome )"></view>
<!-- #endif -->
</view>
@@ -250,10 +250,10 @@
<view class="markdown-body">
<!-- #ifdef MP-WEIXIN -->
<!-- PR-CHAT-SELECTABLE (2026-09-23): 同上, mp-html + selectable 支持长按选中复制 -->
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content)" selectable="force" />
<mp-html class="ai-message-rich" :content="getAIDisplayContent(message.content, message.isWelcome )" selectable="force" />
<!-- #endif -->
<!-- #ifdef H5 -->
<view class="selectable-md" v-html="getAIDisplayContent(message.content)"></view>
<view class="selectable-md" v-html="getAIDisplayContent(message.content, message.isWelcome )"></view>
<!-- #endif -->
</view>
</view>
@@ -529,17 +529,29 @@
① 默认(未输入/未聚焦): 单行胶囊 —— 语音图标 + 输入框 + 加号
② 语音模式(点语音图标进入): 「按住说话」按钮 + 底部工具栏(表情/模型/加号)
③ 文字输入(聚焦或有文字): 输入框独占一行 + 底部工具栏(语音/模型/加号/发送) -->
<!-- 语音模式: 按住说话 (PR-FRONTEND-VOICE-INPUT 2026-09-24) -->
<!-- 语音模式 (PR-VOICE-MODE-ONE-ROW-V2): 整行即白色胶囊(flex),
键盘图标在左、+ 在右、按住说话占中间 —— 不依赖绝对定位, 结构上保证单行 -->
<view
v-if ="voiceM ode"
class="voice-h old-btn"
:class="{ 'is-holding': voiceHolding, 'is-cancelling': voiceHoldCancelling }"
@touchstart.stop.prevent="onVoiceHoldStart"
@touchmove.stop.prevent="onVoiceHoldMove"
@touchend.stop.prevent="onVoiceHoldEnd"
@touchcancel.stop.prevent="onVoiceHoldCancel"
@click.stop="onVoiceHoldEnd">
<text class="voice-hold-text">{{ voiceHoldText }}</text>
class ="voice-m ode-row "
: class="{ 'is-holding': voiceH olding, 'is-cancelling': voiceHoldCancelling, 'hidden-row': !voiceMode }">
<!-- 左侧: 键盘图标 (点击回到文字输入) -->
<view class="voice-side-btn voice-side-left" @click="exitVoiceMode">
<view class="emoji-icon-bg"></view>
</view>
<!-- 中间: 按住说话 (按住录音, 松开发送) -->
<view
class="voice-hold-btn"
@touchstart.stop.prevent="onVoiceHoldStart"
@touchmove.stop.prevent="onVoiceHoldMove"
@touchend.stop.prevent="onVoiceHoldEnd"
@touchcancel.stop.prevent="onVoiceHoldCancel"
@click.stop="onVoiceHoldEnd">
<text class="voice-hold-text">{{ voiceHoldText }}</text>
</view>
<!-- 右侧: 加号 -->
<button class="top-plus-btn voice-side-right" @click="showMoreTools">
<text class="plus-icon">+</text>
</button>
</view>
<!-- 按住时的「上滑取消」提示 (H5/MP 共用) -->
<view v-if="voiceHolding" class="voice-cancel-hint">{{ voiceHoldCancelling ? $lang('ai-chat.index.slideUpToCancelHint') : $lang('ai-chat.index.slideUpToCancel') }}</view>
@@ -548,7 +560,7 @@
Vue 2 要求 v-if/v-else 紧邻, 否则 v-else 会错配到上一个 v-if (voiceHolding),
导致 voiceMode=true 时 input-row-top 也被显示.
改成显式 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 复用
同标签 view 导致 textarea 被销毁重建、聚焦即失焦(塌回胶囊无法输入) -->
@@ -587,14 +599,10 @@
</view>
</view>
<!-- 底部工具栏行: 语音模式 / 文字输入时显示 -->
<view v-if="voiceMode || showTextMode" class="input-toolbar-row">
<!-- 语音模式: 键盘图标 (点击回到文字输入) -->
<view v-if="voiceMode" class="toolbar-round-btn" @click="exitVoiceMode">
<view class="emoji-icon-bg"></view>
</view>
<!-- 底部工具栏行: 仅文字输入时显示 (语音模式的键盘/加号已并入按住说话同一行) -->
<view v-if="showTextMode" class="input-toolbar-row">
<!-- 文字输入: 语音图标 (切到按住说话) -->
<view v-else class="toolbar-round-btn" @click="enterVoiceMode">
<view class="toolbar-round-btn" @click="enterVoiceMode">
<view class="voice-icon-bg"></view>
</view>
@@ -2964,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(染色/排版), 终端此前无条件转义所有 < >, 商家配置的
// <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/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 href / 数字。
@@ -3028,6 +3095,9 @@ _handleStreamError(msg) {
return `%%CODEBLOCK_${idx}%%`
})
// 2.5 还原白名单内 HTML (放在代码块抽离之后: 代码块里的示例 HTML 保持字面文本)
html = wcRestoreHtml(html)
// 3. 按行处理,用 <br> 连接单换行,双换行分割段落块
const lines = html.split('\n')
const out = []
@@ -3928,17 +3998,30 @@ _handleStreamError(msg) {
},
// AI 消息渲染(含关键词高亮)
getAIDisplayContent(content) {
// isWelcome: 欢迎语消息 — 小程序端 mp-html 组件有样式隔离, 页面样式(::v-deep)进不去,
// 图片间距只能在内容层面改内联样式(机器人图片与下方标题的间距, 对齐 WorkBuddy 参考稿)
getAIDisplayContent(content, isWelcome) {
let html
if (!this.searchKeyword || !content) {
return this.parseMarkdown(content)
html = this.parseMarkdown(content)
} else if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
html = this.parseMarkdown(content)
} else {
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
html = this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
}
// 仅当内容包含关键词时才执行高亮,避免非匹配消息的额外处理
if (content.toLowerCase().indexOf(this.searchKeyword.toLowerCase()) === -1) {
return this.parseMarkdown(content)
if (isWelcome && html) {
// 仅替换 img 的 margin(机器人图片), 不影响列表等其他元素的 margin:8rpx 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')
}
// 先做 markdown 渲染,再做关键词高亮
const mdHtml = this.parseMarkdown(content)
return this._wrapHighlightInHtml(mdHtml, this.searchKeyword)
return html
},
// 在纯文本中高亮关键词(用于用户消息)
@@ -4130,6 +4213,8 @@ _handleStreamError(msg) {
this.voiceHolding = false
this.voiceHoldCancelling = false
this.inputFocused = false
// 进入语音模式时清掉文字输入态, 避免白卡片背景 + 工具栏残留(切换时闪烁)
this.showTextMode = false
},
// 退出语音模式, 回到文字输入态
@@ -4780,11 +4865,20 @@ _handleStreamError(msg) {
background: #ffffff !important;
}
// 欢迎语消息: 图片间距由内容内联样式控制(getAIDisplayContent 里改成 0 0 8rpx 0),
// 这里不再用 CSS 强制 48rpx —— 样式表 !important 会覆盖内联样式, 导致 H5 上调距无效
// 欢迎语消息: 隐藏上方时间戳
&.is-welcome .ai-message .message-nickname.ai-nickname {
display: none;
}
// 欢迎语消息: 标题与快捷问题的间距, 与"图片→标题"保持一致 (48rpx)
// 下方 5326 行的 .quick-questions-row 嵌套链更长、特异性更高, 必须 !important 覆盖
&.is-welcome .quick-questions-row {
margin-top: 48rpx !important;
}
// 搜索定位 - 当前结果: 用柔和的浅绿底色提示 (WorkBuddy 风格, 无气泡 box-shadow)
&.search-current {
position: relative;
@@ -5323,7 +5417,7 @@ _handleStreamError(msg) {
margin-bottom: 16rpx;
padding: 12rpx 28rpx;
background: #ffffff;
border: 1rpx solid #e4e6eb ;
border: 1rpx solid #eef0f3 ;
border-radius: 999rpx;
font-size: 26rpx;
color: #1a1a1a;
@@ -5331,7 +5425,7 @@ _handleStreamError(msg) {
transition: all 0.2s ease;
max-width: 100%;
line-height: 1.4;
box-shadow: 0 4 rpx 1 2rpx rgba(0, 0, 0, 0.08 );
box-shadow: 0 6 rpx 20 rpx rgba(0, 0, 0, 0.14 );
&:last-child {
margin-bottom: 0;
@@ -5753,6 +5847,7 @@ _handleStreamError(msg) {
height: 56rpx;
padding: 0 20rpx;
gap: 8rpx;
margin-top: 20rpx;
.refresh-icon {
width: 28rpx;
@@ -5781,10 +5876,10 @@ _handleStreamError(msg) {
}
}
/ * 键盘垫高层 : 高度变化时平滑 过渡 * /
/ * 键盘垫高层 : 高度变化不做 过渡 — — 切换语音 / 文字时 hideKeyboard 触发高度变化 ,
若加 transition 会播放高度动画 , 输入区整体跳动 , 视觉上就是 「 频闪 / 看到上次样式 」 * /
.keyboard -spacer {
flex -shrink : 0 ;
transition : height 0.25s ease ;
}
/ * 输入区域 ( 新版 : 顶部胶囊形输入条 + 底部 5 个图标均匀分布 ) * /
@@ -5810,9 +5905,15 @@ _handleStreamError(msg) {
justify -content : space -between ;
background : # ffffff ;
border -radius : 999rpx ;
padding : 18 rpx 14rpx 18rpx 32rpx ;
min -height : 108rpx ;
box -shadow : 0 2rpx 12rpx rgba ( 0 , 0 , 0 , 0.06 ) ;
/ * border -box : 让 min -height 把上下 padding ( 10 rpx * 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 ;
min -height : 96rpx ;
/ * 与快捷问题按钮 ( .quick -question -chip ) 同款阴影 , 白底更突出 * /
box -shadow : 0 6rpx 20rpx rgba ( 0 , 0 , 0 , 0.14 ) ;
/ * 无文字时左侧语音图标 , 点击打开语音面板
PR -INPUT -LAYOUT -SYMMETRY ( 2026 -09 -28 ) : 容器从 52rpx 扩大到 76rpx ,
@@ -5944,17 +6045,24 @@ _handleStreamError(msg) {
background -size : contain ;
}
/ * 展开态 : 卡片变白底圆角容器 , 内部输入行不再单独成胶囊 * /
/ * 展开态 : 文字输入时 卡片变白底圆角容器;
语音模式卡片保持透明 , 白色胶囊由 .voice -mode -row 自身呈现 ( 避免双层白底 ) * /
.input -card {
& .is -voice -mode ,
& .is -text -mode {
background : # ffffff ;
border -radius : 28rpx ;
padding : 14rpx 16rpx 6rpx ;
box -shadow : 0 2rpx 12rpx rgba ( 0 , 0 , 0 , 0.06 ) ;
/ * 与快捷问题按钮 ( .quick -question -chip ) 同款阴影 * /
box -shadow : 0 6rpx 20rpx rgba ( 0 , 0 , 0 , 0.14 ) ;
}
& .is -voice -mode {
background : transparent ;
border -radius : 0 ;
padding : 0 ;
box -shadow : none ;
}
& .is -voice -mode .input -row -top ,
& .is -text -mode .input -row -top {
background : transparent ;
border -radius : 0 ;
@@ -5964,30 +6072,65 @@ _handleStreamError(msg) {
}
}
/ * 语音模式 : 按住说话 * /
.voice -hold -btn {
/ * 语音/ 文字两态胶囊始终在 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 ;
justify -content : center ;
/ * 与 .input -row -top 一致用 border -box , 保证两者 96rpx 计算口径相同 , 高度一致 * /
box -sizing : border -box ;
height : 96rpx ;
margin : 4rpx 0 8rpx ;
border -radius : 16 rpx ;
background : # f2f2f3 ;
position : relative ; / / PR -FRONTEND -VOICE -INPUT : 让 .voice -cancel -hint 锚定到这里
/ * 上下 margin 为 0 : 与默认输入胶囊 ( .input -row -top margin 0 ) 整块高度一致 ,
否则语音态多占 12rpx , 切换时聊天列表被顶 / 拉 12 rpx 产生 " 看到上次样式 " 的频闪 * /
margin : 0 ;
padding : 0 10rpx 0 24rpx ;
border -radius : 999rpx ;
background : # ffffff ;
box -shadow : 0 6rpx 20rpx rgba ( 0 , 0 , 0 , 0.14 ) ;
transition : background -color 0.15s ease -out ;
& .is -holding {
background : # e2e2e4 ;
background : # f2f2f3 ;
}
/ / PR -FRONTEND -VOICE -INPUT ( 2026 -09 -24 ) : 上滑到 80px + 视为取消手势
& .is -cancelling {
background : # fdecec ;
.voice -hold -text {
color : # d23f3f ;
}
}
.voice -side -btn {
flex -shrink : 0 ;
display : flex ;
align -items : center ;
justify -content : center ;
}
.voice -side -right {
margin -left : 8rpx ;
}
/ * 按住说话区域 : 透明 , 占据中间剩余全部空间 * /
.voice -hold -btn {
flex : 1 ;
height : 100 % ;
min -width : 0 ;
display : flex ;
align -items : center ;
justify -content : center ;
}
.voice -hold -text {
font -size : 30rpx ;
color : # 1f1f1f ;
@@ -6478,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;