Compare commits

...

12 Commits

2 changed files with 208 additions and 51 deletions

View File

@@ -257,7 +257,11 @@ const localDevConfig = ({
// })['2811']; // 本地
// })['5g-317']; // 电磁兼容
// })['5g-351']; //
})['5g-97']; // 本地
})['2881']; // 本地
// })['local-2']; // 本地
// })['local-11']; // 本地
// })['1-test']; // test

View File

@@ -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="voiceMode"
class="voice-hold-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-mode-row"
:class="{ 'is-holding': voiceHolding, '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) => {
// 转义后标签形如 &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/电话"整体抽成占位符保护起来, 再做加粗/斜体等,
// 最后还原, 避免各规则互相串改对方的 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 4rpx 12rpx rgba(0, 0, 0, 0.08);
box-shadow: 0 6rpx 20rpx 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: 18rpx 14rpx 18rpx 32rpx;
min-height: 108rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
/* 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;
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: 16rpx;
background: #f2f2f3;
position: relative; // PR-FRONTEND-VOICE-INPUT: 让 .voice-cancel-hint 锚定到这里
/* 上下 margin 为 0: 与默认输入胶囊(.input-row-top margin 0)整块高度一致,
否则语音态多占 12rpx, 切换时聊天列表被顶/拉 12rpx 产生"看到上次样式"的频闪 */
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;