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
|
// 企微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.5:themeStore 实例已删除(按钮 DOM 不再渲染,无需持有 store 引用)
|
||||||
const employeeStore = useEmployeeStore()
|
const employeeStore = useEmployeeStore()
|
||||||
const integrationZoneStore = useIntegrationZoneStore()
|
const integrationZoneStore = useIntegrationZoneStore()
|
||||||
|
|
||||||
|
|||||||
@@ -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.2:padding-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.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)
|
// 🆕 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 映射复用 helper(integrationZoneLogic.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.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 {
|
.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:无会话
|
||||||
* - 场景2:AI <3 轮(ai_handling + canCallAgent=false)✅ P1 Bug 修复回归
|
* - 场景2:AI <3 轮(ai_handling + canCallAgent=false)✅ P1 Bug 修复回归
|
||||||
* - 场景3:坐席离线
|
* - 场景3:坐席离线 → v1.3.5 起返回 null(产品决定不显示)
|
||||||
* - 场景4:会话过期(resolved + canReopen=false)
|
* - 场景4:会话过期(resolved + canReopen=false)
|
||||||
* - 非 disabled 态:不显示引导语
|
* - 非 disabled 态:不显示引导语
|
||||||
* - isGuideTextWarn:warn 样式判定
|
* - isGuideTextWarn:warn 样式判定
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// 场景2:AI <3 轮(P1 Bug 修复回归)
|
// 场景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' 必须能匹配
|
// 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('场景3:agentOnline=false(且不命中场景2/4) → 显示"⚠️ 坐席当前离线..."', () => {
|
it('场景3(v1.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, // 原命中场景3,v1.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 取值范围,走完所有场景不命中;
|
||||||
|
// 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 类型约束,运行时防御)', () => {
|
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)→ null(v1.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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 场景2:AI <3 轮(ai_handling + canCallAgent=false)
|
// 场景2:AI <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 null,IntegrationZone 不会收到该文案
|
||||||
|
// 此测试样本需要更新为真实场景(场景 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
// 其他 layer(L1/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
|
||||||
|
|||||||
Reference in New Issue
Block a user