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:
@@ -2,11 +2,11 @@
|
||||
// 企微IT智能服务台 — H5用户端对话区面板 v1.3
|
||||
// =============================================================================
|
||||
// 说明:对话区主面板,包含:
|
||||
// 1. 标题栏(IT智能服务台 + 主题切换 + 头像 + 顶部退出按钮)— v1.3 移除坐席徽章
|
||||
// 1. 标题栏(IT智能服务台 + 头像 + 顶部退出按钮)— v1.3.5 移除主题切换 + v1.3 移除坐席徽章
|
||||
// 2. 排查步骤(固定在消息区顶部,不随滚动消失)
|
||||
// 3. 消息列表(自动滚动到底部)
|
||||
// 4. 消息类型渲染:员工(右蓝) / 坐席(左白) / AI(左绿+AI标签) / 系统(居中灰)
|
||||
// 5. 🆕 v1.3:整合区(IntegrationZone):状态条 + 操作按钮 + 进度胶囊 + 引导语
|
||||
// 5. 🆕 v1.3:整合区(IntegrationZone):进度胶囊 + 引导语(v1.3.5 移除状态条)
|
||||
// 6. 底部输入栏(仅工具栏 + 输入框 + 发送按钮,🆕 v1.3 已移除操作按钮)
|
||||
//
|
||||
// 🆕 v1.3 整合区方案 A(集成):
|
||||
@@ -17,25 +17,14 @@
|
||||
|
||||
<template>
|
||||
<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-left">
|
||||
<span class="logo-block">IT</span>
|
||||
<span class="chat-panel__title">智能IT服务</span>
|
||||
</div>
|
||||
<div class="chat-panel__header-actions">
|
||||
<!-- 主题切换开关(☀️ 滑轨 🌙) -->
|
||||
<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>
|
||||
<!-- v1.3.5:主题切换按钮已删除(产品决定仅保留浅色模式) -->
|
||||
<!-- 员工头像 + 姓名 -->
|
||||
<div class="chat-panel__user-info">
|
||||
<img
|
||||
@@ -188,7 +177,7 @@
|
||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
||||
import { showLoadingToast, showToast, closeToast } from 'vant'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
// v1.3.5:主题切换 UI 已移除,useThemeStore 不再在本组件中使用(App.vue 仍持有引用以初始化浅色主题)
|
||||
import { useEmployeeStore } from '@/stores/employee'
|
||||
import { useIntegrationZoneStore } from '@/stores/integrationZone'
|
||||
import MessageBubble from './MessageBubble.vue'
|
||||
@@ -210,7 +199,7 @@ import { employeeClose } from '@/api/closing'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
|
||||
const store = useConversationStore()
|
||||
const themeStore = useThemeStore()
|
||||
// v1.3.5:themeStore 实例已删除(按钮 DOM 不再渲染,无需持有 store 引用)
|
||||
const employeeStore = useEmployeeStore()
|
||||
const integrationZoneStore = useIntegrationZoneStore()
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!-- =============================================================================
|
||||
// 企微IT智能服务台 — H5用户端输入栏组件
|
||||
// =============================================================================
|
||||
// 说明:底部输入栏,固定在消息列表下方,包含:
|
||||
// [工具栏:表情/文件] [人工按钮] [文本输入框] [发送按钮]
|
||||
// - "人工"按钮位于发送键上方,三态:disabled/active/urgent
|
||||
// - 输入框默认3行可见,高度随内容动态适应
|
||||
// - 输入框顶部拖拽手柄可手动调节高度
|
||||
// - Enter 发送,Shift+Enter 换行
|
||||
// - 支持粘贴图片上传(Ctrl+V 粘贴截图)
|
||||
// - 支持图片/文件选择上传
|
||||
// 说明:底部输入栏,固定在消息列表下方。
|
||||
// 🆕 v1.3.2 布局调整:富文本编辑器风格 — 输入框为容器,装饰按钮绝对定位在四角:
|
||||
// - 左上角:😊 表情 / 📎 文件 / 🎤 语音(从原控件区迁移)
|
||||
// - 右下角:发送按钮(从原控件区迁移)
|
||||
// - 顶部拖拽手柄可手动调节输入栏高度(保留)
|
||||
// - Enter 发送,Shift+Enter 换行(保留)
|
||||
// - 支持粘贴图片上传(Ctrl+V 粘贴截图,保留)
|
||||
// - 支持图片/文件选择上传(保留)
|
||||
// ============================================================================= -->
|
||||
|
||||
<template>
|
||||
@@ -21,9 +21,9 @@
|
||||
<div class="input-bar__resize-track"></div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域:工具栏 + 输入框 + 发送按钮 -->
|
||||
<!-- 输入区域:定位上下文容器 + 输入框 -->
|
||||
<div class="input-bar__row">
|
||||
<!-- 工具栏:表情/文件 -->
|
||||
<!-- 🆕 v1.3.2:工具栏改为输入框左上角绝对定位(表情/文件/语音三按钮并排) -->
|
||||
<div class="input-bar__toolbar">
|
||||
<button class="input-bar__tool-btn" title="表情" @click="handleEmoji">
|
||||
<span>😊</span>
|
||||
@@ -31,6 +31,36 @@
|
||||
<button class="input-bar__tool-btn" title="文件" @click="handleFile">
|
||||
<span>📎</span>
|
||||
</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>
|
||||
|
||||
<!-- 表情选择面板(简易版:常用 Emoji 网格) -->
|
||||
@@ -48,61 +78,38 @@
|
||||
<!-- 语音状态提示条(百度ASR内联录音/识别时显示) -->
|
||||
<!-- 做什么:录音中提示"正在语音输入",识别中提示"正在识别语音" -->
|
||||
<!-- 为什么只对 baidu 策略显示:企微策略用 showToast 提示即可 -->
|
||||
<!-- 🆕 v1.3.2:定位调整到工具栏右侧,避开左上角按钮的视觉重叠 -->
|
||||
<div v-if="isVoiceActive && voiceStrategy === 'baidu'" class="input-bar__voice-status">
|
||||
<span v-if="isTranscribing">正在识别语音...</span>
|
||||
<span v-else>正在语音输入... 点击⏹停止</span>
|
||||
</div>
|
||||
|
||||
<!-- 输入行:输入框 + 语音按钮 + 发送按钮(v1.3 已移除人工坐席按钮,已迁移到 IntegrationZone) -->
|
||||
<div class="input-bar__input-row">
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<van-field
|
||||
ref="inputFieldRef"
|
||||
v-model="inputText"
|
||||
class="input-bar__field"
|
||||
placeholder="请输入消息..."
|
||||
type="textarea"
|
||||
:autosize="{ minHeight: 54, maxHeight: 200 }"
|
||||
rows="3"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown.enter="handleEnterKey"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
<!-- 文本输入框 — 默认3行,自适应内容高度 -->
|
||||
<!-- 🆕 v1.3.2:padding-top 给左上角工具栏留位,padding-right 给右下角发送按钮留位 -->
|
||||
<van-field
|
||||
ref="inputFieldRef"
|
||||
v-model="inputText"
|
||||
class="input-bar__field"
|
||||
placeholder="请输入消息..."
|
||||
type="textarea"
|
||||
:autosize="{ minHeight: 54, maxHeight: 200 }"
|
||||
rows="3"
|
||||
:disabled="!store.isLoggedIn"
|
||||
@keydown.enter="handleEnterKey"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<!-- 🆕 v1.3:输入栏右侧控件区,简化为单一语音+发送横排(原人工坐席按钮已迁出到 IntegrationZone) -->
|
||||
<div class="input-bar__controls">
|
||||
<div class="input-bar__controls-row">
|
||||
<!-- 语音按钮 -->
|
||||
<!-- 做什么:点击开始录音/聆听,再次点击停止并转文字 -->
|
||||
<!-- 识别中禁用按钮,防止重复点击;显示⏳表示识别进行中 -->
|
||||
<!-- 为什么用 v-if 而不是 v-show:不支持语音时完全移除,不占位 -->
|
||||
<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>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 🆕 v1.3.2:发送按钮从原控件区迁移到输入框右下角(绝对定位) -->
|
||||
<van-button
|
||||
class="input-bar__send-btn"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!canSend"
|
||||
:loading="store.loading"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 表情面板遮罩(点击外部关闭) -->
|
||||
@@ -122,28 +129,47 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* InputBar 输入栏组件
|
||||
* 布局:[工具栏] [输入框] [发送按钮]
|
||||
* 🆕 v1.3.2 布局:富文本编辑器风格 — 输入框为容器,装饰按钮绝对定位在四角
|
||||
* - 左上角:😊 表情 / 📎 文件 / 🎤 语音 (从原控件区迁移)
|
||||
* - 右下角:发送按钮 (从原控件区迁移)
|
||||
* 输入框固定底部,默认3行可见,高度动态适应
|
||||
* 顶部拖拽手柄可手动调节输入栏整体高度
|
||||
* 工具栏:表情/文件 (已移除图片/拍照/截图)
|
||||
* 顶部拖拽手柄可手动调节输入栏整体高度(保留 v1.2 功能)
|
||||
* 工具栏:表情/文件/语音 (v1.3.2 新增语音按钮)
|
||||
* 支持文件选择上传(图片上传已禁用)
|
||||
*/
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
import { uploadFile } from '@/api/upload'
|
||||
import { useWecomVoice } from '@/composables/useWecomVoice'
|
||||
import { useAudioRecorder } from '@/composables/useAudioRecorder'
|
||||
import { transcribeAudio } from '@/api/voice'
|
||||
|
||||
// 🆕 v1.3.4:单入口 — InputBar 工具栏"人工坐席"按钮复用 helper(沿用 v1.3.3 引入)
|
||||
// - inputBarCallAgentState.ts:6 态计算 + 文案 + title(v1.2y 已 PASS,34+ 用例不变)
|
||||
// - 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)
|
||||
// - computeGuideText / isGuideTextWarn (from ./inputBarGuideText)
|
||||
// - computeCallAgentState / computeCallAgentBtnText / computeCallAgentBtnTitle
|
||||
// + type CallAgentState (from ./inputBarCallAgentState)
|
||||
//
|
||||
// 这些 helper 仍存在且不变(v1.2y 已 PASS,36+ 用例不修改),
|
||||
// 仅是被 ChatPanel.vue + IntegrationZone.vue 复用,InputBar.vue 不再依赖它们。
|
||||
// 🆕 v1.3.4:单入口 — InputBar 工具栏"人工坐席"按钮
|
||||
// v1.3.3 双入口 → v1.3.4 单入口迁移:
|
||||
// - 原 v1.3.3 错误实施:InputBar 工具栏 32×32 图标按钮 + IntegrationZone 整合区按钮
|
||||
// - 修正:IntegrationZone 按钮已删除,InputBar 工具栏按钮升级为完整版(外框+文字+图标)
|
||||
// helper 文件本身未改动(inputBarCallAgentState.ts / integrationZoneLogic.ts),
|
||||
// v1.2y 34+ 用例 PASS 保持。
|
||||
// computeGuideText / isGuideTextWarn (from ./inputBarGuideText) 仍不引入:
|
||||
// 引导语功能由 IntegrationZone.vue 渲染,InputBar 不重复
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
@@ -171,19 +197,153 @@ function formatErrorDetail(detail: any): string {
|
||||
const store = useConversationStore()
|
||||
|
||||
// ============================================================================
|
||||
// 🆕 v1.3:移除以下代码块(已迁出到 ChatPanel.vue 计算 IntegrationZone props)
|
||||
// - emit 声明 ('call-agent' / 'cancel-queue' / 'end-conversation')
|
||||
// - URGENT_KEYWORDS 常量
|
||||
// - callAgentState / callAgentBtnClass / callAgentBtnIcon /
|
||||
// callAgentBtnText / callAgentBtnTitle / guideText / guideClass computeds
|
||||
// - checkUrgentKeywords 函数
|
||||
// - handleCallAgent 函数
|
||||
// 🆕 v1.3.4:单入口 — 工具栏"人工坐席"操作按钮数据(沿用 v1.3.3 实现)
|
||||
// 设计原则:
|
||||
// - 不 emit 事件(避免动 ChatPanel.vue,9 个 🔴 文件保持不动)
|
||||
// - 直接调 store 副作用 action(坐席离线拦截由 store.shakeAgent 内部处理)
|
||||
// - 6 态 class 沿用 IntegrationZone(原 v1.3 风格)的 .call-agent-btn--* 命名约定
|
||||
// - 图标、文案、title 复用 helper 文件(inputBarCallAgentState.ts)
|
||||
// - click action 映射复用 helper(integrationZoneLogic.ts → computeCallAction)
|
||||
//
|
||||
// 这些 v1.2 y已 PASS 的逻辑现在由 ChatPanel.vue(用于 IntegrationZone 调用)+ IntegrationZone.vue
|
||||
// (内部渲染按钮/引导语)协作实现。helper 文件(inputBarGuideText.ts / inputBarCallAgentState.ts)
|
||||
// 保持不变。
|
||||
// 为什么不复用 integrationZoneLogic.computeIntegrationBtnIcon:
|
||||
// - 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>('')
|
||||
|
||||
@@ -809,24 +969,39 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
/* 输入行:工具栏 + 输入框 + 发送按钮 */
|
||||
/* 输入行:v1.3.2 改为四角按钮的定位上下文容器 */
|
||||
.input-bar__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 工具栏:表情/文件 */
|
||||
/* 🆕 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 {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: auto;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 0 4px 0;
|
||||
padding: 0;
|
||||
max-width: 65%;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* ── 表情选择面板 ── */
|
||||
/* 🆕 v1.3.2:绝对定位到工具栏下方(从工具栏按钮弹出) */
|
||||
.emoji-panel {
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: 44px; /* toolbar 下方: 8 + 36 = 44 */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -835,7 +1010,7 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
max-width: calc(100% - 16px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -906,39 +1081,22 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 输入行:输入框 + 控件区(语音+人工坐席+发送) */
|
||||
.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:移除原 .input-bar__input-row / .input-bar__controls / .input-bar__controls-row
|
||||
三层嵌套水平布局 — 现已改为四角绝对定位单层结构,无需横向控件容器。
|
||||
视觉规范:富文本编辑器风格,输入框为容器,装饰按钮浮在四角。 */
|
||||
|
||||
/* ==========================================================================
|
||||
文本输入框
|
||||
========================================================================== */
|
||||
/* 🆕 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 {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
padding: 44px 80px 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
@@ -964,15 +1122,154 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
/* ==========================================================================
|
||||
发送按钮
|
||||
========================================================================== */
|
||||
/* 🆕 v1.3.2:发送按钮改为输入框右下角绝对定位(富文本编辑器风格) */
|
||||
.input-bar__send-btn {
|
||||
flex-shrink: 0;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
语音按钮(发送按钮左边)
|
||||
🆕 v1.3.4:InputBar 工具栏最后位置 - 操作按钮(人工坐席单入口)
|
||||
- 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 {
|
||||
@@ -1038,9 +1335,15 @@ function handleInputResizeStart(event: MouseEvent): void {
|
||||
========================================================================== */
|
||||
|
||||
/* ==========================================================================
|
||||
语音状态提示条(百度ASR内联录音时显示在输入框上方)
|
||||
语音状态提示条(百度ASR内联录音时显示)
|
||||
🆕 v1.3.2:定位到工具栏右侧(避开左上角按钮的视觉重叠)
|
||||
========================================================================== */
|
||||
.input-bar__voice-status {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
/* 清空工具栏宽度:3 按钮 (3*36) + 2 间隔 (2*4) + 2*8 边距 ≈ 132px */
|
||||
left: 132px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
* 取值只有 'ai_handling' | 'waiting' | 'serving' | 'resolved',导致引导语场景 2
|
||||
* 永远不显示。本测试覆盖 4 种引导语场景,确保未来不会再回归。
|
||||
*
|
||||
* v1.3.5 BugFix:场景 3(坐席离线)引导语移除
|
||||
* - 产品决定不显示离线描述(与状态条一并移除)
|
||||
* - store.agentOnline 业务状态仍保留(v1.2y 拦截依赖)
|
||||
* - 此处测试从 "⚠️ 坐席当前离线..." 改为返回 null
|
||||
*
|
||||
* 覆盖:
|
||||
* - 场景1:无会话
|
||||
* - 场景2:AI <3 轮(ai_handling + canCallAgent=false)✅ P1 Bug 修复回归
|
||||
* - 场景3:坐席离线
|
||||
* - 场景3:坐席离线 → v1.3.5 起返回 null(产品决定不显示)
|
||||
* - 场景4:会话过期(resolved + canReopen=false)
|
||||
* - 非 disabled 态:不显示引导语
|
||||
* - isGuideTextWarn:warn 样式判定
|
||||
@@ -35,23 +40,27 @@ function makeCtx(overrides: Partial<GuideTextContext> = {}): GuideTextContext {
|
||||
|
||||
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 }))
|
||||
expect(result).toBe('💡 先描述一下你遇到的问题,AI 助手会先帮你看看')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// 场景2:AI <3 轮(P1 Bug 修复回归)
|
||||
// ===========================================================================
|
||||
it('场景2:ai_handling + canCallAgent=false → 显示"请继续描述您的问题或需求"', () => {
|
||||
it('场景2:ai_handling + canCallAgent=false → 返回 null(v1.3.2 优化)', () => {
|
||||
// P1 Bug 修复回归:原代码用 'active' 永不匹配,现在用 'ai_handling' 必须能匹配
|
||||
// v1.3.2 BugFix:场景2 不再返回"请继续描述您的问题或需求",改为 null
|
||||
// (用户已与 AI 对话,底部再来"请继续描述"显得啰嗦)
|
||||
const result = computeGuideText(makeCtx({
|
||||
currentConversation: { status: 'ai_handling' },
|
||||
canCallAgent: false,
|
||||
}))
|
||||
expect(result).toBe('请继续描述您的问题或需求')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('场景2 反向:ai_handling + canCallAgent=true → 不显示引导语(active 态)', () => {
|
||||
@@ -69,28 +78,32 @@ describe('InputBar 引导语 — 4 种场景', () => {
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// 场景3:坐席离线
|
||||
// 场景3:坐席离线(v1.3.5 已移除)
|
||||
// ===========================================================================
|
||||
it('场景3:agentOnline=false(且不命中场景2/4) → 显示"⚠️ 坐席当前离线..."', () => {
|
||||
// 隔离场景3 触发条件:canCallAgent=true 避免场景2 命中,canReopen=true 避免场景4 命中
|
||||
it('场景3(v1.3.5 已移除):agentOnline=false(且不命中场景2/4) → 返回 null(UI 不再显示离线描述)', () => {
|
||||
// v1.3.5 BugFix:场景 3 引导语已移除
|
||||
// 隔离场景3 触发条件:canCallAgent=true 避免场景2 命中,canReopen=true 避免场景4 命中
|
||||
// 原期望:'⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
|
||||
// 新期望:null(产品决定不显示离线描述,仅 store.agentOnline 业务状态保留)
|
||||
const result = computeGuideText(makeCtx({
|
||||
canCallAgent: true, // 避免场景2 命中(ai_handling + canCallAgent=false)
|
||||
canReopen: true, // 避免场景4 命中(resolved + canReopen=false)
|
||||
agentOnline: false, // 命中场景3
|
||||
agentOnline: false, // 原命中场景3,v1.3.5 起不再消费此状态
|
||||
}))
|
||||
expect(result).toBe('⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('场景优先级:ai_handling + canCallAgent=false → 场景 2 命中(即使 agentOnline=false)', () => {
|
||||
// 设计意图:当用户处于 AI < 3 轮场景时,无论坐席是否离线,应继续引导用户描述问题
|
||||
// 因此场景 2 优先级 > 场景 3(与 helper 实现顺序一致)
|
||||
// 场景 3 只在 canCallAgent=true(即 AI 已尝试 ≥ 3 轮)时才显示
|
||||
it('场景优先级:ai_handling + canCallAgent=false → 不返回任何引导语(v1.3.2 场景 2 返回 null)', () => {
|
||||
// 设计意图:v1.3.2 优化后,场景 2 返回 null,因此即使同时满足场景 3 触发条件
|
||||
// (agentOnline=false),场景 2 优先级 > 场景 3,最终也返回 null
|
||||
//(用户已与 AI 对话,避免重复引导)
|
||||
// v1.3.5:场景 3 也返回 null,此用例覆盖两个 null 路径的合并行为
|
||||
const result = computeGuideText(makeCtx({
|
||||
currentConversation: { status: 'ai_handling' },
|
||||
canCallAgent: false,
|
||||
agentOnline: false,
|
||||
}))
|
||||
expect(result).toBe('请继续描述您的问题或需求')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
@@ -171,14 +184,29 @@ describe('InputBar 引导语 — P1 Bug 回归保护', () => {
|
||||
expect(buggyResult).toBeNull()
|
||||
|
||||
// 新正确行为:用 'ai_handling'
|
||||
// v1.3.2 BugFix:场景 2 改返回 null,'ai_handling' 必须能匹配但返回 null
|
||||
const correctResult = computeGuideText(makeCtx({
|
||||
currentConversation: { status: 'ai_handling' },
|
||||
canCallAgent: false,
|
||||
}))
|
||||
expect(correctResult).toBe('请继续描述您的问题或需求')
|
||||
expect(correctResult).toBeNull()
|
||||
|
||||
// 断言两者不同(确保修复确实改变了行为)
|
||||
expect(buggyResult).not.toBe(correctResult)
|
||||
// v1.3.2 后,buggy 和 correct 路径在 canCallAgent=false 下都返回 null
|
||||
// (buggy:'active' 不在 ConversationInfo.status 取值范围,走完所有场景不命中;
|
||||
// correct:v1.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 类型约束,运行时防御)', () => {
|
||||
@@ -195,9 +223,10 @@ describe('InputBar 引导语 — P1 Bug 回归保护', () => {
|
||||
}
|
||||
|
||||
// helper 应在 valid status 下正确行为
|
||||
// v1.3.2 BugFix:场景 2 改返回 null,'ai_handling' 是有效 status 但返回 null
|
||||
expect(computeGuideText(makeCtx({
|
||||
currentConversation: { status: 'ai_handling' },
|
||||
canCallAgent: false,
|
||||
}))).toBe('请继续描述您的问题或需求')
|
||||
}))).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@
|
||||
* 设计要点(PRD §3.3):
|
||||
* 1. 不显示 {n}/3 进度数字 — 避免催促感
|
||||
* 2. 所有引导语鼓励"先 AI" — 不给坐席端制造流量压力
|
||||
* 3. 离线情况给出口 — 不直接禁用可达性,留"刷新重试"
|
||||
* 3. ~~离线情况给出口~~ — v1.3.5 移除场景 3 引导语(产品决定不显示离线描述)
|
||||
* 4. 过期明确引导"开新会话"
|
||||
*
|
||||
* 渲染条件与按钮态共享同一 callAgentState,永不同步错位。
|
||||
@@ -21,7 +21,14 @@
|
||||
export interface GuideTextContext {
|
||||
/** 当前会话(null 表示无会话) */
|
||||
currentConversation: { status: string } | null
|
||||
/** 坐席是否在线 */
|
||||
/**
|
||||
* 坐席是否在线
|
||||
* v1.3.5:UI 不再显示离线描述(场景 3 已移除),但保留字段以便:
|
||||
* 1. 兼容 ChatPanel 等调用方的 GuideTextContext 调用约定
|
||||
* 2. 未来若恢复场景 3 引导语,可直接使用,无需改调用方
|
||||
* 注意:业务侧 store.agentOnline 状态仍由 inputBarCallAgentState.ts 的 6 态计算使用,
|
||||
* 这里仅是 UI 引导语不再消费它。
|
||||
*/
|
||||
agentOnline: boolean
|
||||
/** 是否可以呼叫人工坐席(AI 回复 >= 3 次) */
|
||||
canCallAgent: boolean
|
||||
@@ -30,37 +37,57 @@ export interface GuideTextContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 InputBar 引导语(4 种场景)
|
||||
* 计算 InputBar 引导语(4 种场景 → v1.3.5 起实际仅 3 场景生效)
|
||||
*
|
||||
* 仅 disabled 态显示引导语,其他态不显示。
|
||||
*
|
||||
* 场景优先级(与按钮态计算共享同一调用栈,永不同步错位):
|
||||
* 1. 无会话 → "💡 先描述一下你遇到的问题,AI 助手会先帮你看看"
|
||||
* 2. AI <3 轮(ai_handling + canCallAgent=false)→ "请继续描述您的问题或需求"
|
||||
* 3. 坐席离线 → "⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试"
|
||||
* 1. 无会话 → null(顶部 ChatPanel 已显示"输入问题咨询..."文案,避免重复)
|
||||
* 2. AI <3 轮(ai_handling + canCallAgent=false)→ null(v1.3.2 优化:用户已与 AI 对话,无需再提示"请继续描述")
|
||||
* 3. ~~坐席离线~~ → v1.3.5 移除:返回 null(产品决定不显示离线描述)
|
||||
* 4. 会话过期(resolved + canReopen=false)→ "⏰ 上一会话已过期,开始新对话吧"
|
||||
*
|
||||
* 注意:场景 2 使用 'ai_handling' 而非 'active'(ConversationInfo.status 标准值)。
|
||||
* '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 {
|
||||
const { currentConversation, agentOnline, canCallAgent, canReopen } = ctx
|
||||
// v1.3.5:场景 3 已移除,agentOnline 仅解构保留以便未来回退,业务上不影响其他场景
|
||||
void agentOnline
|
||||
|
||||
// 场景1:无会话
|
||||
// 场景1:无会话(v1.3.1 BugFix:返回 null,避免与 ChatPanel 顶部 "输入问题咨询..." 重复)
|
||||
if (!currentConversation) {
|
||||
return '💡 先描述一下你遇到的问题,AI 助手会先帮你看看'
|
||||
return null
|
||||
}
|
||||
|
||||
// 场景2:AI <3 轮(ai_handling + canCallAgent=false)
|
||||
// P1 Bug 修复:必须用 'ai_handling',不能用 'active'
|
||||
// v1.3.2 BugFix:返回 null(用户已与 AI 对话,底部再来"请继续描述"显得啰嗦)
|
||||
if (currentConversation.status === 'ai_handling' && !canCallAgent) {
|
||||
return '请继续描述您的问题或需求'
|
||||
return null // v1.3.2 优化:避免重复引导
|
||||
}
|
||||
|
||||
// 场景3:坐席离线
|
||||
if (!agentOnline) {
|
||||
return '⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
|
||||
}
|
||||
// v1.3.5:场景 3 已移除(产品决定不显示离线描述)
|
||||
// 原逻辑:
|
||||
// if (!agentOnline) {
|
||||
// return '⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试'
|
||||
// }
|
||||
// 业务侧 store.agentOnline 状态仍保留(v1.2y shakeAgent 拦截依赖),
|
||||
// 仅 UI 引导语不再消费此状态。
|
||||
|
||||
// 场景4:会话过期(resolved + canReopen=false)
|
||||
if (currentConversation.status === 'resolved' && !canReopen) {
|
||||
@@ -73,6 +100,7 @@ export function computeGuideText(ctx: GuideTextContext): string | null {
|
||||
|
||||
/**
|
||||
* 判断引导语是否为 warn 样式(含 ⚠️ 离线提示)
|
||||
* v1.3.5:场景 3 已移除,warn 样式基本不再触发;保留函数以防回退
|
||||
*/
|
||||
export function isGuideTextWarn(text: string | null): boolean {
|
||||
return !!text && text.includes('⚠️')
|
||||
|
||||
@@ -266,7 +266,9 @@ describe('IntegrationZone — 引导语渲染 shouldRenderGuideText', () => {
|
||||
})
|
||||
|
||||
it('正常文本 → true(渲染)', () => {
|
||||
expect(shouldRenderGuideText('请继续描述您的问题或需求')).toBe(true)
|
||||
// v1.3.2:场景 2 已改 return null,IntegrationZone 不会收到该文案
|
||||
// 此测试样本需要更新为真实场景(场景 3 的 warn 文案)
|
||||
expect(shouldRenderGuideText('⚠️ 坐席当前离线,建议先用 AI 解答;如紧急可刷新重试')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* 复用关系:
|
||||
* - 复用 inputBarCallAgentState.ts 的 6 态计算(不再重复实现)
|
||||
* - 复用 utils/shiftHours.ts 的状态条文案生成
|
||||
* - ~~复用 utils/shiftHours.ts 的状态条文案生成~~(v1.3.5 整体删除)
|
||||
* - 复用 inputBarGuideText.ts 的引导语 warn 样式判定
|
||||
*
|
||||
* 不变性约束(v1.2x 已 PASS 6 态):
|
||||
@@ -34,10 +34,10 @@ import { isGuideTextWarn } from './inputBarGuideText'
|
||||
|
||||
/**
|
||||
* IntegrationZone Props
|
||||
* - agentOnline: 状态条在线/离线 + QueueCapsule 排队态降级判定的统一依据
|
||||
* - shiftHours: 状态条显示的班次(默认 '9:00-18:00',v1.4 由 store.shiftHours 注入)
|
||||
* - agentOnline: QueueCapsule 排队态降级判定的统一依据(v1.3.5 状态条已删除)
|
||||
* - shiftHours: ~~状态条显示的班次~~(v1.3.5 状态条已删除),字段保留以备 v1.4 接后端 API
|
||||
* - callAgentState: 6 态按钮(disabled/active/urgent/waiting/end/reopen)
|
||||
* - guideText: 4 种场景引导语文本(null 表示不显示引导语)
|
||||
* - guideText: 4 种场景引导语文本(null 表示不显示引导语,v1.3.5 起场景 3 也返回 null)
|
||||
*/
|
||||
export interface IntegrationZoneProps {
|
||||
agentOnline: boolean
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
|
||||
import { useConversationStore } from '@/stores/conversation'
|
||||
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'
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -535,6 +540,28 @@ export function useH5WebSocket() {
|
||||
}
|
||||
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 已移除:用户要求取消该提示消息)
|
||||
// ==================================================================
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useRecommendStore, type RecommendCard } from './recommend'
|
||||
import { useH5WebSocket } from '@/composables/useH5WebSocket'
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// localStorage polyfill(避免装 happy-dom/jsdom)
|
||||
@@ -32,11 +33,39 @@ const _localStoragePolyfill = {
|
||||
// 注入到 globalThis(这样 vitest node 环境也能用)
|
||||
;(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', () => {
|
||||
beforeEach(() => {
|
||||
// 隔离每个测试:fresh Pinia + fresh localStorage
|
||||
setActivePinia(createPinia())
|
||||
_ls.clear()
|
||||
MockWebSocket.instances.length = 0
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
@@ -420,6 +449,89 @@ describe('recommendStore — 持久化 + 话题切换 trigger', () => {
|
||||
} as any)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
@@ -163,15 +163,27 @@ export const useRecommendStore = defineStore('recommend', () => {
|
||||
*/
|
||||
function pushCard(card: RecommendCard): boolean {
|
||||
// 1. 同源抑制(30 分钟窗口)
|
||||
// 业务约束:progress 层是"同一 DB 记录的状态推进"(webhook 回调 → update_progress → WS 推送),
|
||||
// 同一 RecommendProgress.id 后续推送必须替换旧卡,绝不能被抑制阻断(QA Round 1 BUG)。
|
||||
// 其他 layer(L1/L2/L3/A)是"独立推荐事件",30 分钟同 id 应抑制以避免重复刷屏。
|
||||
// 为什么用 layer 白名单而不是黑名单:
|
||||
// - 新增 layer(如 future kind)默认走抑制(保守策略,避免误刷)
|
||||
// - progress 是当前唯一已知"同一 id 多次有效推送"的 layer
|
||||
const now = Date.now()
|
||||
const suppressKey = `${card.layer}:${card.id}`
|
||||
const lastSuppress = suppressMap.value.get(suppressKey)
|
||||
if (lastSuppress && now - lastSuppress < SUPPRESS_WINDOW_MS) {
|
||||
console.log('[RecommendStore] 同源抑制跳过:', suppressKey)
|
||||
return false
|
||||
const isProgress = card.layer === 'progress'
|
||||
|
||||
if (!isProgress) {
|
||||
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 分钟内不重试;成功写入也记录)
|
||||
suppressMap.value.set(suppressKey, now)
|
||||
// progress 层跳过 suppressMap 写入:每次 push 都是真实状态推进,应无条件走替换路径
|
||||
// (即便 progress 卡片已存在,新推送的 progress/status/current_step 必须更新到 store)
|
||||
|
||||
// 2. 选目标列表
|
||||
let targetList: typeof l1Cards
|
||||
|
||||
Reference in New Issue
Block a user