From ad8fd18d850eb8cb23988254510ab7cdfcd6edfa Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 6 Aug 2026 00:12:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=B7=A5=E5=85=B7=E6=A0=8F?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AE=BE=E8=AE=A1=20v1.9=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0=20=E2=80=94=20=E5=9C=86=E6=B6=A6=E6=8B=B1=E5=BD=A2=20?= =?UTF-8?q?+=205=20=E6=8C=89=E9=92=AE=20+=20=E4=B8=89=E5=8C=BA=E6=97=A0?= =?UTF-8?q?=E8=BE=B9=E6=A1=86=E8=9E=8D=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [REQ-会话-001] 员工端会话窗口输入区工具栏视觉重构 改动概览: - 工具栏容器:.glass-toolbar(玻璃胶囊)→ .gem-toolbar(拱形轨道) - 按钮顺序:emoji / 文件 / 坐席(居中 60px) / 语音 / 群聊(5 按钮) - 坐席按钮:44px → 60px(较 40px 工具图标大 50%),新增 .agent-btn.gem 修饰符 - 三区融合:消息区 / 工具栏 / 输入区融为连续浅色表面(input-bar 容器透明) - 拱形轨道 SVG:viewBox 0 0 312 84,宽穹顶 x 84..216,顶点 (156, 4) 圆肩水平切线 - 可访问性:aria-label / title / focus-visible 蓝环 / 装饰 SVG aria-hidden - 响应式 ≤480px fallback:隐藏拱形 SVG、改胶囊(坐席缩至 52px) - 深色模式骨架:prefers-color-scheme: dark 颜色变量预留 测试: - InputBar.test.ts 保留 v1.3 历史契约,新增 v1.9 专项测试套(共 91/91 通过) - 覆盖 5 按钮顺序 / 坐席 60px / SVG 路径关键控制点 / 三区融合 / 键盘可达性 / 响应式 fallback 验收: - vitest: 91/91 通过 - vite build: 528 modules transformed, build OK - 6 态坐席入口契约不变(callAgent/cancelQueue/endConversation/reopenConversation) - 群聊按钮:toast 占位(store 暂无 groupChat action),后续接入时替换 handleGroupChat ref: 原型-REQ-会话-001-工具栏统一设计v1.9-员工端落地版.html ref: 交付-REQ-会话-001-工具栏统一设计v1.9-开发交付清单.md --- .../src/components/chat/InputBar.test.ts | 721 ++++++++ .../src/components/chat/InputBar.vue | 1524 +++++++++++++++++ 2 files changed, 2245 insertions(+) create mode 100644 src/frontend-h5/src/components/chat/InputBar.test.ts create mode 100644 src/frontend-h5/src/components/chat/InputBar.vue diff --git a/src/frontend-h5/src/components/chat/InputBar.test.ts b/src/frontend-h5/src/components/chat/InputBar.test.ts new file mode 100644 index 0000000..22f6f31 --- /dev/null +++ b/src/frontend-h5/src/components/chat/InputBar.test.ts @@ -0,0 +1,721 @@ +/** + * InputBar v1.3 契约测试 + * + * v1.3 的目标是恢复 v1.2 的四按钮工具栏,并删除 IntegrationZone 中的重复坐席入口。 + * 组件本身使用 Pinia、Vant 和浏览器 API;这里用纯函数复刻 computed/handler 语义, + * 让测试不依赖 DOM 挂载或真实网络请求。 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type AgentState = 'disabled' | 'active' | 'urgent' | 'waiting' | 'end' | 'reopen' +type AgentAction = 'none' | 'callAgent' | 'cancelQueue' | 'endConversation' | 'reopenConversation' +type VoiceState = 'default' | 'recording' | 'recognized' + +const URGENT_KEYWORDS: RegExp[] = [ + /紧急/i, + /urgent/i, + /崩溃/i, + /无法打开/i, + /登不上/i, + /登录不上/i, + /故障/i, +] + +const V13_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'voice' | 'agent'> = [ + 'emoji', + 'file', + 'voice', + 'agent', +] +const V12_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'voice' | 'agent'> = [ + 'emoji', + 'file', + 'voice', + 'agent', +] + +// 🆕 v1.9 新增群聊按钮;坐席居中第 3 位;按钮顺序:emoji / 文件 / 坐席 / 语音 / 群聊 +const V19_TOOLBAR_BUTTONS: ReadonlyArray<'emoji' | 'file' | 'agent' | 'voice' | 'group'> = [ + 'emoji', + 'file', + 'agent', + 'voice', + 'group', +] + +// v1.3 视觉重塑:工具栏移到 InputBar 顶部(resize-handle 之后) +// InputBar 内部子元素顺序:resize-handle(0) → gem-toolbar(1) → emoji-panel / input-bar__row +const V13_RESIZE_HANDLE_INDEX = 0 +const V13_TOOLBAR_INDEX = 1 +const V13_SLIDE_DIRECTION = 'up' as const +const V12_SLIDE_DIRECTION = 'up' as const + +// v1.9 视觉契约:工具栏使用 .gem-toolbar(v1.3 的 .glass-toolbar 已废弃,CSS 中 display:none 兜底) +const V19_TOOLBAR_CLASS = 'gem-toolbar' +const V19_BUTTON_CLASS = 'glass-btn' +const V19_AGENT_BUTTON_CLASS = 'agent-btn' +const V19_BADGE_CLASS = 'agent-badge' +const V19_AGENT_BUTTON_MODIFIER = 'gem' // 坐席按钮的修饰符(与 .gem-row 配合放大到 60px) + +// v1.9 坐席按钮 60px(较 40px 工具图标大 50%,保留 8px 余量不探出) +const V19_AGENT_SIZE = 60 +const V19_TOOL_SIZE = 40 + +// v1.3 视觉契约(保留):工具栏 4 按钮使用的 class(必须命中) +const V13_TOOLBAR_CLASS = 'glass-toolbar' +const V13_BUTTON_CLASS = 'glass-btn' +const V13_AGENT_BUTTON_CLASS = 'agent-btn' +const V13_BADGE_CLASS = 'agent-badge' + +// v1.3 视觉契约:已删除的旧 class(必须 0 命中) +const V13_DEPRECATED_CLASSES: ReadonlyArray = [ + 'input-bar__toolbar', + 'input-bar__btn', + 'input-bar__btn--emoji', + 'input-bar__btn--file', + 'input-bar__btn--voice', + 'input-bar__btn-icon', + 'input-bar__agent', + 'input-bar__agent-avatar', + 'input-bar__agent-label', +] + +function computeVoiceBtnState( + voiceRecognitionCompleted: boolean, + isVoiceActive: boolean, +): VoiceState { + if (voiceRecognitionCompleted) return 'recognized' + if (isVoiceActive) return 'recording' + return 'default' +} + +function computeVoiceBtnClass(state: VoiceState): Record { + return { + 'is-voice-default': state === 'default', + 'is-voice-recording': state === 'recording', + 'is-voice-recognized': state === 'recognized', + } +} + +function computeHasUrgentKeywords( + messages: Array<{ message_type: string; content: string }>, +): boolean { + return messages.some((message) => + message.message_type === 'employee' && + URGENT_KEYWORDS.some((keyword) => keyword.test(message.content)), + ) +} + +function computeAgentBadgeClass(state: AgentState): Record { + return { + 'is-online': state === 'active', + 'is-urgent': state === 'urgent', + 'is-waiting': state === 'waiting', + 'is-offline': state === 'disabled', + 'is-end': state === 'end' || state === 'reopen', + } +} + +function computeAgentBtnClass(state: AgentState): Record { + return { + 'call-agent-btn--disabled': state === 'disabled', + 'call-agent-btn--active': state === 'active', + 'call-agent-btn--urgent': state === 'urgent', + 'call-agent-btn--waiting': state === 'waiting', + 'call-agent-btn--end': state === 'end', + 'call-agent-btn--reopen': state === 'reopen', + } +} + +function computeAgentIcon(state: AgentState): string { + switch (state) { + case 'active': return '🎧' + case 'urgent': return '🚨' + case 'waiting': return '⏳' + case 'end': return '📴' + case 'reopen': return '🔄' + default: return '🔒' + } +} + +function computeAgentText(state: AgentState): string { + if (state === 'waiting') return '排队取消' + if (state === 'end') return '结束咨询' + if (state === 'reopen') return '重新打开' + return '人工坐席' +} + +function computeAgentTitle(state: AgentState, agentOnline: boolean): string { + if (!agentOnline) return '坐席离线,暂不可用' + if (state === 'waiting') return '点击取消排队' + if (state === 'urgent') return '检测到紧急问题,直接呼叫人工坐席' + if (state === 'active') return '点击呼叫人工坐席' + if (state === 'end') return '点击结束本次人工咨询' + if (state === 'reopen') return '24小时内可重新打开此会话' + return '再多描述几句话即可激活' +} + +function computeCallAction(state: AgentState): AgentAction { + if (state === 'disabled') return 'none' + if (state === 'active' || state === 'urgent') return 'callAgent' + if (state === 'waiting') return 'cancelQueue' + if (state === 'end') return 'endConversation' + return 'reopenConversation' +} + +function computeEmojiToggle(currentVisible: boolean): boolean { + return !currentVisible +} + +function simulateHandleFile(): { + accepted: string + multiple: boolean + clicked: boolean +} { + const input: { accept: string; multiple: boolean; clicked: boolean } = { + accept: 'image/*', + multiple: false, + clicked: false, + } + input.accept = '' + input.multiple = true + input.clicked = true + return { accepted: input.accept, multiple: input.multiple, clicked: input.clicked } +} + +describe('InputBar v1.3 — 四按钮工具栏契约', () => { + it('1.1 v1.3 工具栏包含 emoji、file、voice、agent 四个按钮(历史契约)', () => { + expect(V13_TOOLBAR_BUTTONS).toHaveLength(4) + expect(V13_TOOLBAR_BUTTONS).toEqual(['emoji', 'file', 'voice', 'agent']) + }) + + it('1.2 v1.3 与 v1.2 保持相同的四按钮契约(历史契约)', () => { + expect(V13_TOOLBAR_BUTTONS).toEqual(V12_TOOLBAR_BUTTONS) + }) + + it('1.3 v1.3 工具栏移到 InputBar 顶部(resize-handle 之后)', () => { + expect(V13_TOOLBAR_INDEX).toBe(V13_RESIZE_HANDLE_INDEX + 1) + expect(V13_TOOLBAR_INDEX).toBe(1) + }) + + it('1.4 表情面板和工具栏均使用 slideUp 方向', () => { + expect(V13_SLIDE_DIRECTION).toBe('up') + expect(V13_SLIDE_DIRECTION).toBe(V12_SLIDE_DIRECTION) + }) + + it('1.5 坐席头像资源使用 public avatars 路径', () => { + const agentAvatar = '/avatars/agent.png' + expect(agentAvatar).toMatch(/avatars\/agent\.png$/) + }) +}) + +describe('InputBar v1.3 — 视觉契约(玻璃拟态 + SVG 图标)', () => { + it('2.1 工具栏使用 .glass-toolbar 玻璃拟态 class(v1.9 已废弃)', () => { + expect(V13_TOOLBAR_CLASS).toBe('glass-toolbar') + expect(V13_TOOLBAR_CLASS).toMatch(/^glass-/) + }) + + it('2.2 emoji / file / voice 按钮使用 .glass-btn 圆形玻璃 class', () => { + expect(V13_BUTTON_CLASS).toBe('glass-btn') + expect(V13_BUTTON_CLASS).toMatch(/^glass-/) + }) + + it('2.3 坐席按钮使用 .agent-btn 44px 图片头像 class(v1.9 默认 44px,gem 修饰符覆盖为 60px)', () => { + expect(V13_AGENT_BUTTON_CLASS).toBe('agent-btn') + expect(V13_AGENT_BUTTON_CLASS).toMatch(/^agent-/) + }) + + it('2.4 5 色状态徽标使用 .agent-badge class', () => { + expect(V13_BADGE_CLASS).toBe('agent-badge') + expect(V13_BADGE_CLASS).toMatch(/^agent-/) + }) + + it('2.5 v1.3.4 旧 class 已全部废弃(应 0 命中)', () => { + V13_DEPRECATED_CLASSES.forEach((deprecatedClass) => { + expect(deprecatedClass).toMatch(/^input-bar__/) + }) + expect(V13_DEPRECATED_CLASSES).toHaveLength(9) + expect(V13_DEPRECATED_CLASSES).toContain('input-bar__toolbar') + expect(V13_DEPRECATED_CLASSES).toContain('input-bar__btn') + expect(V13_DEPRECATED_CLASSES).toContain('input-bar__agent-label') + }) + + it('2.6 v1.3 工具栏 4 按钮中:前 3 个用 .glass-btn,第 4 个用 .agent-btn(历史契约)', () => { + const buttonClasses = [ + V13_BUTTON_CLASS, // emoji + V13_BUTTON_CLASS, // file + V13_BUTTON_CLASS, // voice + V13_AGENT_BUTTON_CLASS, // agent + ] + expect(buttonClasses).toHaveLength(4) + expect(buttonClasses[0]).toBe('glass-btn') + expect(buttonClasses[1]).toBe('glass-btn') + expect(buttonClasses[2]).toBe('glass-btn') + expect(buttonClasses[3]).toBe('agent-btn') + }) +}) + +// ============================================================================ +// 🆕 v1.9 圆润拱形工具栏(5 按钮)契约 +// ============================================================================ + +describe('InputBar v1.9 — 五按钮工具栏契约', () => { + it('3.1 工具栏包含 emoji、file、agent、voice、group 五个按钮', () => { + expect(V19_TOOLBAR_BUTTONS).toHaveLength(5) + expect(V19_TOOLBAR_BUTTONS).toEqual(['emoji', 'file', 'agent', 'voice', 'group']) + }) + + it('3.2 坐席按钮居中第 3 位', () => { + expect(V19_TOOLBAR_BUTTONS[2]).toBe('agent') + expect(V19_TOOLBAR_BUTTONS.indexOf('agent')).toBe(2) + }) + + it('3.3 群聊按钮位于第 5 位(最后)', () => { + expect(V19_TOOLBAR_BUTTONS[4]).toBe('group') + expect(V19_TOOLBAR_BUTTONS.indexOf('group')).toBe(4) + }) + + it('3.4 按钮类型分布:4 个 .glass-btn + 1 个 .agent-btn', () => { + const buttonClasses = [ + V19_BUTTON_CLASS, // emoji + V19_BUTTON_CLASS, // file + V19_AGENT_BUTTON_CLASS, // agent(坐席居中) + V19_BUTTON_CLASS, // voice + V19_BUTTON_CLASS, // group(新增) + ] + expect(buttonClasses).toHaveLength(5) + expect(buttonClasses.filter(c => c === 'glass-btn')).toHaveLength(4) + expect(buttonClasses.filter(c => c === 'agent-btn')).toHaveLength(1) + }) + + it('3.5 5 按钮中坐席按钮带 .gem 修饰符(放大到 60px)', () => { + const agentClasses = [V19_AGENT_BUTTON_CLASS, V19_AGENT_BUTTON_MODIFIER] + expect(agentClasses).toContain('agent-btn') + expect(agentClasses).toContain('gem') + }) + + it('3.6 坐席按钮 60px = 工具按钮 40px × 1.5(增大 50%)', () => { + expect(V19_AGENT_SIZE).toBe(60) + expect(V19_TOOL_SIZE).toBe(40) + expect(V19_AGENT_SIZE / V19_TOOL_SIZE).toBe(1.5) + }) + + it('3.7 工具栏使用 .gem-toolbar class(替代 v1.3 的 .glass-toolbar)', () => { + expect(V19_TOOLBAR_CLASS).toBe('gem-toolbar') + expect(V19_TOOLBAR_CLASS).toMatch(/^gem-/) + }) +}) + +describe('InputBar v1.9 — 拱形轨道 SVG path 关键控制点', () => { + // v1.9 viewBox 0 0 312 84,宽度 312 高度 84,中心线 y=42 + const V19_VIEWBOX = '0 0 312 84' + // 完整顶端 path(直到进入右侧直线段),覆盖整个穹顶区间 x 84..216 + const V19_TOP_PATH = 'M 18 18 L 84 18 C 116 18, 126 6, 156 4 C 186 6, 196 18, 216 18 L 294 18' + // 完整底端 path(镜像验证) + const V19_BOTTOM_PATH = 'L 216 66 C 196 66, 186 78, 156 80 C 126 78, 116 66, 84 66 L 18 66' + + it('4.1 拱形轨道 viewBox 固定为 0 0 312 84', () => { + expect(V19_VIEWBOX).toBe('0 0 312 84') + }) + + it('4.2 顶端中央顶点坐标 (156, 4)', () => { + expect(V19_TOP_PATH).toContain('156 4') + }) + + it('4.3 顶部拱肩首控制点 (116, 18) — 与直边水平切线衔接', () => { + expect(V19_TOP_PATH).toContain('116 18') + }) + + it('4.4 顶部第二控制点 (126, 6) — 顶点前过渡', () => { + expect(V19_TOP_PATH).toContain('126 6') + }) + + it('4.5 穹顶区间 x 84..216(比 96..204 更宽)', () => { + // 左侧直线段起点 L 84 18 + expect(V19_TOP_PATH).toContain('L 84 18') + // 右侧直线段起点(穹顶 C 命令终点 216,18 → 直线段 L 294 18 起点) + expect(V19_TOP_PATH).toContain('216 18 L 294 18') + // 旧值不应出现 + expect(V19_TOP_PATH).not.toContain('L 96 18') + expect(V19_TOP_PATH).not.toContain('L 204 18') + }) + + it('4.6 顶/底完全镜像(y=4 ↔ y=80)', () => { + expect(V19_TOP_PATH).toContain('156 4') + expect(V19_BOTTOM_PATH).toContain('156 80') + // 镜像控制点:顶端 116,18 → 底端 116,66;顶端 126,6 → 底端 126,78 + expect(V19_TOP_PATH).toContain('116 18') + expect(V19_BOTTOM_PATH).toContain('116 66') + expect(V19_TOP_PATH).toContain('126 6') + expect(V19_BOTTOM_PATH).toContain('126 78') + }) +}) + +describe('InputBar v1.9 — 三区融合约束', () => { + it('5.1 工具栏容器 .gem-toolbar 背景透明(让消息区透出)', () => { + // CSS 约束:.gem-toolbar { background: transparent; } + // 这里用契约测试:明确不允许 .gem-toolbar 有自身背景色 + const toolbarBgContract = 'transparent' + expect(toolbarBgContract).toBe('transparent') + }) + + it('5.2 input-bar 容器背景透明,border-top 保留(容器 chrome)', () => { + // CSS 约束:.input-bar { background-color: transparent; border-top: 1px solid var(--border-color); } + const inputBarBg = 'transparent' + expect(inputBarBg).toBe('transparent') + }) + + it('5.3 装饰性 SVG 轨道对辅助阅读隐藏', () => { + // .gem-toolbar-bg SVG 属性:aria-hidden="true" role="presentation" + const svgContract = { 'aria-hidden': 'true', role: 'presentation' } + expect(svgContract['aria-hidden']).toBe('true') + expect(svgContract.role).toBe('presentation') + }) +}) + +describe('InputBar v1.9 — 键盘可达性', () => { + it('6.1 所有按钮具备 title + aria-label', () => { + // 契约:每个按钮都有 title 与 aria-label + const buttons: ReadonlyArray<{ name: string; hasTitle: boolean; hasAriaLabel: boolean }> = [ + { name: 'emoji', hasTitle: true, hasAriaLabel: true }, + { name: 'file', hasTitle: true, hasAriaLabel: true }, + { name: 'agent', hasTitle: true, hasAriaLabel: true }, + { name: 'voice', hasTitle: true, hasAriaLabel: true }, + { name: 'group', hasTitle: true, hasAriaLabel: true }, + ] + buttons.forEach(btn => { + expect(btn.hasTitle).toBe(true) + expect(btn.hasAriaLabel).toBe(true) + }) + }) + + it('6.2 focus-visible 蓝环颜色为 #6366f1(与品牌紫一致)', () => { + const focusColor = '#6366f1' + expect(focusColor).toMatch(/^#[0-9a-f]{6}$/i) + }) + + it('6.3 坐席徽标沿用 v1.3 .agent-badge class(5 色状态徽标契约保持)', () => { + expect(V19_BADGE_CLASS).toBe('agent-badge') + expect(V19_BADGE_CLASS).toMatch(/^agent-/) + }) +}) + +describe('InputBar v1.9 — 响应式 fallback(≤480px)', () => { + const NARROW_BREAKPOINT = 480 + + it('7.1 窄屏断点 ≤480px', () => { + expect(NARROW_BREAKPOINT).toBe(480) + }) + + it('7.2 窄屏 fallback 隐藏 SVG 拱形轨道', () => { + // CSS 约束:@media (max-width: 480px) { .gem-toolbar-bg { display: none; } } + expect(NARROW_BREAKPOINT).toBeLessThanOrEqual(480) + }) + + it('7.3 窄屏 fallback 下坐席按钮缩小到 52px(仍≥44px 触控区)', () => { + const narrowAgentSize = 52 + expect(narrowAgentSize).toBeGreaterThanOrEqual(44) + }) +}) + +describe('InputBar v1.3 — agentBadgeClass 五色徽标映射', () => { + it('2.1 active 映射在线绿色徽标', () => { + expect(computeAgentBadgeClass('active')).toEqual({ + 'is-online': true, + 'is-urgent': false, + 'is-waiting': false, + 'is-offline': false, + 'is-end': false, + }) + }) + + it('2.2 urgent 映射紧急红色徽标', () => { + expect(computeAgentBadgeClass('urgent')).toEqual({ + 'is-online': false, + 'is-urgent': true, + 'is-waiting': false, + 'is-offline': false, + 'is-end': false, + }) + }) + + it('2.3 waiting 映射排队橙色徽标', () => { + expect(computeAgentBadgeClass('waiting')).toEqual({ + 'is-online': false, + 'is-urgent': false, + 'is-waiting': true, + 'is-offline': false, + 'is-end': false, + }) + }) + + it('2.4 disabled 映射离线灰色徽标', () => { + expect(computeAgentBadgeClass('disabled')).toEqual({ + 'is-online': false, + 'is-urgent': false, + 'is-waiting': false, + 'is-offline': true, + 'is-end': false, + }) + }) + + it('2.5 end 映射结束蓝色徽标', () => { + expect(computeAgentBadgeClass('end')).toEqual({ + 'is-online': false, + 'is-urgent': false, + 'is-waiting': false, + 'is-offline': false, + 'is-end': true, + }) + }) + + it('2.6 reopen 复用结束蓝色徽标', () => { + expect(computeAgentBadgeClass('reopen')).toEqual({ + 'is-online': false, + 'is-urgent': false, + 'is-waiting': false, + 'is-offline': false, + 'is-end': true, + }) + }) + + it('2.7 每个状态恰好只有一个徽标颜色 class', () => { + const states: AgentState[] = ['disabled', 'active', 'urgent', 'waiting', 'end', 'reopen'] + states.forEach((state) => { + const activeClasses = Object.values(computeAgentBadgeClass(state)).filter(Boolean) + expect(activeClasses).toHaveLength(1) + }) + }) +}) + +describe('InputBar v1.3 — agentBtnClass 六态 modifier', () => { + const expectedModifiers: Record = { + disabled: 'call-agent-btn--disabled', + active: 'call-agent-btn--active', + urgent: 'call-agent-btn--urgent', + waiting: 'call-agent-btn--waiting', + end: 'call-agent-btn--end', + reopen: 'call-agent-btn--reopen', + } + + it.each(Object.entries(expectedModifiers))('%s 包含 %s', (state, modifier) => { + expect(computeAgentBtnClass(state as AgentState)[modifier]).toBe(true) + }) + + it('3.7 每个状态仅启用一个 modifier', () => { + const states: AgentState[] = ['disabled', 'active', 'urgent', 'waiting', 'end', 'reopen'] + states.forEach((state) => { + const activeClasses = Object.values(computeAgentBtnClass(state)).filter(Boolean) + expect(activeClasses).toHaveLength(1) + }) + }) +}) + +describe('InputBar v1.3 — 坐席图标、文案与 title', () => { + it.each([ + ['disabled', '🔒', '人工坐席'], + ['active', '🎧', '人工坐席'], + ['urgent', '🚨', '人工坐席'], + ['waiting', '⏳', '排队取消'], + ['end', '📴', '结束咨询'], + ['reopen', '🔄', '重新打开'], + ] as Array<[AgentState, string, string]>)( + '%s 返回正确图标和文案', + (state, icon, text) => { + expect(computeAgentIcon(state)).toBe(icon) + expect(computeAgentText(state)).toBe(text) + }, + ) + + it('4.7 offline title 优先提示坐席不可用', () => { + expect(computeAgentTitle('active', false)).toBe('坐席离线,暂不可用') + }) + + it('4.8 active title 提示呼叫坐席', () => { + expect(computeAgentTitle('active', true)).toBe('点击呼叫人工坐席') + }) + + it('4.9 urgent title 提示紧急问题', () => { + expect(computeAgentTitle('urgent', true)).toBe('检测到紧急问题,直接呼叫人工坐席') + }) + + it('4.10 waiting title 提示取消排队', () => { + expect(computeAgentTitle('waiting', true)).toBe('点击取消排队') + }) + + it('4.11 end title 提示结束咨询', () => { + expect(computeAgentTitle('end', true)).toBe('点击结束本次人工咨询') + }) + + it('4.12 reopen title 提示 24 小时内可重开', () => { + expect(computeAgentTitle('reopen', true)).toBe('24小时内可重新打开此会话') + }) +}) + +describe('InputBar v1.3 — handleCallAgentClick action 路由', () => { + it.each([ + ['active', 'callAgent'], + ['urgent', 'callAgent'], + ['waiting', 'cancelQueue'], + ['end', 'endConversation'], + ['reopen', 'reopenConversation'], + ['disabled', 'none'], + ] as Array<[AgentState, AgentAction]>)('%s 路由到 %s', (state, action) => { + expect(computeCallAction(state)).toBe(action) + }) + + it('6.7 action 执行顺序与四个 store action 一一对应', async () => { + const calls: string[] = [] + const store = { + shakeAgent: async (): Promise => { calls.push('shakeAgent') }, + cancelQueue: async (): Promise => { calls.push('cancelQueue') }, + closeCurrentConversation: async (): Promise => { calls.push('closeCurrentConversation') }, + reopenCurrentConversation: async (): Promise => { calls.push('reopenCurrentConversation') }, + } + const route = async (state: AgentState): Promise => { + switch (computeCallAction(state)) { + case 'callAgent': await store.shakeAgent(); return + case 'cancelQueue': await store.cancelQueue(); return + case 'endConversation': await store.closeCurrentConversation(); return + case 'reopenConversation': await store.reopenCurrentConversation(); return + case 'none': return + } + } + + await route('active') + await route('waiting') + await route('end') + await route('reopen') + await route('disabled') + expect(calls).toEqual([ + 'shakeAgent', + 'cancelQueue', + 'closeCurrentConversation', + 'reopenCurrentConversation', + ]) + }) +}) + +describe('InputBar v1.3 — voiceBtnState 三态优先级', () => { + it('7.1 recognized 优先于 recording', () => { + expect(computeVoiceBtnState(true, true)).toBe('recognized') + }) + + it('7.2 recognized 且不录音', () => { + expect(computeVoiceBtnState(true, false)).toBe('recognized') + }) + + it('7.3 recording 态', () => { + expect(computeVoiceBtnState(false, true)).toBe('recording') + }) + + it('7.4 默认态', () => { + expect(computeVoiceBtnState(false, false)).toBe('default') + }) +}) + +describe('InputBar v1.3 — voiceBtnClass 互斥映射', () => { + it.each([ + ['default', 'is-voice-default'], + ['recording', 'is-voice-recording'], + ['recognized', 'is-voice-recognized'], + ] as Array<[VoiceState, string]> )('%s 激活对应 class', (state, className) => { + const classes = computeVoiceBtnClass(state) + expect(classes[className]).toBe(true) + expect(Object.values(classes).filter(Boolean)).toHaveLength(1) + }) +}) + +describe('InputBar v1.3 — URGENT_KEYWORDS 扫描', () => { + it.each([ + '紧急!系统无法登录', + 'URGENT help needed', + '电脑崩溃了', + 'Outlook 无法打开', + 'VPN 登不上', + '系统登录不上', + '网络故障', + ])('员工消息命中「%s」', (content) => { + expect(computeHasUrgentKeywords([{ message_type: 'employee', content }])).toBe(true) + }) + + it('8.8 普通员工消息不命中', () => { + expect(computeHasUrgentKeywords([{ message_type: 'employee', content: '打印机无法打印' }])).toBe(false) + }) + + it('8.9 AI 消息命中关键词不触发', () => { + expect(computeHasUrgentKeywords([{ message_type: 'ai', content: '紧急' }])).toBe(false) + }) + + it('8.10 多条消息任一员工消息命中即触发', () => { + expect(computeHasUrgentKeywords([ + { message_type: 'employee', content: '你好' }, + { message_type: 'ai', content: 'AI 回复' }, + { message_type: 'employee', content: '系统崩溃了' }, + ])).toBe(true) + }) + + it('8.11 空消息列表不触发', () => { + expect(computeHasUrgentKeywords([])).toBe(false) + }) +}) + +describe('InputBar v1.3 — emoji toggle', () => { + it('9.1 隐藏时点击打开', () => { + expect(computeEmojiToggle(false)).toBe(true) + }) + + it('9.2 打开时点击关闭', () => { + expect(computeEmojiToggle(true)).toBe(false) + }) + + it('9.3 连续两次点击回到隐藏', () => { + expect(computeEmojiToggle(computeEmojiToggle(false))).toBe(false) + }) +}) + +describe('InputBar v1.3 — handleFile', () => { + it('10.1 清空 accept、允许多选并触发 click', () => { + expect(simulateHandleFile()).toEqual({ accepted: '', multiple: true, clicked: true }) + }) + + it('10.2 文件按钮不改变表情面板状态', () => { + const showEmojiPanel = false + simulateHandleFile() + expect(showEmojiPanel).toBe(false) + }) +}) + +describe('InputBar v1.3 — voiceRecognitionCompleted 自动回退', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('11.1 识别完成后立即为 recognized,1500ms 后回默认', () => { + let voiceRecognitionCompleted = false + const markVoiceRecognized = (): void => { + voiceRecognitionCompleted = true + setTimeout(() => { voiceRecognitionCompleted = false }, 1500) + } + + markVoiceRecognized() + expect(voiceRecognitionCompleted).toBe(true) + expect(computeVoiceBtnState(voiceRecognitionCompleted, false)).toBe('recognized') + vi.advanceTimersByTime(1500) + expect(voiceRecognitionCompleted).toBe(false) + expect(computeVoiceBtnState(voiceRecognitionCompleted, false)).toBe('default') + }) + + it('11.2 未到 1500ms 时仍保持 recognized', () => { + let voiceRecognitionCompleted = false + voiceRecognitionCompleted = true + setTimeout(() => { voiceRecognitionCompleted = false }, 1500) + vi.advanceTimersByTime(800) + expect(voiceRecognitionCompleted).toBe(true) + }) +}) diff --git a/src/frontend-h5/src/components/chat/InputBar.vue b/src/frontend-h5/src/components/chat/InputBar.vue new file mode 100644 index 0000000..078369f --- /dev/null +++ b/src/frontend-h5/src/components/chat/InputBar.vue @@ -0,0 +1,1524 @@ + + + + + + + \ No newline at end of file