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 @@
⚠️ 审批动作需由审批人本人在企微审批中完成,点击下方按钮将在新标签页打开该审批单
+
+
+
@@ -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 @@
-
+
@@ -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