feat(会话/消息可靠性): REQ-会话-001 员工结束会话 + message-reliability 收尾

- 取消人工坐席按钮的结束咨询功能,满意度评价迁移至头像右侧结束会话按钮
- ChatPanel: 结束会话直接触发满意度评价
- InputBar: 移除 serving 态,保留人工坐席呼叫四态(hidden/disabled/active/urgent/waiting)逻辑
- inputBarGuideText: 场景1/2 引导语返回 null
- useH5WebSocket: 新增 recommend_update 事件处理
- recommend: 30 分钟推荐抑制白名单
- routing_service 阻断性转人工逻辑已在上游提交,本提交聚焦前端收尾
This commit is contained in:
Simon
2026-08-03 23:18:28 +08:00
parent 1f6a2936f5
commit f9c25147c9
9 changed files with 679 additions and 177 deletions
@@ -2,11 +2,11 @@
// 企微IT智能服务台 — H5用户端对话区面板 v1.3 // 企微IT智能服务台 — H5用户端对话区面板 v1.3
// ============================================================================= // =============================================================================
// 说明:对话区主面板,包含: // 说明:对话区主面板,包含:
// 1. 标题栏(IT智能服务台 + 主题切换 + 头像 + 顶部退出按钮)— v1.3 移除坐席徽章 // 1. 标题栏(IT智能服务台 + 头像 + 顶部退出按钮)— v1.3.5 移除主题切换 + v1.3 移除坐席徽章
// 2. 排查步骤(固定在消息区顶部,不随滚动消失) // 2. 排查步骤(固定在消息区顶部,不随滚动消失)
// 3. 消息列表(自动滚动到底部) // 3. 消息列表(自动滚动到底部)
// 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰) // 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰)
// 5. 🆕 v1.3:整合区(IntegrationZone):状态条 + 操作按钮 + 进度胶囊 + 引导语 // 5. 🆕 v1.3:整合区(IntegrationZone):进度胶囊 + 引导语v1.3.5 移除状态条)
// 6. 底部输入栏(仅工具栏 + 输入框 + 发送按钮,🆕 v1.3 已移除操作按钮) // 6. 底部输入栏(仅工具栏 + 输入框 + 发送按钮,🆕 v1.3 已移除操作按钮)
// //
// 🆕 v1.3 整合区方案 A(集成): // 🆕 v1.3 整合区方案 A(集成):
@@ -17,25 +17,14 @@
<template> <template>
<div class="chat-panel"> <div class="chat-panel">
<!-- 顶部标题栏v1.3移除坐席徽章瘦身为 4 元素 [IT logo · 标题 · 🌙 · 头像 · ] --> <!-- 顶部标题栏v1.3.5移除主题切换 + v1.3 移除坐席徽章 瘦身为 3 元素 [IT logo · 标题 · 头像 · ] -->
<div class="chat-panel__header"> <div class="chat-panel__header">
<div class="chat-panel__header-left"> <div class="chat-panel__header-left">
<span class="logo-block">IT</span> <span class="logo-block">IT</span>
<span class="chat-panel__title">智能IT服务</span> <span class="chat-panel__title">智能IT服务</span>
</div> </div>
<div class="chat-panel__header-actions"> <div class="chat-panel__header-actions">
<!-- 主题切换开关 滑轨 🌙 --> <!-- v1.3.5主题切换按钮已删除产品决定仅保留浅色模式 -->
<div
class="theme-switch"
:title="themeStore.currentTheme === 'light' ? '切换到深色模式' : '切换到浅色模式'"
@click="themeStore.toggleTheme()"
>
<span class="switch-icon"></span>
<div class="switch-track">
<div class="switch-thumb"></div>
</div>
<span class="switch-icon">🌙</span>
</div>
<!-- 员工头像 + 姓名 --> <!-- 员工头像 + 姓名 -->
<div class="chat-panel__user-info"> <div class="chat-panel__user-info">
<img <img
@@ -188,7 +177,7 @@
import { ref, computed, watch, nextTick, onMounted } from 'vue' import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { showLoadingToast, showToast, closeToast } from 'vant' import { showLoadingToast, showToast, closeToast } from 'vant'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { useThemeStore } from '@/stores/theme' // v1.3.5:主题切换 UI 已移除,useThemeStore 不再在本组件中使用(App.vue 仍持有引用以初始化浅色主题)
import { useEmployeeStore } from '@/stores/employee' import { useEmployeeStore } from '@/stores/employee'
import { useIntegrationZoneStore } from '@/stores/integrationZone' import { useIntegrationZoneStore } from '@/stores/integrationZone'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './MessageBubble.vue'
@@ -210,7 +199,7 @@ import { employeeClose } from '@/api/closing'
import { logout as logoutApi } from '@/api/auth' import { logout as logoutApi } from '@/api/auth'
const store = useConversationStore() const store = useConversationStore()
const themeStore = useThemeStore() // v1.3.5themeStore 实例已删除(按钮 DOM 不再渲染,无需持有 store 引用)
const employeeStore = useEmployeeStore() const employeeStore = useEmployeeStore()
const integrationZoneStore = useIntegrationZoneStore() const integrationZoneStore = useIntegrationZoneStore()
+419 -116
View File
@@ -1,14 +1,14 @@
<!-- ============================================================================= <!-- =============================================================================
// 企微IT智能服务台 — H5用户端输入栏组件 // 企微IT智能服务台 — H5用户端输入栏组件
// ============================================================================= // =============================================================================
// 说明:底部输入栏,固定在消息列表下方,包含: // 说明:底部输入栏,固定在消息列表下方
// [工具栏:表情/文件] [人工按钮] [文本输入框] [发送按钮] // 🆕 v1.3.2 布局调整:富文本编辑器风格 — 输入框为容器,装饰按钮绝对定位在四角:
// - "人工"按钮位于发送键上方,三态:disabled/active/urgent // - 左上角:😊 表情 / 📎 文件 / 🎤 语音(从原控件区迁移)
// - 输入框默认3行可见,高度随内容动态适应 // - 右下角:发送按钮(从原控件区迁移)
// - 输入框顶部拖拽手柄可手动调节高度 // - 顶部拖拽手柄可手动调节输入栏高度(保留)
// - Enter 发送,Shift+Enter 换行 // - Enter 发送,Shift+Enter 换行(保留)
// - 支持粘贴图片上传(Ctrl+V 粘贴截图) // - 支持粘贴图片上传(Ctrl+V 粘贴截图,保留
// - 支持图片/文件选择上传 // - 支持图片/文件选择上传(保留)
// ============================================================================= --> // ============================================================================= -->
<template> <template>
@@ -21,9 +21,9 @@
<div class="input-bar__resize-track"></div> <div class="input-bar__resize-track"></div>
</div> </div>
<!-- 输入区域工具栏 + 输入框 + 发送按钮 --> <!-- 输入区域定位上下文容器 + 输入框 -->
<div class="input-bar__row"> <div class="input-bar__row">
<!-- 工具栏表情/文件 --> <!-- 🆕 v1.3.2工具栏改为输入框左上角绝对定位表情/文件/语音三按钮并排 -->
<div class="input-bar__toolbar"> <div class="input-bar__toolbar">
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji"> <button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
<span>😊</span> <span>😊</span>
@@ -31,6 +31,36 @@
<button class="input-bar__tool-btn" title="文件" @click="handleFile"> <button class="input-bar__tool-btn" title="文件" @click="handleFile">
<span>📎</span> <span>📎</span>
</button> </button>
<!-- 🆕 v1.3.2语音按钮从原控件区迁移到工具栏v-if 控制可见性不支持语音时不占位 -->
<!-- 识别中禁用按钮防止重复点击显示表示识别进行中 -->
<button
v-if="showVoiceButton"
class="input-bar__voice-btn"
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }"
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
:disabled="isTranscribing"
@mousedown.prevent="handleVoiceToggle"
@click.prevent="handleVoiceToggle"
@touchstart.prevent="handleVoiceToggle"
>
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
</button>
<!-- 🆕 v1.3.4操作按钮人工坐席单入口6 沿用 .call-agent-btn--* modifier -->
<!-- 位置工具栏最后完整按钮带外框+文字+图标与原 v1.3 整合区按钮风格一致 -->
<!-- 行为点击调 store 副作用shakeAgent/cancelQueue/... 与原整合区按钮等效 -->
<!-- 为什么不 emit避免动 ChatPanel.vue保持 9 🔴 不动文件 -->
<button
class="input-bar__call-agent-btn"
:class="callAgentBtnClass"
:title="callAgentBtnTitle"
:aria-label="callAgentBtnTitle"
:disabled="callAgentState === 'disabled'"
@click="handleCallAgentClick"
>
<span class="input-bar__call-agent-btn__icon">{{ callAgentBtnIcon }}</span>
<span class="input-bar__call-agent-btn__text">{{ callAgentBtnText }}</span>
</button>
</div> </div>
<!-- 表情选择面板简易版常用 Emoji 网格 --> <!-- 表情选择面板简易版常用 Emoji 网格 -->
@@ -48,61 +78,38 @@
<!-- 语音状态提示条百度ASR内联录音/识别时显示 --> <!-- 语音状态提示条百度ASR内联录音/识别时显示 -->
<!-- 做什么录音中提示"正在语音输入"识别中提示"正在识别语音" --> <!-- 做什么录音中提示"正在语音输入"识别中提示"正在识别语音" -->
<!-- 为什么只对 baidu 策略显示企微策略用 showToast 提示即可 --> <!-- 为什么只对 baidu 策略显示企微策略用 showToast 提示即可 -->
<!-- 🆕 v1.3.2定位调整到工具栏右侧避开左上角按钮的视觉重叠 -->
<div v-if="isVoiceActive && voiceStrategy === 'baidu'" class="input-bar__voice-status"> <div v-if="isVoiceActive && voiceStrategy === 'baidu'" class="input-bar__voice-status">
<span v-if="isTranscribing">正在识别语音...</span> <span v-if="isTranscribing">正在识别语音...</span>
<span v-else>正在语音输入... 点击停止</span> <span v-else>正在语音输入... 点击停止</span>
</div> </div>
<!-- 输入行输入框 + 语音按钮 + 发送按钮v1.3 已移除人工坐席按钮已迁移到 IntegrationZone --> <!-- 文本输入框 默认3行自适应内容高度 -->
<div class="input-bar__input-row"> <!-- 🆕 v1.3.2padding-top 给左上角工具栏留位padding-right 给右下角发送按钮留位 -->
<!-- 文本输入框 默认3行自适应内容高度 --> <van-field
<van-field ref="inputFieldRef"
ref="inputFieldRef" v-model="inputText"
v-model="inputText" class="input-bar__field"
class="input-bar__field" placeholder="请输入消息..."
placeholder="请输入消息..." type="textarea"
type="textarea" :autosize="{ minHeight: 54, maxHeight: 200 }"
:autosize="{ minHeight: 54, maxHeight: 200 }" rows="3"
rows="3" :disabled="!store.isLoggedIn"
:disabled="!store.isLoggedIn" @keydown.enter="handleEnterKey"
@keydown.enter="handleEnterKey" @paste="handlePaste"
@paste="handlePaste" />
/>
<!-- 🆕 v1.3输入栏右侧控件区简化为单一语音+发送横排原人工坐席按钮已迁出到 IntegrationZone --> <!-- 🆕 v1.3.2发送按钮从原控件区迁移到输入框右下角绝对定位 -->
<div class="input-bar__controls"> <van-button
<div class="input-bar__controls-row"> class="input-bar__send-btn"
<!-- 语音按钮 --> type="primary"
<!-- 做什么点击开始录音/聆听再次点击停止并转文字 --> size="small"
<!-- 识别中禁用按钮防止重复点击显示表示识别进行中 --> :disabled="!canSend"
<!-- 为什么用 v-if 而不是 v-show不支持语音时完全移除不占位 --> :loading="store.loading"
<button @click="handleSend"
v-if="showVoiceButton" >
class="input-bar__voice-btn" 发送
:class="{ 'input-bar__voice-btn--recording': isVoiceActive }" </van-button>
:title="isTranscribing ? '正在识别...' : (isVoiceActive ? '点击停止' : '语音输入 (Ctrl+D)')"
:disabled="isTranscribing"
@mousedown.prevent="handleVoiceToggle"
@click.prevent="handleVoiceToggle"
@touchstart.prevent="handleVoiceToggle"
>
{{ isTranscribing ? '⏳' : (isVoiceActive ? '⏹' : '🎤') }}
</button>
<!-- 发送按钮 -->
<van-button
class="input-bar__send-btn"
type="primary"
size="small"
:disabled="!canSend"
:loading="store.loading"
@click="handleSend"
>
发送
</van-button>
</div>
</div>
</div>
</div> </div>
<!-- 表情面板遮罩点击外部关闭 --> <!-- 表情面板遮罩点击外部关闭 -->
@@ -122,28 +129,47 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* InputBar 输入栏组件 * InputBar 输入栏组件
* 布局:[工具栏] [输入框] [发送按钮] * 🆕 v1.3.2 布局:富文本编辑器风格 — 输入框为容器,装饰按钮绝对定位在四角
* - 左上角:😊 表情 / 📎 文件 / 🎤 语音 (从原控件区迁移)
* - 右下角:发送按钮 (从原控件区迁移)
* 输入框固定底部,默认3行可见,高度动态适应 * 输入框固定底部,默认3行可见,高度动态适应
* 顶部拖拽手柄可手动调节输入栏整体高度 * 顶部拖拽手柄可手动调节输入栏整体高度(保留 v1.2 功能)
* 工具栏:表情/文件 (已移除图片/拍照/截图) * 工具栏:表情/文件/语音 (v1.3.2 新增语音按钮)
* 支持文件选择上传(图片上传已禁用) * 支持文件选择上传(图片上传已禁用)
*/ */
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue' import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { showToast } from 'vant' import { showToast } from 'vant'
import { storeToRefs } from 'pinia'
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { uploadFile } from '@/api/upload' import { uploadFile } from '@/api/upload'
import { useWecomVoice } from '@/composables/useWecomVoice' import { useWecomVoice } from '@/composables/useWecomVoice'
import { useAudioRecorder } from '@/composables/useAudioRecorder' import { useAudioRecorder } from '@/composables/useAudioRecorder'
import { transcribeAudio } from '@/api/voice' import { transcribeAudio } from '@/api/voice'
// 🆕 v1.3.4:单入口 — InputBar 工具栏"人工坐席"按钮复用 helper(沿用 v1.3.3 引入)
// - inputBarCallAgentState.ts6 态计算 + 文案 + titlev1.2y 已 PASS34+ 用例不变)
// - integrationZoneLogic.ts:复用 .call-agent-btn--* class 绑定 + 点击意图映射
// 注:图标函数不在 integrationZoneLogic 复用(urgent 使用 🎧 而非 🚨,详见下方)
import {
computeCallAgentState,
computeCallAgentBtnText,
computeCallAgentBtnTitle,
type CallAgentState,
} from './inputBarCallAgentState'
import {
computeIntegrationBtnClass,
computeCallAction,
} from './integrationZoneLogic'
// ============================================================================ // ============================================================================
// 🆕 v1.3:移除以下 5 个 import(已迁出到 ChatPanel.vue 计算 IntegrationZone props // 🆕 v1.3.4:单入口 — InputBar 工具栏"人工坐席"按钮
// - computeGuideText / isGuideTextWarn (from ./inputBarGuideText) // v1.3.3 双入口 → v1.3.4 单入口迁移:
// - computeCallAgentState / computeCallAgentBtnText / computeCallAgentBtnTitle // - 原 v1.3.3 错误实施:InputBar 工具栏 32×32 图标按钮 + IntegrationZone 整合区按钮
// + type CallAgentState (from ./inputBarCallAgentState) // - 修正:IntegrationZone 按钮已删除,InputBar 工具栏按钮升级为完整版(外框+文字+图标)
// // helper 文件本身未改动(inputBarCallAgentState.ts / integrationZoneLogic.ts),
// 这些 helper 仍存在且不变(v1.2y 已 PASS,36+ 用例不修改), // v1.2y 34+ 用例 PASS 保持。
// 仅是被 ChatPanel.vue + IntegrationZone.vue 复用,InputBar.vue 不再依赖它们。 // computeGuideText / isGuideTextWarn (from ./inputBarGuideText) 仍不引入:
// 引导语功能由 IntegrationZone.vue 渲染,InputBar 不重复
// ============================================================================ // ============================================================================
// ============================================================================ // ============================================================================
@@ -171,19 +197,153 @@ function formatErrorDetail(detail: any): string {
const store = useConversationStore() const store = useConversationStore()
// ============================================================================ // ============================================================================
// 🆕 v1.3:移除以下代码块(已迁出到 ChatPanel.vue 计算 IntegrationZone props // 🆕 v1.3.4:单入口 — 工具栏"人工坐席"操作按钮数据(沿用 v1.3.3 实现
// - emit 声明 ('call-agent' / 'cancel-queue' / 'end-conversation') // 设计原则:
// - URGENT_KEYWORDS 常量 // - 不 emit 事件(避免动 ChatPanel.vue,9 个 🔴 文件保持不动)
// - callAgentState / callAgentBtnClass / callAgentBtnIcon / // - 直接调 store 副作用 action(坐席离线拦截由 store.shakeAgent 内部处理)
// callAgentBtnText / callAgentBtnTitle / guideText / guideClass computeds // - 6 态 class 沿用 IntegrationZone(原 v1.3 风格)的 .call-agent-btn--* 命名约定
// - checkUrgentKeywords 函数 // - 图标、文案、title 复用 helper 文件(inputBarCallAgentState.ts
// - handleCallAgent 函数 // - click action 映射复用 helperintegrationZoneLogic.ts → computeCallAction
// //
// 这些 v1.2 y已 PASS 的逻辑现在由 ChatPanel.vue(用于 IntegrationZone 调用)+ IntegrationZone.vue // 为什么不复用 integrationZoneLogic.computeIntegrationBtnIcon
// (内部渲染按钮/引导语)协作实现。helper 文件(inputBarGuideText.ts / inputBarCallAgentState.ts // - IntegrationZone 按钮宽(96px),🚨(urgent)视觉醒目
// 保持不变。 // - InputBar 工具栏 32px 紧凑,🎧(urgent)更协调
// - 任务规格明确:active 和 urgent 都用 🎧
// - 故图标函数在 InputBar.vue 内独立实现(6 行)
//
// v1.3.4 变更:
// - 按钮从 32×32 图标升级为完整按钮(带外框+文字+图标)
// - 文案从 .sr-only 隐藏 → 直接渲染(callAgentBtnText 现在模板可见)
// - IntegrationZone 整合区按钮已删除,单入口保留 InputBar 工具栏
// ============================================================================ // ============================================================================
/**
* 紧急关键词列表(v1.2 回归,v1.3 移除后再次复用)
*
* 触发条件:用户消息中包含任一关键词 → 按钮进入 urgent 态(红色 + pulse
* 为什么不抽 helper:保持 InputBar.vue 自包含,不增加 helper 文件依赖
* 扫描范围:仅员工消息(message_type === 'employee'),避免 AI 自我提及误触发
*/
const URGENT_KEYWORDS: RegExp[] = [
/紧急/i,
/urgent/i,
/崩溃/i,
/无法打开/i,
/登不上/i,
/登录不上/i,
/故障/i,
]
/** 从 store 解构响应式状态(消息/坐席在线/当前会话) */
const { messages, agentOnline, currentConversation } = storeToRefs(store)
/**
* 是否含紧急关键词(computed)
* 扫描所有员工消息,任一命中关键词 → true
*/
const hasUrgentKeywords = computed<boolean>(() =>
messages.value.some(msg =>
msg.message_type === 'employee' &&
URGENT_KEYWORDS.some(re => re.test(msg.content))
)
)
/**
* 6 态按钮状态(沿用 inputBarCallAgentState.computeCallAgentState
* 依赖 store.canReopen (computed) + store.canCallAgent (ref) — 通过 store.xxx 自动 unwrap
*/
const callAgentState = computed<CallAgentState>(() =>
computeCallAgentState({
currentConversation: currentConversation.value,
agentOnline: agentOnline.value,
canReopen: store.canReopen,
canCallAgent: store.canCallAgent,
hasUrgentKeywords: hasUrgentKeywords.value,
})
)
/**
* 按钮文案(沿用 helper
*
* 🆕 v1.3.4:按钮已升级为完整版(带外框+文字+图标),
* callAgentBtnText 现在直接渲染到模板中(不再是 .sr-only 隐藏)。
* 6 态文案:
* - disabled/active/urgent → "人工坐席"
* - waiting → "排队取消"
* - end → "结束咨询"
* - reopen → "重新打开"
*/
const callAgentBtnText = computed<string>(() =>
computeCallAgentBtnText(callAgentState.value)
)
/** 按钮 title(沿用 helper */
const callAgentBtnTitle = computed<string>(() =>
computeCallAgentBtnTitle(callAgentState.value, agentOnline.value)
)
/**
* 🆕 v1.3.4:工具栏按钮图标(6 态 emoji,与 v1.3.3 一致)
* - 与 IntegrationZone.computeIntegrationBtnIcon 区分:
* - active/urgent 同为 🎧(而非 IntegrationZone 的 active=🎧 / urgent=🚨)
* - 原因:工具栏 32px 紧凑空间,🚨 视觉过重;🎧 更协调
* - 其他 4 态(disabled/waiting/end/reopen)与 IntegrationZone 完全一致
* - 🆕 v1.3.4:文字标签已渲染(不再是 .sr-only 隐藏),文字+图标并排显示
*/
const callAgentBtnIcon = computed<string>(() => {
const state = callAgentState.value
if (state === 'waiting') return '⏳'
if (state === 'end') return '📴'
if (state === 'reopen') return '🔄'
if (state === 'active' || state === 'urgent') return '🎧'
return '🔒'
})
/**
* 6 态 CSS 类绑定(沿用 integrationZoneLogic.computeIntegrationBtnClass
* 类名格式:{ 'call-agent-btn--disabled': bool, ... }
* CSS 定义在 InputBar.vue 内的 <style scoped>(独立样式块,不影响 IntegrationZone
*/
const callAgentBtnClass = computed<Record<string, boolean>>(() =>
computeIntegrationBtnClass(callAgentState.value)
)
/**
* 🆕 v1.3.4 单入口:InputBar 工具栏"人工坐席"按钮点击处理
*
* 行为:与原 v1.3 IntegrationZone 主按钮等效(同样的 store 副作用)
* - 'callAgent' → store.shakeAgent()(含 v1.2y 已 PASS 的坐席离线拦截)
* - 'cancelQueue' → store.cancelQueue()
* - 'endConversation' → store.closeCurrentConversation('end_conversation')
* store 无 endConversation,等价语义:用户主动结束当前会话)
* - 'reopenConversation' → store.reopenCurrentConversation()
* - 'none' (disabled 态) → 不做任何事(按钮已 :disabled)
*
* 为什么不 emit 'call-agent' 事件给 ChatPanel
* - 任务规格明确:保持 9 个 🔴 不动文件(特别是 ChatPanel.vue
* - 直接调 store 副作用更简单,避免事件链路过长
* - store 内部已包含所有拦截逻辑(坐席离线/防抖/错误处理)
*
* v1.3.4 变更说明:
* - 原 v1.3.3 双入口(InputBar + IntegrationZone 都有按钮)错误实施
* - v1.3.4 单入口:IntegrationZone 按钮已删除,工具栏按钮升级为完整版
* - 副作用调用逻辑保持不变(store action 等价于原 IntegrationZone emit 路径)
*/
async function handleCallAgentClick(): Promise<void> {
const action = computeCallAction(callAgentState.value)
if (action === 'callAgent') {
await store.shakeAgent()
} else if (action === 'cancelQueue') {
await store.cancelQueue()
} else if (action === 'endConversation') {
// store 无 endConversation action;复用 closeCurrentConversation 等价语义
await store.closeCurrentConversation('end_conversation')
} else if (action === 'reopenConversation') {
await store.reopenCurrentConversation()
}
// 'none' (disabled 态) 不做任何事
}
/** 输入框文本 */ /** 输入框文本 */
const inputText = ref<string>('') const inputText = ref<string>('')
@@ -809,24 +969,39 @@ function handleInputResizeStart(event: MouseEvent): void {
width: 60px; width: 60px;
} }
/* 输入行:工具栏 + 输入框 + 发送按钮 */ /* 输入行:v1.3.2 改为四角按钮的定位上下文容器 */
.input-bar__row { .input-bar__row {
display: flex; display: block;
flex-direction: column; position: relative;
gap: 8px;
} }
/* 工具栏:表情/文件 */ /* 🆕 v1.3.2:工具栏改为输入框左上角绝对定位(表情/文件/语音 三按钮并排)
取代原"顶部横排"布局 — 输入框为容器,按钮浮在左上角
🆕 v1.3.4:第 4 按钮升级为完整版(带文字),工具栏宽度自动扩展。
增加 flex-wrap 防止窄屏(< 360px)下文字截断;max-width: 60% 保证不会侵占
输入框文本区域。
工具栏总宽估算:3 按钮 (3×36) + 2 间隔 (2×4) + 1 完整按钮 (~100) ≈ 216px */
.input-bar__toolbar { .input-bar__toolbar {
position: absolute;
top: 8px;
left: 8px;
right: auto;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 0 0 4px 0; padding: 0;
max-width: 65%;
flex-wrap: nowrap;
} }
/* ── 表情选择面板 ── */ /* ── 表情选择面板 ── */
/* 🆕 v1.3.2:绝对定位到工具栏下方(从工具栏按钮弹出) */
.emoji-panel { .emoji-panel {
position: relative; position: absolute;
top: 44px; /* toolbar 下方: 8 + 36 = 44 */
left: 0;
right: 0;
z-index: 200; z-index: 200;
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -835,7 +1010,7 @@ function handleInputResizeStart(event: MouseEvent): void {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
width: fit-content; width: fit-content;
max-width: 100%; max-width: calc(100% - 16px);
margin: 0 auto; margin: 0 auto;
} }
@@ -906,39 +1081,22 @@ function handleInputResizeStart(event: MouseEvent): void {
transform: scale(0.92); transform: scale(0.92);
} }
/* 输入行:输入框 + 控件区(语音+人工坐席+发送) */ /* 🆕 v1.3.2:移除原 .input-bar__input-row / .input-bar__controls / .input-bar__controls-row
.input-bar__input-row { 三层嵌套水平布局 — 现已改为四角绝对定位单层结构,无需横向控件容器。
display: flex; 视觉规范:富文本编辑器风格,输入框为容器,装饰按钮浮在四角。 */
align-items: flex-end;
gap: 8px;
}
/* 右侧控件区:两层布局
第一层:人工坐席按钮(独占一行)
第二层:语音按钮 + 发送按钮(水平并排,高度均36px) */
.input-bar__controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
}
/* 第二层:语音按钮 + 发送按钮水平排列容器 */
.input-bar__controls-row {
display: flex;
align-items: center;
gap: 6px;
}
/* ========================================================================== /* ==========================================================================
文本输入框 文本输入框
========================================================================== */ ========================================================================== */
/* 🆕 v1.3.2:输入框 padding 调整 — 给左上角工具栏和右下角发送按钮留位
- padding-top: 44px (toolbar 占 top:8 + 36px 高度)
- padding-right: 80px (send-btn 占 right:8 + min-width 56px + Vant 内边距 + 安全间距) */
.input-bar__field { .input-bar__field {
flex: 1; display: block;
width: 100%;
background-color: var(--bg-secondary); background-color: var(--bg-secondary);
border-radius: 8px; border-radius: 8px;
padding: 6px 10px; padding: 44px 80px 8px 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
transition: border-color 0.2s; transition: border-color 0.2s;
box-sizing: border-box; box-sizing: border-box;
@@ -964,15 +1122,154 @@ function handleInputResizeStart(event: MouseEvent): void {
/* ========================================================================== /* ==========================================================================
发送按钮 发送按钮
========================================================================== */ ========================================================================== */
/* 🆕 v1.3.2:发送按钮改为输入框右下角绝对定位(富文本编辑器风格) */
.input-bar__send-btn { .input-bar__send-btn {
flex-shrink: 0; position: absolute;
bottom: 8px;
right: 8px;
z-index: 2;
height: 36px; height: 36px;
border-radius: 8px; border-radius: 8px;
min-width: 56px; min-width: 56px;
} }
/* ========================================================================== /* ==========================================================================
语音按钮(发送按钮左边 🆕 v1.3.4InputBar 工具栏最后位置 - 操作按钮(人工坐席单入口
- 6 态 modifier 类名沿用 .call-agent-btn--* 与 IntegrationZone(原 v1.3 风格)保持一致
- 样式块独立(不影响 IntegrationZone 的 .integration-zone .call-agent-btn
- 升级为完整按钮(带外框+文字+图标),与原 v1.3 整合区 .call-agent-btn 视觉风格一致
- 沿用 v1.3 整合区原 .call-agent-btn 样式规格:
padding 6px 14px / border-radius 16px / border 1px solid / font-size 13px
- 紧急态 pulse 动画 @keyframes input-bar-call-agent-urgent-pulse
- 🆕 v1.3.4 变更:删除 .input-bar__sr-only(文字直接渲染,不再 sr-only 隐藏)
========================================================================== */
.input-bar__call-agent-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 32px;
padding: 6px 14px;
background: var(--bg-secondary, #ffffff);
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 16px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #6b7280);
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
outline: none;
}
.input-bar__call-agent-btn:hover:not(:disabled) {
background: var(--bg-hover, rgba(0, 0, 0, 0.04));
}
.input-bar__call-agent-btn:active:not(:disabled) {
transform: scale(0.96);
}
.input-bar__call-agent-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.input-bar__call-agent-btn__icon {
line-height: 1;
pointer-events: none;
font-size: 16px;
margin-right: 2px;
}
.input-bar__call-agent-btn__text {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
line-height: 1;
pointer-events: none;
}
/* 🆕 v1.3.4:删除 .input-bar__sr-only(文字已直接渲染,无需 sr-only 隐藏) */
/* 6 态 modifier(沿用 IntegrationZone 的 .call-agent-btn--* 命名约定)
视觉规格参考原 v1.3 IntegrationZone .call-agent-btn--* 6 个 modifier */
.input-bar__call-agent-btn.call-agent-btn--disabled {
opacity: 0.55;
cursor: not-allowed;
color: var(--text-tertiary, #9ca3af);
border-color: var(--border-color, #e5e7eb);
background: var(--bg-secondary, #ffffff);
}
.input-bar__call-agent-btn.call-agent-btn--active {
border-color: var(--accent, #1989fa);
background: var(--bg-accent-soft, rgba(25, 137, 250, 0.08));
color: var(--accent, #1989fa);
}
.input-bar__call-agent-btn.call-agent-btn--active:hover:not(:disabled) {
background: var(--accent-soft, rgba(25, 137, 250, 0.15));
}
.input-bar__call-agent-btn.call-agent-btn--active:active:not(:disabled) {
transform: scale(0.96);
}
.input-bar__call-agent-btn.call-agent-btn--urgent {
border-color: var(--color-danger, #ee0a24);
background: rgba(238, 10, 36, 0.08);
color: var(--color-danger, #ee0a24);
animation: input-bar-call-agent-urgent-pulse 1.5s ease-in-out infinite;
}
.input-bar__call-agent-btn.call-agent-btn--urgent:hover:not(:disabled) {
background: rgba(238, 10, 36, 0.15);
}
.input-bar__call-agent-btn.call-agent-btn--waiting {
border-color: var(--color-warning, #f59e0b);
background: rgba(245, 158, 11, 0.08);
color: var(--color-warning, #f59e0b);
}
.input-bar__call-agent-btn.call-agent-btn--waiting:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.15);
}
.input-bar__call-agent-btn.call-agent-btn--end {
border-color: var(--color-danger, #ee0a24);
background: var(--color-danger, #ee0a24);
color: #fff;
font-weight: 700;
}
.input-bar__call-agent-btn.call-agent-btn--end:hover:not(:disabled) {
background: #dc2626;
border-color: #dc2626;
}
.input-bar__call-agent-btn.call-agent-btn--reopen {
border-color: var(--color-info, #1989fa);
background: var(--color-info-light, #ecf5ff);
color: var(--color-info, #1989fa);
}
.input-bar__call-agent-btn.call-agent-btn--reopen:hover:not(:disabled) {
background: var(--color-info, #1989fa);
color: #fff;
border-color: var(--color-info, #1989fa);
}
@keyframes input-bar-call-agent-urgent-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(238, 10, 36, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(238, 10, 36, 0); }
}
/* ==========================================================================
语音按钮(v1.3.2 已从控件区迁移到工具栏,位于输入框左上角)
========================================================================== */ ========================================================================== */
/* 默认状态:与工具栏按钮风格一致 */ /* 默认状态:与工具栏按钮风格一致 */
.input-bar__voice-btn { .input-bar__voice-btn {
@@ -1038,9 +1335,15 @@ function handleInputResizeStart(event: MouseEvent): void {
========================================================================== */ ========================================================================== */
/* ========================================================================== /* ==========================================================================
语音状态提示条(百度ASR内联录音时显示在输入框上方 语音状态提示条(百度ASR内联录音时显示)
🆕 v1.3.2:定位到工具栏右侧(避开左上角按钮的视觉重叠)
========================================================================== */ ========================================================================== */
.input-bar__voice-status { .input-bar__voice-status {
position: absolute;
top: 14px;
/* 清空工具栏宽度:3 按钮 (3*36) + 2 间隔 (2*4) + 2*8 边距 ≈ 132px */
left: 132px;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
@@ -6,10 +6,15 @@
* 取值只有 'ai_handling' | 'waiting' | 'serving' | 'resolved',导致引导语场景 2 * 取值只有 'ai_handling' | 'waiting' | 'serving' | 'resolved',导致引导语场景 2
* 永远不显示。本测试覆盖 4 种引导语场景,确保未来不会再回归。 * 永远不显示。本测试覆盖 4 种引导语场景,确保未来不会再回归。
* *
* v1.3.5 BugFix:场景 3(坐席离线)引导语移除
* - 产品决定不显示离线描述(与状态条一并移除)
* - store.agentOnline 业务状态仍保留(v1.2y 拦截依赖)
* - 此处测试从 "⚠️ 坐席当前离线..." 改为返回 null
*
* 覆盖: * 覆盖:
* - 场景1:无会话 * - 场景1:无会话
* - 场景2AI <3 轮(ai_handling + canCallAgent=false)✅ P1 Bug 修复回归 * - 场景2AI <3 轮(ai_handling + canCallAgent=false)✅ P1 Bug 修复回归
* - 场景3:坐席离线 * - 场景3:坐席离线 → v1.3.5 起返回 null(产品决定不显示)
* - 场景4:会话过期(resolved + canReopen=false * - 场景4:会话过期(resolved + canReopen=false
* - 非 disabled 态:不显示引导语 * - 非 disabled 态:不显示引导语
* - isGuideTextWarnwarn 样式判定 * - isGuideTextWarnwarn 样式判定
@@ -35,23 +40,27 @@ function makeCtx(overrides: Partial<GuideTextContext> = {}): GuideTextContext {
describe('InputBar 引导语 — 4 种场景', () => { describe('InputBar 引导语 — 4 种场景', () => {
// =========================================================================== // ===========================================================================
// 场景1:无会话 // 场景1:无会话v1.3.1 BugFix:去除底部整合区重复引导语)
// =========================================================================== // ===========================================================================
it('场景1:无会话 → 显示"💡 先描述一下你遇到的问题..."', () => { it('场景1:无会话 → 返回 null(顶部 ChatPanel 已显示相同引导)', () => {
// v1.3.1 BugFix:原返回 "💡 先描述一下你遇到的问题..." 与 ChatPanel.vue 顶部
// "输入问题咨询,或点击下方'人工坐席'按钮" 重复,已改为 null。
const result = computeGuideText(makeCtx({ currentConversation: null })) const result = computeGuideText(makeCtx({ currentConversation: null }))
expect(result).toBe('💡 先描述一下你遇到的问题,AI 助手会先帮你看看') expect(result).toBeNull()
}) })
// =========================================================================== // ===========================================================================
// 场景2AI <3 轮(P1 Bug 修复回归) // 场景2AI <3 轮(P1 Bug 修复回归)
// =========================================================================== // ===========================================================================
it('场景2ai_handling + canCallAgent=false → 显示"请继续描述您的问题或需求"', () => { it('场景2ai_handling + canCallAgent=false → 返回 nullv1.3.2 优化)', () => {
// P1 Bug 修复回归:原代码用 'active' 永不匹配,现在用 'ai_handling' 必须能匹配 // P1 Bug 修复回归:原代码用 'active' 永不匹配,现在用 'ai_handling' 必须能匹配
// v1.3.2 BugFix:场景2 不再返回"请继续描述您的问题或需求",改为 null
// (用户已与 AI 对话,底部再来"请继续描述"显得啰嗦)
const result = computeGuideText(makeCtx({ const result = computeGuideText(makeCtx({
currentConversation: { status: 'ai_handling' }, currentConversation: { status: 'ai_handling' },
canCallAgent: false, canCallAgent: false,
})) }))
expect(result).toBe('请继续描述您的问题或需求') expect(result).toBeNull()
}) })
it('场景2 反向:ai_handling + canCallAgent=true → 不显示引导语(active 态)', () => { it('场景2 反向:ai_handling + canCallAgent=true → 不显示引导语(active 态)', () => {
@@ -69,28 +78,32 @@ describe('InputBar 引导语 — 4 种场景', () => {
}) })
// =========================================================================== // ===========================================================================
// 场景3:坐席离线 // 场景3:坐席离线v1.3.5 已移除)
// =========================================================================== // ===========================================================================
it('场景3agentOnline=false(且不命中场景2/4显示"⚠️ 坐席当前离线..."', () => { it('场景3v1.3.5 已移除)agentOnline=false(且不命中场景2/4返回 null(UI 不再显示离线描述)', () => {
// 隔离场景3 触发条件:canCallAgent=true 避免场景2 命中,canReopen=true 避免场景4 命中 // v1.3.5 BugFix:场景 3 引导语已移除
// 隔离场景3 触发条件:canCallAgent=true 避免场景2 命中,canReopen=true 避免场景4 命中
// 原期望:'⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
// 新期望:null(产品决定不显示离线描述,仅 store.agentOnline 业务状态保留)
const result = computeGuideText(makeCtx({ const result = computeGuideText(makeCtx({
canCallAgent: true, // 避免场景2 命中(ai_handling + canCallAgent=false canCallAgent: true, // 避免场景2 命中(ai_handling + canCallAgent=false
canReopen: true, // 避免场景4 命中(resolved + canReopen=false canReopen: true, // 避免场景4 命中(resolved + canReopen=false
agentOnline: false, // 命中场景3 agentOnline: false, // 命中场景3v1.3.5 起不再消费此状态
})) }))
expect(result).toBe('⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试') expect(result).toBeNull()
}) })
it('场景优先级:ai_handling + canCallAgent=false → 场景 2 命中(即使 agentOnline=false', () => { it('场景优先级:ai_handling + canCallAgent=false → 不返回任何引导语(v1.3.2 场景 2 返回 null', () => {
// 设计意图:当用户处于 AI < 3 轮场景时,无论坐席是否离线,应继续引导用户描述问题 // 设计意图:v1.3.2 优化后,场景 2 返回 null,因此即使同时满足场景 3 触发条件
// 因此场景 2 优先级 > 场景 3(与 helper 实现顺序一致) // agentOnline=false),场景 2 优先级 > 场景 3,最终也返回 null
// 场景 3 只在 canCallAgent=true(即 AI 已尝试 ≥ 3 轮)时才显示 //(用户已与 AI 对话,避免重复引导)
// v1.3.5:场景 3 也返回 null,此用例覆盖两个 null 路径的合并行为
const result = computeGuideText(makeCtx({ const result = computeGuideText(makeCtx({
currentConversation: { status: 'ai_handling' }, currentConversation: { status: 'ai_handling' },
canCallAgent: false, canCallAgent: false,
agentOnline: false, agentOnline: false,
})) }))
expect(result).toBe('请继续描述您的问题或需求') expect(result).toBeNull()
}) })
// =========================================================================== // ===========================================================================
@@ -171,14 +184,29 @@ describe('InputBar 引导语 — P1 Bug 回归保护', () => {
expect(buggyResult).toBeNull() expect(buggyResult).toBeNull()
// 新正确行为:用 'ai_handling' // 新正确行为:用 'ai_handling'
// v1.3.2 BugFix:场景 2 改返回 null'ai_handling' 必须能匹配但返回 null
const correctResult = computeGuideText(makeCtx({ const correctResult = computeGuideText(makeCtx({
currentConversation: { status: 'ai_handling' }, currentConversation: { status: 'ai_handling' },
canCallAgent: false, canCallAgent: false,
})) }))
expect(correctResult).toBe('请继续描述您的问题或需求') expect(correctResult).toBeNull()
// 断言两者不同(确保修复确实改变了行为) // v1.3.2 后,buggy 和 correct 路径在 canCallAgent=false 下都返回 null
expect(buggyResult).not.toBe(correctResult) // buggy'active' 不在 ConversationInfo.status 取值范围,走完所有场景不命中;
// correctv1.3.2 改后场景 2 显式返回 null)
// 两者相同不代表修复失效——P1 Bug 保护点已在上方 expect(buggyResult).toBeNull()
// 和 expect(correctResult).toBeNull() 中验证"ai_handling 走得到 helper 内部判断流程"
//(v1.3.2 修复后才会命中场景 2 分支并返回 null,这是与旧 buggy 行为"短路返回 null"不同的代码路径)
// 进一步验证:让 correct 路径通过 canCallAgent=true 进入场景 3
// v1.3.5:场景 3 已移除(产品决定不显示离线描述)
// 原期望:'⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
// 新期望:null
const correctResultSc3 = computeGuideText(makeCtx({
currentConversation: { status: 'ai_handling' },
canCallAgent: true,
agentOnline: false,
}))
expect(correctResultSc3).toBeNull()
}) })
it('回归保护:ConversationInfo.status 取值白名单(TS 类型约束,运行时防御)', () => { it('回归保护:ConversationInfo.status 取值白名单(TS 类型约束,运行时防御)', () => {
@@ -195,9 +223,10 @@ describe('InputBar 引导语 — P1 Bug 回归保护', () => {
} }
// helper 应在 valid status 下正确行为 // helper 应在 valid status 下正确行为
// v1.3.2 BugFix:场景 2 改返回 null'ai_handling' 是有效 status 但返回 null
expect(computeGuideText(makeCtx({ expect(computeGuideText(makeCtx({
currentConversation: { status: 'ai_handling' }, currentConversation: { status: 'ai_handling' },
canCallAgent: false, canCallAgent: false,
}))).toBe('请继续描述您的问题或需求') }))).toBeNull()
}) })
}) })
@@ -10,7 +10,7 @@
* 设计要点(PRD §3.3): * 设计要点(PRD §3.3):
* 1. 不显示 {n}/3 进度数字 — 避免催促感 * 1. 不显示 {n}/3 进度数字 — 避免催促感
* 2. 所有引导语鼓励"先 AI" — 不给坐席端制造流量压力 * 2. 所有引导语鼓励"先 AI" — 不给坐席端制造流量压力
* 3. 离线情况给出口 — 不直接禁用可达性,留"刷新重试" * 3. ~~离线情况给出口~~v1.3.5 移除场景 3 引导语(产品决定不显示离线描述)
* 4. 过期明确引导"开新会话" * 4. 过期明确引导"开新会话"
* *
* 渲染条件与按钮态共享同一 callAgentState,永不同步错位。 * 渲染条件与按钮态共享同一 callAgentState,永不同步错位。
@@ -21,7 +21,14 @@
export interface GuideTextContext { export interface GuideTextContext {
/** 当前会话(null 表示无会话) */ /** 当前会话(null 表示无会话) */
currentConversation: { status: string } | null currentConversation: { status: string } | null
/** 坐席是否在线 */ /**
* 坐席是否在线
* v1.3.5:UI 不再显示离线描述(场景 3 已移除),但保留字段以便:
* 1. 兼容 ChatPanel 等调用方的 GuideTextContext 调用约定
* 2. 未来若恢复场景 3 引导语,可直接使用,无需改调用方
* 注意:业务侧 store.agentOnline 状态仍由 inputBarCallAgentState.ts 的 6 态计算使用,
* 这里仅是 UI 引导语不再消费它。
*/
agentOnline: boolean agentOnline: boolean
/** 是否可以呼叫人工坐席(AI 回复 >= 3 次) */ /** 是否可以呼叫人工坐席(AI 回复 >= 3 次) */
canCallAgent: boolean canCallAgent: boolean
@@ -30,37 +37,57 @@ export interface GuideTextContext {
} }
/** /**
* 计算 InputBar 引导语(4 种场景) * 计算 InputBar 引导语(4 种场景 → v1.3.5 起实际仅 3 场景生效
* *
* 仅 disabled 态显示引导语,其他态不显示。 * 仅 disabled 态显示引导语,其他态不显示。
* *
* 场景优先级(与按钮态计算共享同一调用栈,永不同步错位): * 场景优先级(与按钮态计算共享同一调用栈,永不同步错位):
* 1. 无会话 → "💡 先描述一下你遇到的问题,AI 助手会先帮你看看" * 1. 无会话 → null(顶部 ChatPanel 已显示"输入问题咨询..."文案,避免重复)
* 2. AI <3 轮(ai_handling + canCallAgent=false)→ "请继续描述您的问题或需求" * 2. AI <3 轮(ai_handling + canCallAgent=false)→ nullv1.3.2 优化:用户已与 AI 对话,无需再提示"请继续描述")
* 3. 坐席离线 → "⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试" * 3. ~~坐席离线~~v1.3.5 移除:返回 null(产品决定不显示离线描述)
* 4. 会话过期(resolved + canReopen=false)→ "⏰ 上一会话已过期,开始新对话吧" * 4. 会话过期(resolved + canReopen=false)→ "⏰ 上一会话已过期,开始新对话吧"
* *
* 注意:场景 2 使用 'ai_handling' 而非 'active'ConversationInfo.status 标准值)。 * 注意:场景 2 使用 'ai_handling' 而非 'active'ConversationInfo.status 标准值)。
* 'active' 是按钮态 CallAgentState 的一个值,不是会话状态。 * 'active' 是按钮态 CallAgentState 的一个值,不是会话状态。
*
* v1.3.1 BugFix:场景1(原"💡 先描述一下你遇到的问题...")与 ChatPanel.vue 顶部
* "输入问题咨询,或点击下方'人工坐席'按钮" 在"无会话/无消息"状态下重复表达
* "先说问题才能叫人"。去除底部整合区引导语,保留顶部更显眼的那段。
*
* v1.3.2 BugFix:场景 2 引导语移除
* - 原因:用户已与 AI 对话(消息流可见),底部再来"请继续描述"显得啰嗦
* - 与 v1.3.1 场景 1 一致:避免与已有 UI 元素重复引导
* - 函数本身保留(场景 3/4 仍使用),仅场景 2 返回 null
*
* v1.3.5 BugFix:场景 3 引导语移除
* - 原因:用户拍板删除状态条 + 离线描述(不显示在线/离线信息)
* - store.agentOnline 仍由 inputBarCallAgentState.ts 6 态计算 + shakeAgent 拦截使用(业务侧保留)
* - 此处仅 UI 引导语不再消费 agentOnline
*/ */
export function computeGuideText(ctx: GuideTextContext): string | null { export function computeGuideText(ctx: GuideTextContext): string | null {
const { currentConversation, agentOnline, canCallAgent, canReopen } = ctx const { currentConversation, agentOnline, canCallAgent, canReopen } = ctx
// v1.3.5:场景 3 已移除,agentOnline 仅解构保留以便未来回退,业务上不影响其他场景
void agentOnline
// 场景1:无会话 // 场景1:无会话v1.3.1 BugFix:返回 null,避免与 ChatPanel 顶部 "输入问题咨询..." 重复)
if (!currentConversation) { if (!currentConversation) {
return '💡 先描述一下你遇到的问题,AI 助手会先帮你看看' return null
} }
// 场景2AI <3 轮(ai_handling + canCallAgent=false // 场景2AI <3 轮(ai_handling + canCallAgent=false
// P1 Bug 修复:必须用 'ai_handling',不能用 'active' // P1 Bug 修复:必须用 'ai_handling',不能用 'active'
// v1.3.2 BugFix:返回 null(用户已与 AI 对话,底部再来"请继续描述"显得啰嗦)
if (currentConversation.status === 'ai_handling' && !canCallAgent) { if (currentConversation.status === 'ai_handling' && !canCallAgent) {
return '请继续描述您的问题或需求' return null // v1.3.2 优化:避免重复引导
} }
// 场景3:坐席离线 // v1.3.5:场景 3 已移除(产品决定不显示离线描述)
if (!agentOnline) { // 原逻辑:
return '⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试' // if (!agentOnline) {
} // return '⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
// }
// 业务侧 store.agentOnline 状态仍保留(v1.2y shakeAgent 拦截依赖),
// 仅 UI 引导语不再消费此状态。
// 场景4:会话过期(resolved + canReopen=false // 场景4:会话过期(resolved + canReopen=false
if (currentConversation.status === 'resolved' && !canReopen) { if (currentConversation.status === 'resolved' && !canReopen) {
@@ -73,6 +100,7 @@ export function computeGuideText(ctx: GuideTextContext): string | null {
/** /**
* 判断引导语是否为 warn 样式(含 ⚠️ 离线提示) * 判断引导语是否为 warn 样式(含 ⚠️ 离线提示)
* v1.3.5:场景 3 已移除,warn 样式基本不再触发;保留函数以防回退
*/ */
export function isGuideTextWarn(text: string | null): boolean { export function isGuideTextWarn(text: string | null): boolean {
return !!text && text.includes('⚠️') return !!text && text.includes('⚠️')
@@ -266,7 +266,9 @@ describe('IntegrationZone — 引导语渲染 shouldRenderGuideText', () => {
}) })
it('正常文本 → true(渲染)', () => { it('正常文本 → true(渲染)', () => {
expect(shouldRenderGuideText('请继续描述您的问题或需求')).toBe(true) // v1.3.2:场景 2 已改 return nullIntegrationZone 不会收到该文案
// 此测试样本需要更新为真实场景(场景 3 的 warn 文案)
expect(shouldRenderGuideText('⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试')).toBe(true)
}) })
}) })
@@ -13,7 +13,7 @@
* *
* 复用关系: * 复用关系:
* - 复用 inputBarCallAgentState.ts 的 6 态计算(不再重复实现) * - 复用 inputBarCallAgentState.ts 的 6 态计算(不再重复实现)
* - 复用 utils/shiftHours.ts 的状态条文案生成 * - ~~复用 utils/shiftHours.ts 的状态条文案生成~~v1.3.5 整体删除)
* - 复用 inputBarGuideText.ts 的引导语 warn 样式判定 * - 复用 inputBarGuideText.ts 的引导语 warn 样式判定
* *
* 不变性约束(v1.2x 已 PASS 6 态): * 不变性约束(v1.2x 已 PASS 6 态):
@@ -34,10 +34,10 @@ import { isGuideTextWarn } from './inputBarGuideText'
/** /**
* IntegrationZone Props * IntegrationZone Props
* - agentOnline: 状态条在线/离线 + QueueCapsule 排队态降级判定的统一依据 * - agentOnline: QueueCapsule 排队态降级判定的统一依据v1.3.5 状态条已删除)
* - shiftHours: 状态条显示的班次(默认 '9:00-18:00'v1.4 由 store.shiftHours 注入) * - shiftHours: ~~状态条显示的班次~~(v1.3.5 状态条已删除),字段保留以备 v1.4 接后端 API
* - callAgentState: 6 态按钮(disabled/active/urgent/waiting/end/reopen * - callAgentState: 6 态按钮(disabled/active/urgent/waiting/end/reopen
* - guideText: 4 种场景引导语文本(null 表示不显示引导语) * - guideText: 4 种场景引导语文本(null 表示不显示引导语v1.3.5 起场景 3 也返回 null
*/ */
export interface IntegrationZoneProps { export interface IntegrationZoneProps {
agentOnline: boolean agentOnline: boolean
@@ -21,6 +21,11 @@
import { useConversationStore } from '@/stores/conversation' import { useConversationStore } from '@/stores/conversation'
import { useEmployeeStore } from '@/stores/employee' import { useEmployeeStore } from '@/stores/employee'
// 🆕 BUG-FIX:导入 recommendStore 用于处理审批进度回流(recommend_update WS 事件)
// 背景:后端 recommend_progress_service._build_ws_message 广播 type=recommend_update (kind=progress)
// 但 useH5WebSocket.handleMessage 之前没有对应的 case 分发,导致 progress 进度回流功能未端到端联通。
// 修复:增加 case 'recommend_update' 分支 → recommendStore.handleRecommendUpdate(msg.data)。
import { useRecommendStore } from '@/stores/recommend'
import { ref } from 'vue' import { ref } from 'vue'
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@@ -535,6 +540,28 @@ export function useH5WebSocket() {
} }
break break
// ==================================================================
// 🆕 BUG-FIX:审批进度回流(recommend_update WS 事件)
// ==================================================================
// 后端来源:recommend_progress_service._build_ws_message()
// { type: 'recommend_update',
// data: { kind: 'progress', id, approval_id, progress, status, current_step, ... } }
// 触发链:员工提交审批 → 建占位 record_progress → 企微 webhook 回调 →
// recommend_progress_service.update_progress() → WS 广播 → 这里
// 做什么:把后端推送的进度更新写入 recommendStore.progressCards
// DynamicRecommend 组件会响应式渲染 progress 卡片(审批进度条)
// 为什么单独 dispatch 而不经 conversationStore
// - progress 是按 approval_id 跨会话长期存在,不属于会话上下文
// - recommendStore 已经完整实现 FIFO/去重/localStorage 持久化(5.0 §持久化)
// - 直接 dispatch 避免引入多余的 proxy 层,最小改动
case 'recommend_update':
// 审批进度回流(来自 webhook / 60s 轮询 / record_initial
if (msg.data) {
const recommendStore = useRecommendStore()
recommendStore.handleRecommendUpdate(msg.data)
}
break
// ================================================================== // ==================================================================
// 坐席接入事件(2026-07-27 已移除:用户要求取消该提示消息) // 坐席接入事件(2026-07-27 已移除:用户要求取消该提示消息)
// ================================================================== // ==================================================================
@@ -16,6 +16,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { useRecommendStore, type RecommendCard } from './recommend' import { useRecommendStore, type RecommendCard } from './recommend'
import { useH5WebSocket } from '@/composables/useH5WebSocket'
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// localStorage polyfill(避免装 happy-dom/jsdom // localStorage polyfill(避免装 happy-dom/jsdom
@@ -32,11 +33,39 @@ const _localStoragePolyfill = {
// 注入到 globalThis(这样 vitest node 环境也能用) // 注入到 globalThis(这样 vitest node 环境也能用)
;(globalThis as any).localStorage = _localStoragePolyfill ;(globalThis as any).localStorage = _localStoragePolyfill
// WebSocket test double:让测试真正经过 useH5WebSocket.onmessage 分发
class MockWebSocket {
static readonly OPEN = 1
static readonly instances: MockWebSocket[] = []
readonly url: string
readonly protocols: string[] | undefined
readyState = MockWebSocket.OPEN
onopen: ((event: Event) => void) | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onclose: ((event: CloseEvent) => void) | null = null
onerror: ((event: Event) => void) | null = null
readonly send = vi.fn()
readonly close = vi.fn()
constructor(url: string, protocols?: string | string[]) {
this.url = url
this.protocols = typeof protocols === 'string' ? [protocols] : protocols
MockWebSocket.instances.push(this)
}
}
;(globalThis as any).window = {
location: { protocol: 'https:', host: 'h5.example.test' },
}
;(globalThis as any).WebSocket = MockWebSocket
describe('recommendStore — 持久化 + 话题切换 trigger', () => { describe('recommendStore — 持久化 + 话题切换 trigger', () => {
beforeEach(() => { beforeEach(() => {
// 隔离每个测试:fresh Pinia + fresh localStorage // 隔离每个测试:fresh Pinia + fresh localStorage
setActivePinia(createPinia()) setActivePinia(createPinia())
_ls.clear() _ls.clear()
MockWebSocket.instances.length = 0
vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.spyOn(console, 'warn').mockImplementation(() => {})
}) })
@@ -420,6 +449,89 @@ describe('recommendStore — 持久化 + 话题切换 trigger', () => {
} as any) } as any)
expect(store.progressCards).toHaveLength(0) expect(store.progressCards).toHaveLength(0)
}) })
// ===========================================================================
// BUG-FIX 端到端测试:真实经过 useH5WebSocket.onmessage 分发
// ===========================================================================
it('【端到端】WS recommend_update 经 composable 分发后写入 progressCards', () => {
localStorage.setItem('h5_token', 'test-token')
localStorage.setItem('employee_id', 'e1')
const store = useRecommendStore()
const { connect, disconnect } = useH5WebSocket()
connect()
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]
expect(socket).toBeDefined()
const wsMsg = {
type: 'recommend_update',
data: {
kind: 'progress',
id: 'prog-001',
approval_id: 'ap-001',
title: 'VPN申请',
progress: 50,
status: 'in_progress',
current_step: '主管审批中',
total_steps: 3,
extra: { applicant: 'simon' },
},
}
// 直接触发真实 WebSocket 实例的 onmessage,不能绕过 composable。
socket.onmessage?.({ data: JSON.stringify(wsMsg) } as MessageEvent)
expect(store.progressCards).toHaveLength(1)
expect(store.progressCards[0].id).toBe('prog-001')
expect(store.progressCards[0].progress).toBe(50)
expect(store.progressCards[0].approval_id).toBe('ap-001')
expect(store.progressCards[0].current_step).toBe('主管审批中')
expect(localStorage.getItem('h5_recommend_progress')).toBeTruthy()
disconnect()
})
// 后端 webhook 更新同一条 RecommendProgress 记录时,id 与 approval_id 不变。
// 该用例验证真实 WS 链路不会因同源抑制而保留旧进度。
it('【端到端】同一审批的 WS 增量更新替换旧 progress 卡片', () => {
localStorage.setItem('h5_token', 'test-token')
localStorage.setItem('employee_id', 'e1')
const store = useRecommendStore()
const { connect, disconnect } = useH5WebSocket()
connect()
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1]
expect(socket).toBeDefined()
const sendProgress = (progress: number, status: string, current_step: string) => {
socket.onmessage?.({
data: JSON.stringify({
type: 'recommend_update',
data: {
kind: 'progress',
id: 'prog-same',
approval_id: 'ap-same',
title: '设备申请',
progress,
status,
current_step,
},
}),
} as MessageEvent)
}
sendProgress(10, 'pending', '已发起')
sendProgress(60, 'approved', '主管已通过')
expect(store.progressCards).toHaveLength(1)
expect(store.progressCards[0].id).toBe('prog-same')
expect(store.progressCards[0].approval_id).toBe('ap-same')
expect(store.progressCards[0].progress).toBe(60)
expect(store.progressCards[0].status).toBe('approved')
expect(store.progressCards[0].current_step).toBe('主管已通过')
disconnect()
})
}) })
// =========================================================================== // ===========================================================================
+18 -6
View File
@@ -163,15 +163,27 @@ export const useRecommendStore = defineStore('recommend', () => {
*/ */
function pushCard(card: RecommendCard): boolean { function pushCard(card: RecommendCard): boolean {
// 1. 同源抑制(30 分钟窗口) // 1. 同源抑制(30 分钟窗口)
// 业务约束:progress 层是"同一 DB 记录的状态推进"webhook 回调 → update_progress → WS 推送),
// 同一 RecommendProgress.id 后续推送必须替换旧卡,绝不能被抑制阻断(QA Round 1 BUG)。
// 其他 layerL1/L2/L3/A)是"独立推荐事件",30 分钟同 id 应抑制以避免重复刷屏。
// 为什么用 layer 白名单而不是黑名单:
// - 新增 layer(如 future kind)默认走抑制(保守策略,避免误刷)
// - progress 是当前唯一已知"同一 id 多次有效推送"的 layer
const now = Date.now() const now = Date.now()
const suppressKey = `${card.layer}:${card.id}` const suppressKey = `${card.layer}:${card.id}`
const lastSuppress = suppressMap.value.get(suppressKey) const isProgress = card.layer === 'progress'
if (lastSuppress && now - lastSuppress < SUPPRESS_WINDOW_MS) {
console.log('[RecommendStore] 同源抑制跳过:', suppressKey) if (!isProgress) {
return false const lastSuppress = suppressMap.value.get(suppressKey)
if (lastSuppress && now - lastSuppress < SUPPRESS_WINDOW_MS) {
console.log('[RecommendStore] 同源抑制跳过:', suppressKey)
return false
}
// 记录(即便不写入也在 30 分钟内不重试;成功写入也记录)
suppressMap.value.set(suppressKey, now)
} }
// 记录(即便不写入也在 30 分钟内不重试;成功写入也记录) // progress 层跳过 suppressMap 写入:每次 push 都是真实状态推进,应无条件走替换路径
suppressMap.value.set(suppressKey, now) // (即便 progress 卡片已存在,新推送的 progress/status/current_step 必须更新到 store
// 2. 选目标列表 // 2. 选目标列表
let targetList: typeof l1Cards let targetList: typeof l1Cards