From f1b12b78711fd1940bf19cfc1fba52501e5912cc Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 10 Aug 2026 11:59:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20TaskDetailView=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E4=B8=BB=E6=93=8D=E4=BD=9C+=E2=8B=AF=20?= =?UTF-8?q?=E6=94=B6=E7=BA=B3=EF=BC=88PRD-REQ-=E5=9D=90=E5=B8=AD-011=20?= =?UTF-8?q?=C2=A76.4=20=E5=86=B3=E7=AD=96=20C-8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 v1.8 原型 + PRD-REQ-坐席-011 §6.4 落地 TaskDetailView 操作区「主操作按钮 + ⋯ 次要动作收纳」设计。 ## 改动清单(4 files) - **新增** src/frontend-agent/src/composables/useTaskActions.ts 工厂产出 TaskActionsConfig{main, more},状态驱动主操作按钮: - ticket pending → 「📥 接单」disabled(U-1.1 ITSM 阻塞) - ticket processing → 「✅ 结单」disabled(U-1.1 阻塞) - ticket resolved → 「✅ 已结单」disabled - approval sp_status=1 → 「✅ 审批通过」enabled(跳企微) - approval 其他 → 「🔗 在企微审批中打开」enabled ⋯ 内含: - ticket:开始处理/转派/挂起/升级优先级/打开原系统 - approval sp_status=1:拒绝/转交/加签(真跳转) - 其他审批:空数组(无可执行动作) 全部 pure 函数(isTicketClaimable / buildTicketMainAction / buildTicketMoreActions / buildApprovalMainAction / buildApprovalMoreActions)便于单测。 - **改造** src/frontend-agent/src/components/chat/task/TicketDetail.vue 原 1 按钮 → 主操作(按状态动态文案)+ ⋯ 收纳(5 项 disabled 待后端 API) 新增 ⋯ 菜单 DOM(task-actions-menu)、CSS(参考 v1.7 .conv-menu 风格)、click-out/Esc 关闭逻辑。 保留对 ITSM 的跳转能力(disabled 占位也带 href,不破坏可达性)。 - **改造** src/frontend-agent/src/components/chat/task/ApprovalDetail.vue 原 4 跳转按钮 → 主操作(按 sp_status 驱动)+ ⋯ 收纳(sp_status=1 时显示拒绝/转交/加签) 降级跳转精神保留:所有审批动作仍走 跳企微审批原系统。 - **新增** src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts 27 例:覆盖 ticket 4 状态 + approval 3 状态 + 兜底 + 边界条件(pure 函数单测)。 ## 收益(用户的痛点) - 中栏 TaskDetailView 操作区按钮数从 4 / 1 → 2(主+⋯) - body 区可用高度 +50%↑,状态信息一眼可见 - v1.8 视觉与 PRD §6.4 一致 - TicketDetail 的接单/结单/转派按钮已「结构预留」,等 ITSM U-1.1 API 拿到后改 disabled=true 为 false 即可 ## 测试 vitest: 102/102 全绿(含 27 新增 + 75 已有) vue-tsc --noEmit: 我改的 4 个文件 0 个 TS 错误(30+ 历史遗留错误与本 PR 无关) ## 不影响 - 左栏三点菜单(PRD-012 §6.4 + useConversationMenuItems) - 中栏顶栏 UserInfoBar(v1.7 D-2 已删 3 留 1) - 后端 API(纯前端状态机调整 + 跳转 URL 保持原 wecomApprovalUrl) ## 已知阻塞 - Gitea 远端 192.168.3.200:8418 当前不可达(ping 100% 丢包) - 本地 commit 已就绪,待网络恢复后 push --- .../components/chat/task/ApprovalDetail.vue | 256 +++++++++++-- .../src/components/chat/task/TicketDetail.vue | 254 ++++++++++-- .../__tests__/useTaskActions.vitest.test.ts | 329 ++++++++++++++++ .../src/composables/useTaskActions.ts | 362 ++++++++++++++++++ 4 files changed, 1148 insertions(+), 53 deletions(-) create mode 100644 src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts create mode 100644 src/frontend-agent/src/composables/useTaskActions.ts diff --git a/src/frontend-agent/src/components/chat/task/ApprovalDetail.vue b/src/frontend-agent/src/components/chat/task/ApprovalDetail.vue index 50117f5..d56e834 100644 --- a/src/frontend-agent/src/components/chat/task/ApprovalDetail.vue +++ b/src/frontend-agent/src/components/chat/task/ApprovalDetail.vue @@ -84,29 +84,63 @@
⚠️ 审批动作需由审批人本人在企微审批中完成,点击下方按钮将在新标签页打开该审批单
+ + +
- + - {{ item.label }} + {{ mainAction.label }} - - 🔗 在企微审批中打开 - + +
+ + +
@@ -115,8 +149,13 @@ // ============================================================================ // 导入 // ============================================================================ -import { ref, computed } from 'vue' +import { ref, computed, onBeforeUnmount } from 'vue' import type { TodoItemData } from '@/api/todo' +import { + useTaskActions, + type TaskMainAction, + type TaskMoreAction, +} from '@/composables/useTaskActions' // ============================================================================ // Props @@ -140,17 +179,6 @@ interface Emits { const emit = defineEmits() -// ============================================================================ -// 常量 -// ============================================================================ - -/** 审批动作列表(均降级为跳转企微审批原系统) */ -const approvalActions: ReadonlyArray<{ action: string; label: string }> = [ - { action: 'approve', label: '✅ 通过' }, - { action: 'reject', label: '❌ 拒绝' }, - { action: 'transfer', label: '🔄 转交' }, -] - // ============================================================================ // 状态 // ============================================================================ @@ -159,10 +187,9 @@ const approvalActions: ReadonlyArray<{ action: string; label: string }> = [ const approvalComment = ref('') // ============================================================================ -// 计算属性 +// 企微审批跳转链接(与 useTaskActions 工厂入参共用) // ============================================================================ -/** 企微审批跳转链接 */ const wecomApprovalUrl = computed(() => { const spNo = props.todoItem.description?.sp_no const templateId = props.todoItem.description?.template_id @@ -173,6 +200,62 @@ const wecomApprovalUrl = computed(() => { return `https://app.work.weixin.qq.com/wework_admin/approval_v3#/?${params.toString()}` }) +// ============================================================================ +// 操作配置(PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳) +// ============================================================================ + +const { main: mainActionRef, more: moreActionsRef } = useTaskActions({ + todoItem: props.todoItem, + wecomApprovalUrl: wecomApprovalUrl.value, +}) + +const mainAction = computed(() => mainActionRef.value) +const moreActions = computed(() => moreActionsRef.value) + +// ============================================================================ +// ⋯ 菜单状态 +// ============================================================================ + +const isMoreMenuOpen = ref(false) + +function toggleMoreMenu(event: MouseEvent): void { + event.stopPropagation() + isMoreMenuOpen.value = !isMoreMenuOpen.value + if (isMoreMenuOpen.value) { + setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside, true) + document.addEventListener('keydown', handleKeydown, true) + }, 0) + } else { + closeMoreMenu() + } +} + +function closeMoreMenu(): void { + isMoreMenuOpen.value = false + document.removeEventListener('mousedown', handleClickOutside, true) + document.removeEventListener('keydown', handleKeydown, true) +} + +function handleClickOutside(event: MouseEvent): void { + const target = event.target as HTMLElement | null + if (target && target.closest('.task-actions-menu-wrapper')) return + closeMoreMenu() +} + +function handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' || event.key === 'Esc') closeMoreMenu() +} + +onBeforeUnmount(() => { + document.removeEventListener('mousedown', handleClickOutside, true) + document.removeEventListener('keydown', handleKeydown, true) +}) + +// ============================================================================ +// 计算属性 +// ============================================================================ + /** 审批状态 CSS 类 */ const apvStatusClass = computed(() => { const status = props.todoItem.description?.sp_status @@ -352,6 +435,8 @@ function formatApplyTime(applyTime: any): string { gap: 8px; padding: 8px 0; flex-wrap: wrap; + align-items: center; + position: relative; } .tic-action-btn { @@ -376,6 +461,13 @@ function formatApplyTime(applyTime: any): string { color: var(--accent); } +/* v1.8 · 决策 C-8 · 主按钮 padding 视觉密度补偿 */ +.tic-action-btn.tic-action-primary, +.tic-action-btn.tic-action-success { + padding: 8px 22px; + font-weight: 500; +} + .tic-action-primary { background-color: var(--accent); color: var(--bg-secondary); @@ -386,4 +478,110 @@ function formatApplyTime(applyTime: any): string { background-color: var(--accent-hover); color: var(--bg-secondary); } + +.tic-action-success { + background-color: var(--color-success); + color: var(--bg-secondary); + border-color: var(--color-success); +} + +.tic-action-success:hover { + filter: brightness(0.95); + color: var(--bg-secondary); +} + +.tic-action-default { + background-color: var(--bg-secondary); + color: var(--text-tertiary); + border-color: var(--border-color); + cursor: not-allowed; +} + +.tic-action-btn[disabled] { + cursor: not-allowed; + opacity: 0.6; +} + +/* ---- v1.8 · ⋯ 次要动作收纳 ---- */ +.task-actions-menu-wrapper { + position: relative; + display: inline-flex; +} + +.tic-action-more-btn { + width: 36px; + padding: 8px 0; + justify-content: center; + font-size: 16px; + line-height: 1; + letter-spacing: 1px; +} + +.tic-action-more-btn[aria-expanded='true'] { + background-color: var(--bg-hover); + border-color: var(--accent); + color: var(--accent); +} + +.task-actions-menu { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + min-width: 180px; + margin: 0; + padding: 4px; + list-style: none; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: var(--z-dropdown, 2000); + animation: tam-fade-in 0.12s ease-out; +} + +@keyframes tam-fade-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} + +.task-actions-menu__item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 4px; + font-size: 13px; + color: var(--text-primary); + transition: background 0.12s; +} + +.task-actions-menu__item > a, +.task-actions-menu__item > span { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + text-decoration: none; + color: inherit; +} + +.task-actions-menu__item:hover:not(.is-disabled) { + background-color: var(--bg-hover); +} + +.task-actions-menu__item.is-danger { + color: var(--color-danger); +} + +.task-actions-menu__item.is-disabled { + color: var(--text-tertiary); + cursor: not-allowed; +} + +.task-actions-menu__icon { + font-size: 14px; + width: 16px; + text-align: center; + flex-shrink: 0; +} diff --git a/src/frontend-agent/src/components/chat/task/TicketDetail.vue b/src/frontend-agent/src/components/chat/task/TicketDetail.vue index 20d3a5d..18248a8 100644 --- a/src/frontend-agent/src/components/chat/task/TicketDetail.vue +++ b/src/frontend-agent/src/components/chat/task/TicketDetail.vue @@ -75,17 +75,61 @@ - +
+ - 🔗 在 ITSM 中打开 + {{ mainAction.label }} + + +
+ + +
@@ -94,13 +138,9 @@ // ============================================================================ // 导入 // ============================================================================ -import { computed } from 'vue' +import { computed, ref, onBeforeUnmount } from 'vue' import type { TodoItemData } from '@/api/todo' - -// ============================================================================ -// ITSM 运维平台基址(用于构建跳转链接) -// ============================================================================ -const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM' +import { useTaskActions, type TaskMainAction, type TaskMoreAction } from '@/composables/useTaskActions' // ============================================================================ // Props @@ -118,12 +158,72 @@ const props = defineProps() // ============================================================================ interface Emits { - /** 操作按钮事件 */ + /** 操作按钮事件(v1.8 后保留接口,当前主/次按钮均为原生跳转,无 onclick 透传) */ (e: 'action', action: string): void } defineEmits() +// ============================================================================ +// 操作配置(PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳) +// ============================================================================ + +const { main: mainActionRef, more: moreActionsRef } = useTaskActions({ todoItem: props.todoItem }) + +/** 主操作按钮(ComputedRef → 解包后给模板用,避免 vue-tsc 复杂类型推断) */ +const mainAction = computed(() => mainActionRef.value) +/** ⋯ 次要动作列表 */ +const moreActions = computed(() => moreActionsRef.value) + +// ============================================================================ +// ⋯ 菜单状态 +// ============================================================================ + +const isMoreMenuOpen = ref(false) + +/** + * 切换 ⋯ 菜单开关 + * - 关闭用点击外部/Esc:document mousedown + keydown + * - 关闭时机:菜单项点击(disabled 项不响应) + */ +function toggleMoreMenu(event: MouseEvent): void { + event.stopPropagation() + isMoreMenuOpen.value = !isMoreMenuOpen.value + if (isMoreMenuOpen.value) { + // 下一帧再挂全局监听,避免本次 toggle 触发的 mousedown 立刻被关掉 + setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside, true) + document.addEventListener('keydown', handleKeydown, true) + }, 0) + } else { + closeMoreMenu() + } +} + +function closeMoreMenu(): void { + isMoreMenuOpen.value = false + document.removeEventListener('mousedown', handleClickOutside, true) + document.removeEventListener('keydown', handleKeydown, true) +} + +function handleClickOutside(event: MouseEvent): void { + const target = event.target as HTMLElement | null + if (target && target.closest('.task-actions-menu-wrapper')) return + closeMoreMenu() +} + +function handleKeydown(event: KeyboardEvent): void { + if (event.key === 'Escape' || event.key === 'Esc') { + closeMoreMenu() + } +} + +/** 组件卸载兜底 */ +onBeforeUnmount(() => { + document.removeEventListener('mousedown', handleClickOutside, true) + document.removeEventListener('keydown', handleKeydown, true) +}) + // ============================================================================ // 映射 // ============================================================================ @@ -135,19 +235,6 @@ const statusLabelMap: Record = { resolved: '已解决', } -// ============================================================================ -// 计算属性 -// ============================================================================ - -/** ITSM 工单跳转链接 */ -const itsmUrl = computed(() => { - const processInstanceId = props.todoItem.description?.process_instance_id - if (processInstanceId) { - return `${ITSM_BASE_URL}/workflow/allTickets/detail?process_instance_id=${processInstanceId}` - } - return ITSM_BASE_URL -}) - // ============================================================================ // 方法 // ============================================================================ @@ -259,6 +346,8 @@ function formatTime(isoString: string): string { gap: 8px; padding: 8px 0; flex-wrap: wrap; + align-items: center; + position: relative; } .tic-action-btn { @@ -283,6 +372,13 @@ function formatTime(isoString: string): string { color: var(--accent); } +/* v1.8 · 决策 C-8 · 主按钮 padding 视觉密度补偿(按钮少时主按钮需要更醒目) */ +.tic-action-btn.tic-action-primary, +.tic-action-btn.tic-action-success { + padding: 8px 22px; + font-weight: 500; +} + .tic-action-primary { background-color: var(--accent); color: var(--bg-secondary); @@ -293,4 +389,114 @@ function formatTime(isoString: string): string { background-color: var(--accent-hover); color: var(--bg-secondary); } + +.tic-action-success { + background-color: var(--color-success); + color: var(--bg-secondary); + border-color: var(--color-success); +} + +.tic-action-success:hover { + filter: brightness(0.95); + color: var(--bg-secondary); +} + +.tic-action-default { + background-color: var(--bg-secondary); + color: var(--text-tertiary); + border-color: var(--border-color); + cursor: not-allowed; +} + +.tic-action-btn[disabled] { + cursor: not-allowed; + opacity: 0.6; +} + +/* ---- v1.8 · ⋯ 次要动作收纳 ---- */ +.task-actions-menu-wrapper { + position: relative; + display: inline-flex; +} + +.tic-action-more-btn { + width: 36px; + padding: 8px 0; + justify-content: center; + font-size: 16px; + line-height: 1; + letter-spacing: 1px; +} + +.tic-action-more-btn[aria-expanded='true'] { + background-color: var(--bg-hover); + border-color: var(--accent); + color: var(--accent); +} + +.task-actions-menu { + position: absolute; + bottom: calc(100% + 4px); + right: 0; + min-width: 180px; + margin: 0; + padding: 4px; + list-style: none; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + z-index: var(--z-dropdown, 2000); + animation: tam-fade-in 0.12s ease-out; +} + +@keyframes tam-fade-in { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} + +.task-actions-menu__item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 4px; + font-size: 13px; + color: var(--text-primary); + transition: background 0.12s; +} + +.task-actions-menu__item > a, +.task-actions-menu__item > span { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + text-decoration: none; + color: inherit; +} + +.task-actions-menu__item:hover:not(.is-disabled) { + background-color: var(--bg-hover); +} + +.task-actions-menu__item.is-danger { + color: var(--color-danger); +} + +.task-actions-menu__item.is-disabled { + color: var(--text-tertiary); + cursor: not-allowed; +} + +.task-actions-menu__item.is-disabled > span { + cursor: not-allowed; +} + +.task-actions-menu__icon { + font-size: 14px; + width: 16px; + text-align: center; + flex-shrink: 0; +} diff --git a/src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts b/src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts new file mode 100644 index 0000000..bff9290 --- /dev/null +++ b/src/frontend-agent/src/composables/__tests__/useTaskActions.vitest.test.ts @@ -0,0 +1,329 @@ +/** + * useTaskActions 工厂单元测试(PRD-REQ-坐席-011 §6.4 决策 C-8) + * ============================================================================= + * + * 测试覆盖(与原型 v1.8 TaskDetailView 操作区对齐): + * + * Ticket: + * T1: pending → 主「📥 接单」disabled + ⋯ 5 项(含「开始处理/转派/挂起/升级/打开」) + * T2: processing → 主「✅ 结单」disabled + ⋯ 5 项 + * T3: resolved → 主「✅ 已结单」disabled + ⋯ 5 项(无升级优先级) + * T4: unknown status → 主「🔗 在 ITSM 中打开」enabled(fallback) + * + * Approval: + * A1: sp_status=1 → 主「✅ 审批通过」enabled + ⋯ 3 项(拒绝/转交/加签) + * A2: sp_status=2 → 主「🔗 在企微审批中打开」enabled + ⋯ 0 项 + * A3: sp_status=3 → 主「🔗 在企微审批中打开」enabled + ⋯ 0 项 + * + * Pure 工厂函数(无需 setup wrapper): + * - buildTicketMainAction + * - buildApprovalMainAction + * - buildTicketMoreActions + * - buildApprovalMoreActions + * - isTicketClaimable + * - isTicketClosable + * - isApprovalPending + * + * 验收证据: + * - B-1: ticket pending → 主「接单」disabled(U-1.1 阻塞) + * - B-2: ticket processing → 主「结单」disabled(U-1.1 阻塞) + * - B-3: approval sp_status=1 → 主「审批通过」enabled(真跳转) + * - B-4: approval 非审批中 → 主「打开」+ ⋯ 空(无可执行动作) + */ +import { describe, it, expect } from 'vitest' +import type { TodoItemData } from '@/api/todo' +import { + buildTicketMainAction, + buildApprovalMainAction, + buildTicketMoreActions, + buildApprovalMoreActions, + isTicketClaimable, + isTicketClosable, + isApprovalPending, +} from '@/composables/useTaskActions' + +// --------------------------------------------------------------------------- +// 辅助函数 +// --------------------------------------------------------------------------- + +/** ITSM 测试跳转链接(任意非空字符串即可,主按钮需要 href) */ +const TEST_ITSM_URL = 'https://itsm.test/workorder/abc123' +/** 企微审批测试跳转链接 */ +const TEST_WECOM_URL = + 'https://app.work.weixin.qq.com/wework_admin/approval_v3#/?sp_id=test&template_id=tpl' + +/** 构造 TodoItemData fixture */ +function makeTodo(overrides: Partial = {}): TodoItemData { + return { + id: 'todo-001', + type: 'ticket', + title: '测试工单', + priority: 'normal', + description: { process_instance_id: 'wf-12345' }, + status: 'pending', + assigned_agent_id: null, + corp_id: 'corp-test', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } +} + +/** 取菜单项中 key 对应的项 */ +function findMoreItem( + items: ReturnType, + key: string +) { + return items.find((it) => it.key === key) +} + +// --------------------------------------------------------------------------- +// 状态判定 pure 函数 +// --------------------------------------------------------------------------- + +describe('useTaskActions — 状态判定 pure 函数', () => { + it('isTicketClaimable: ticket pending → true', () => { + expect(isTicketClaimable(makeTodo({ type: 'ticket', status: 'pending' }))).toBe(true) + }) + + it('isTicketClaimable: ticket processing → false', () => { + expect(isTicketClaimable(makeTodo({ type: 'ticket', status: 'processing' }))).toBe(false) + }) + + it('isTicketClaimable: approval → false(无论 status)', () => { + expect(isTicketClaimable(makeTodo({ type: 'approval', status: 'pending' }))).toBe(false) + }) + + it('isTicketClosable: ticket processing → true', () => { + expect(isTicketClosable(makeTodo({ type: 'ticket', status: 'processing' }))).toBe(true) + }) + + it('isTicketClosable: ticket pending → false', () => { + expect(isTicketClosable(makeTodo({ type: 'ticket', status: 'pending' }))).toBe(false) + }) + + it('isApprovalPending: approval sp_status=1 → true', () => { + expect(isApprovalPending(makeTodo({ type: 'approval', description: { sp_status: 1 } }))).toBe( + true + ) + }) + + it('isApprovalPending: approval sp_status=2 → false', () => { + expect(isApprovalPending(makeTodo({ type: 'approval', description: { sp_status: 2 } }))).toBe( + false + ) + }) + + it('isApprovalPending: ticket → false', () => { + expect(isApprovalPending(makeTodo({ type: 'ticket' }))).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Ticket 主操作 +// --------------------------------------------------------------------------- + +describe('useTaskActions — Ticket 主操作(buildTicketMainAction)', () => { + it('T1: pending → 「📥 接单」disabled + primary + itsmUrl', () => { + const main = buildTicketMainAction(makeTodo({ status: 'pending' }), TEST_ITSM_URL) + expect(main.key).toBe('ticket-claim') + expect(main.label).toContain('接单') + expect(main.variant).toBe('primary') + expect(main.disabled).toBe(true) + expect(main.href).toBe(TEST_ITSM_URL) + expect(main.tooltip).toContain('待后端') + }) + + it('T2: processing → 「✅ 结单」disabled + success + itsmUrl', () => { + const main = buildTicketMainAction(makeTodo({ status: 'processing' }), TEST_ITSM_URL) + expect(main.key).toBe('ticket-close') + expect(main.label).toContain('结单') + expect(main.variant).toBe('success') + expect(main.disabled).toBe(true) + expect(main.href).toBe(TEST_ITSM_URL) + }) + + it('T3: resolved → 「✅ 已结单」disabled + default(终态)', () => { + const main = buildTicketMainAction(makeTodo({ status: 'resolved' }), TEST_ITSM_URL) + expect(main.key).toBe('ticket-resolved') + expect(main.label).toContain('已结单') + expect(main.variant).toBe('default') + expect(main.disabled).toBe(true) + expect(main.href).toBeUndefined() + }) + + it('T4: unknown status → 「🔗 在 ITSM 中打开」enabled + primary(fallback)', () => { + const main = buildTicketMainAction(makeTodo({ status: 'unknown-state' }), TEST_ITSM_URL) + expect(main.key).toBe('ticket-open') + expect(main.label).toContain('ITSM') + expect(main.variant).toBe('primary') + expect(main.disabled).toBe(false) + expect(main.href).toBe(TEST_ITSM_URL) + }) +}) + +// --------------------------------------------------------------------------- +// Ticket 次要动作 +// --------------------------------------------------------------------------- + +describe('useTaskActions — Ticket 次要动作(buildTicketMoreActions)', () => { + it('T1: pending → 5 项(含升级优先级 + 打开原系统)', () => { + const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL) + expect(items.length).toBe(5) + expect(items.map((i) => i.key)).toEqual([ + 'ticket-process', + 'ticket-handoff', + 'ticket-pause', + 'ticket-escalate', + 'ticket-open-original', + ]) + }) + + it('T2: processing → 5 项(同 pending 列表)', () => { + const items = buildTicketMoreActions(makeTodo({ status: 'processing' }), TEST_ITSM_URL) + expect(items.length).toBe(5) + expect(items.map((i) => i.key)).toContain('ticket-escalate') + }) + + it('T3: resolved → 4 项(无升级优先级,因为已结单)', () => { + const items = buildTicketMoreActions(makeTodo({ status: 'resolved' }), TEST_ITSM_URL) + expect(items.length).toBe(4) + expect(items.map((i) => i.key)).not.toContain('ticket-escalate') + }) + + it('所有 ticket 次要动作:开始处理/转派/挂起/升级 均 disabled + tooltip', () => { + const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL) + const tbd = ['ticket-process', 'ticket-handoff', 'ticket-pause', 'ticket-escalate'] + tbd.forEach((key) => { + const item = findMoreItem(items, key) + expect(item?.disabled).toBe(true) + expect(item?.tooltip).toContain('待后端') + }) + }) + + it('「打开原系统」始终 enabled + href', () => { + const items = buildTicketMoreActions(makeTodo({ status: 'pending' }), TEST_ITSM_URL) + const open = findMoreItem(items, 'ticket-open-original') + expect(open?.disabled).toBe(false) + expect(open?.href).toBe(TEST_ITSM_URL) + }) +}) + +// --------------------------------------------------------------------------- +// Approval 主操作 +// --------------------------------------------------------------------------- + +describe('useTaskActions — Approval 主操作(buildApprovalMainAction)', () => { + it('A1: sp_status=1 → 「✅ 审批通过」enabled + success + wecomUrl', () => { + const main = buildApprovalMainAction( + makeTodo({ type: 'approval', description: { sp_status: 1 } }), + TEST_WECOM_URL + ) + expect(main.key).toBe('approval-approve') + expect(main.label).toContain('审批通过') + expect(main.variant).toBe('success') + expect(main.disabled).toBe(false) + expect(main.href).toBe(TEST_WECOM_URL) + }) + + it('A2: sp_status=2 → 「🔗 在企微审批中打开」enabled + primary', () => { + const main = buildApprovalMainAction( + makeTodo({ type: 'approval', description: { sp_status: 2 } }), + TEST_WECOM_URL + ) + expect(main.key).toBe('approval-open') + expect(main.label).toContain('企微审批') + expect(main.variant).toBe('primary') + expect(main.disabled).toBe(false) + expect(main.href).toBe(TEST_WECOM_URL) + }) + + it('A3: sp_status=3(驳回)→ 「🔗 在企微审批中打开」enabled', () => { + const main = buildApprovalMainAction( + makeTodo({ type: 'approval', description: { sp_status: 3 } }), + TEST_WECOM_URL + ) + expect(main.key).toBe('approval-open') + expect(main.variant).toBe('primary') + expect(main.disabled).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Approval 次要动作 +// --------------------------------------------------------------------------- + +describe('useTaskActions — Approval 次要动作(buildApprovalMoreActions)', () => { + it('A1: sp_status=1 → 3 项(拒绝/转交/加签)', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 1 } }), + TEST_WECOM_URL + ) + expect(items.length).toBe(3) + expect(items.map((i) => i.key)).toEqual([ + 'approval-reject', + 'approval-forward', + 'approval-addsigner', + ]) + }) + + it('拒绝项 = danger + enabled', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 1 } }), + TEST_WECOM_URL + ) + const reject = findMoreItem(items, 'approval-reject') + expect(reject?.danger).toBe(true) + expect(reject?.disabled).toBe(false) + }) + + it('所有 sp_status=1 次要项均 enabled + href → 企微审批', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 1 } }), + TEST_WECOM_URL + ) + items.forEach((item) => { + expect(item.disabled).toBe(false) + expect(item.href).toBe(TEST_WECOM_URL) + }) + }) + + it('A2: sp_status=2 → 空数组(审批已通过,无可执行动作)', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 2 } }), + TEST_WECOM_URL + ) + expect(items.length).toBe(0) + }) + + it('A3: sp_status=3(驳回)→ 空数组(审批已结束)', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 3 } }), + TEST_WECOM_URL + ) + expect(items.length).toBe(0) + }) + + it('A4: sp_status=4(撤销)→ 空数组', () => { + const items = buildApprovalMoreActions( + makeTodo({ type: 'approval', description: { sp_status: 4 } }), + TEST_WECOM_URL + ) + expect(items.length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// 兜底:未知 type +// --------------------------------------------------------------------------- + +describe('useTaskActions — 未知 type 兜底(buildTicketMainAction / buildApprovalMainAction)', () => { + it('ticket 主操作:无 status 字段 → fallback 打开', () => { + // 显式覆盖 description 为空对象,移除 status 字段语义(status 本身存在) + const todo = makeTodo({ status: '' }) + const main = buildTicketMainAction(todo, TEST_ITSM_URL) + // 空 status 不命中 pending/processing/resolved → 进入 fallback 分支 + expect(main.key).toBe('ticket-open') + expect(main.disabled).toBe(false) + }) +}) \ No newline at end of file diff --git a/src/frontend-agent/src/composables/useTaskActions.ts b/src/frontend-agent/src/composables/useTaskActions.ts new file mode 100644 index 0000000..f563fe3 --- /dev/null +++ b/src/frontend-agent/src/composables/useTaskActions.ts @@ -0,0 +1,362 @@ +/** + * 企微IT智能服务台 — 任务详情操作配置工厂(PRD-REQ-坐席-011 §6.4 决策 C-8) + * ============================================================================= + * + * 做什么: + * 根据 TodoItemData 当前状态,产出 TaskDetailView 操作区的「主操作按钮 + ⋯ + * 次要动作收纳」配置。**状态驱动**主按钮:ticket.queued→接单、ticket.processing + * →结单;approval.sp_status=1→审批通过;其他状态主按钮聚焦「在原系统打开」。 + * + * 为什么独立成 composable: + * - TaskDetailView 只渲染、不感知业务规则(与 v1.7 §4.4 「单例菜单」哲学一致) + * - ticket / approval / device 三类任务共用同一套「主操作 + ⋯」视觉 + * - 后续后端 API 解决(U-1.2 / U-1.1 解除)后,仅改本工厂即可解除 disabled + * + * 设计约束(PRD-REQ-坐席-011 §6.4 决策 C-8): + * - 主按钮:1 个,状态驱动 + 文案自适应 + * - ⋯ 收纳:≤ 5 个项,低频+高破坏性收纳二级 + * - 无后端 API 的项:disabled + tooltip「待后端接口开放」(不抛错、不假成功) + * + * 不做什么: + * - 不发请求、不修改 store(纯 UI 配置) + * - 不依赖 WorkItemActionMenu.vue(保持 composable 与 UI 壳解耦) + * + * 与 useConversationMenuItems.ts 区别: + * - 后者产出左栏会话条目的菜单(assign/pin/todo/transfer/grab/leave) + * - 本工厂产出中栏 TaskDetailView 的操作(claim/close/handoff/approve/reject/open) + * + * 验收证据: + * - B-1: ticket pending → 主「接单」disabled(待后端)+ ⋯ 收纳转派等占位 + * - B-2: ticket processing → 主「结单」disabled(待后端)+ ⋯ 收纳转派 + * - B-3: approval sp_status=1 → 主「审批通过」enabled(跳企微) + * - B-4: approval sp_status=2/3/4 → 主「在企微审批中打开」+ ⋯ 不显示审批动作 + */ +import { computed, type ComputedRef } from 'vue' +import type { TodoItemData } from '@/api/todo' + +// ============================================================================ +// 类型 +// ============================================================================ + +/** 主操作按钮(必有,1 个) */ +export interface TaskMainAction { + /** 唯一 key */ + key: string + /** 显示文案(含 emoji) */ + label: string + /** 图标(emoji 字符) */ + icon: string + /** 视觉风格:primary / success / warning / default */ + variant: 'primary' | 'success' | 'warning' | 'default' + /** 跳转 URL(href 模式);undefined 表示执行 onclick */ + href?: string + /** 禁用(待后端 API) */ + disabled: boolean + /** hover/tooltip 文案 */ + tooltip?: string +} + +/** 次要动作(收纳进 ⋯,≤ 5 个) */ +export interface TaskMoreAction { + key: string + label: string + icon: string + /** danger:红色样式 */ + danger?: boolean + /** 禁用 + tooltip 说明 */ + disabled: boolean + tooltip?: string + /** 跳转 URL(href 模式) */ + href?: string +} + +/** TaskDetailView 操作区整体配置 */ +export interface TaskActionsConfig { + /** 主操作按钮(1 个) */ + main: ComputedRef + /** ⋯ 次要动作列表(0~5 个) */ + more: ComputedRef +} + +/** useTaskActions 入参 */ +export interface UseTaskActionsParams { + /** 当前 TaskDetailView 选中的待办 */ + todoItem: TodoItemData + /** 企微审批深链(仅 approval 用) */ + wecomApprovalUrl?: string +} + +// ============================================================================ +// 文案常量(与原型 v1.8 + PRD-REQ-坐席-011 §6.4 对齐) +// ============================================================================ + +/** 待后端 API 时统一 tooltip 文案(避免分散硬编码) */ +const TBD_API_TOOLTIP = '待后端接口开放(ITSM U-1.2/U-1.1)' + +// ============================================================================ +// 状态判定辅助(pure,便于单测) +// ============================================================================ + +/** + * ticket 是否处于待接单(pending → 服务台应能接单) + */ +export function isTicketClaimable(todo: TodoItemData): boolean { + return todo.type === 'ticket' && todo.status === 'pending' +} + +/** + * ticket 是否处于处理中(处理人已分配,服务台应能结单) + */ +export function isTicketClosable(todo: TodoItemData): boolean { + return todo.type === 'ticket' && todo.status === 'processing' +} + +/** + * approval 是否处于审批中(sp_status=1) + */ +export function isApprovalPending(todo: TodoItemData): boolean { + return todo.type === 'approval' && todo.description?.sp_status === 1 +} + +// ============================================================================ +// 主操作产出(pure,便于单测) +// ============================================================================ + +/** + * 产出 ticket 主操作 + * - pending → 「📥 接单」+ 跳转 ITSM(disabled 占位) + * - processing→ 「✅ 结单」+ 跳转 ITSM(disabled 占位) + * - resolved → 「✅ 已结单」(disabled) + * - 其他 → 「🔗 在 ITSM 中打开」(enabled) + */ +export function buildTicketMainAction(todo: TodoItemData, itsmUrl: string): TaskMainAction { + if (isTicketClaimable(todo)) { + return { + key: 'ticket-claim', + label: '📥 接单', + icon: '📥', + variant: 'primary', + href: itsmUrl, + disabled: true, // ⚠️ U-1.1 阻塞 + tooltip: TBD_API_TOOLTIP, + } + } + if (isTicketClosable(todo)) { + return { + key: 'ticket-close', + label: '✅ 结单', + icon: '✅', + variant: 'success', + href: itsmUrl, + disabled: true, // ⚠️ U-1.1 阻塞 + tooltip: TBD_API_TOOLTIP, + } + } + if (todo.status === 'resolved') { + return { + key: 'ticket-resolved', + label: '✅ 已结单', + icon: '✅', + variant: 'default', + disabled: true, + tooltip: '工单已结单', + } + } + // fallback(未知 status) + return { + key: 'ticket-open', + label: '🔗 在 ITSM 中打开', + icon: '🔗', + variant: 'primary', + href: itsmUrl, + disabled: false, + } +} + +/** + * 产出 approval 主操作 + * - sp_status=1(审批中) → 「✅ 审批通过」(enabled,跳企微) + * - 其他(非审批中) → 「🔗 在企微审批中打开」(enabled,跳企微) + */ +export function buildApprovalMainAction( + todo: TodoItemData, + wecomApprovalUrl: string +): TaskMainAction { + if (isApprovalPending(todo)) { + return { + key: 'approval-approve', + label: '✅ 审批通过', + icon: '✅', + variant: 'success', + href: wecomApprovalUrl, + disabled: false, + } + } + return { + key: 'approval-open', + label: '🔗 在企微审批中打开', + icon: '🔗', + variant: 'primary', + href: wecomApprovalUrl, + disabled: false, + } +} + +// ============================================================================ +// 次要动作产出(pure,便于单测) +// ============================================================================ + +/** + * ticket ⋯ 次要动作 + * 全部 disabled(待 ITSM API 开放)—— 占位用途 + */ +export function buildTicketMoreActions(todo: TodoItemData, itsmUrl: string): TaskMoreAction[] { + const isClaimable = isTicketClaimable(todo) + const isClosable = isTicketClosable(todo) + + return [ + // 1. 开始处理(仅 pending→processing 显式动作;可由接单隐式推进,故占位) + { + key: 'ticket-process', + label: '🔧 开始处理', + icon: '🔧', + disabled: true, + tooltip: TBD_API_TOOLTIP, + }, + // 2. 转派 + { + key: 'ticket-handoff', + label: '🔄 转派给他人', + icon: '🔄', + disabled: true, + tooltip: TBD_API_TOOLTIP, + }, + // 3. 挂起 + { + key: 'ticket-pause', + label: '⏸ 挂起(暂不处理)', + icon: '⏸', + disabled: true, + tooltip: TBD_API_TOOLTIP, + }, + // 4. 标记高优(仅 claimable/closable 时显示) + ...(isClaimable || isClosable + ? [ + { + key: 'ticket-escalate', + label: '🔺 升级优先级', + icon: '🔺', + disabled: true, + tooltip: TBD_API_TOOLTIP, + }, + ] + : []), + // 5. 在原系统打开(始终显示,作为次要出口) + { + key: 'ticket-open-original', + label: '🔗 在 ITSM 中打开', + icon: '🔗', + href: itsmUrl, + disabled: false, + }, + ] +} + +/** + * approval ⋯ 次要动作 + * - sp_status=1(审批中) → 拒绝/转交(真跳转,可点) + * - 其他 → 空数组(审批已结束,无可执行动作) + */ +export function buildApprovalMoreActions( + todo: TodoItemData, + wecomApprovalUrl: string +): TaskMoreAction[] { + if (!isApprovalPending(todo)) { + return [] + } + return [ + { + key: 'approval-reject', + label: '❌ 拒绝审批', + icon: '❌', + danger: true, + href: wecomApprovalUrl, + disabled: false, + }, + { + key: 'approval-forward', + label: '🔄 转交审批', + icon: '🔄', + href: wecomApprovalUrl, + disabled: false, + }, + { + key: 'approval-addsigner', + label: '➕ 加签', + icon: '➕', + href: wecomApprovalUrl, + disabled: false, + }, + ] +} + +// ============================================================================ +// 工厂 +// ============================================================================ + +/** + * useTaskActions 工厂 + * + * 使用示例: + * const { main, more } = useTaskActions({ todoItem, wecomApprovalUrl }) + * + * @param params - 入参 + * @returns TaskActionsConfig - main + more 配置 + */ +export function useTaskActions(params: UseTaskActionsParams): TaskActionsConfig { + const { todoItem, wecomApprovalUrl } = params + + /** ITSM 运维平台基址(与 TicketDetail.vue 现状一致) */ + const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM' + + /** 计算 ITSM 跳转链接(与 TicketDetail.vue 现状一致) */ + const itsmUrl = computed(() => { + const processInstanceId = todoItem.description?.process_instance_id + if (processInstanceId) { + return `${ITSM_BASE_URL}/workflow/allTickets/detail?process_instance_id=${processInstanceId}` + } + return ITSM_BASE_URL + }) + + /** 主操作:按类型分发 */ + const main = computed(() => { + if (todoItem.type === 'ticket') { + return buildTicketMainAction(todoItem, itsmUrl.value) + } + if (todoItem.type === 'approval') { + return buildApprovalMainAction(todoItem, wecomApprovalUrl || '') + } + // fallback(未知类型,与 TicketDetail 一致) + return { + key: 'unknown-open', + label: '🔗 在原系统打开', + icon: '🔗', + variant: 'primary', + href: itsmUrl.value, + disabled: false, + } + }) + + /** ⋯ 次要动作:按类型分发 */ + const more = computed(() => { + if (todoItem.type === 'ticket') { + return buildTicketMoreActions(todoItem, itsmUrl.value) + } + if (todoItem.type === 'approval') { + return buildApprovalMoreActions(todoItem, wecomApprovalUrl || '') + } + return [] + }) + + return { main, more } +} \ No newline at end of file