feat(agent): TaskDetailView 操作区主操作+⋯ 收纳(PRD-REQ-坐席-011 §6.4 决策 C-8)

按 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 时显示拒绝/转交/加签)
  降级跳转精神保留:所有审批动作仍走 <a target="_blank"> 跳企微审批原系统。

- **新增** 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
This commit is contained in:
Simon
2026-08-10 11:59:51 +08:00
parent b4e21e3150
commit f1b12b7871
4 changed files with 1148 additions and 53 deletions
@@ -84,29 +84,63 @@
<div class="apv-action-hint">
审批动作需由审批人本人在企微审批中完成点击下方按钮将在新标签页打开该审批单
</div>
<!-- ================================================================== -->
<!-- 底部操作按钮 PRD-REQ-坐席-011 §6.4 决策 C-8主操作 + 收纳 -->
<!-- ================================================================== -->
<div class="tic-actions">
<!-- 通过/拒绝/转交三个动作共用同一个深链仅作为跳转入口 -->
<!-- 主操作按状态驱动sp_status=1审批通过 / 其他在企微审批中打开 -->
<a
v-for="item in approvalActions"
:key="item.action"
class="tic-action-btn"
:href="wecomApprovalUrl"
:class="`tic-action-${mainAction.variant}`"
:href="mainAction.href"
:disabled="mainAction.disabled"
:title="mainAction.tooltip || mainAction.label"
target="_blank"
rel="noopener noreferrer"
@click="handleApprovalAction(item.action)"
@click="handleApprovalAction(mainAction.key)"
>
{{ item.label }}
{{ mainAction.label }}
</a>
<a
class="tic-action-btn tic-action-primary"
:href="wecomApprovalUrl"
target="_blank"
rel="noopener noreferrer"
@click="handleApprovalAction('open')"
>
🔗 在企微审批中打开
</a>
<!-- 次要动作收纳按钮 sp_status=1 显示拒绝/转交/加签 -->
<div class="task-actions-menu-wrapper" v-if="moreActions.length > 0">
<button
class="tic-action-btn tic-action-more-btn"
type="button"
:aria-expanded="isMoreMenuOpen"
aria-haspopup="menu"
aria-label="更多操作"
title="更多操作"
@click="toggleMoreMenu"
>
</button>
<ul v-if="isMoreMenuOpen" class="task-actions-menu" role="menu">
<li
v-for="item in moreActions"
:key="item.key"
class="task-actions-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:title="item.tooltip || item.label"
>
<a
v-if="item.href && !item.disabled"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="handleApprovalAction(item.key); closeMoreMenu()"
>
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</a>
<span v-else class="task-actions-menu__label-disabled">
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</span>
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -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<Emits>()
// ============================================================================
// 常量
// ============================================================================
/** 审批动作列表(均降级为跳转企微审批原系统) */
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<string>('')
// ============================================================================
// 计算属性
// 企微审批跳转链接(与 useTaskActions 工厂入参共用)
// ============================================================================
/** 企微审批跳转链接 */
const wecomApprovalUrl = computed<string>(() => {
const spNo = props.todoItem.description?.sp_no
const templateId = props.todoItem.description?.template_id
@@ -173,6 +200,62 @@ const wecomApprovalUrl = computed<string>(() => {
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<TaskMainAction>(() => mainActionRef.value)
const moreActions = computed<TaskMoreAction[]>(() => moreActionsRef.value)
// ============================================================================
// ⋯ 菜单状态
// ============================================================================
const isMoreMenuOpen = ref<boolean>(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<string>(() => {
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;
}
</style>
@@ -75,17 +75,61 @@
</div>
<!-- ================================================================== -->
<!-- 底部操作按钮 跳转到 ITSM 原系统操作 -->
<!-- 底部操作按钮 PRD-REQ-坐席-011 §6.4 决策 C-8主操作 + 收纳 -->
<!-- ================================================================== -->
<div class="tic-actions">
<!-- 主操作按状态驱动pending接单 / processing结单 / resolved已结单 / 其他打开 ITSM -->
<a
class="tic-action-btn tic-action-primary"
:href="itsmUrl"
class="tic-action-btn"
:class="`tic-action-${mainAction.variant}`"
:href="mainAction.href"
:disabled="mainAction.disabled"
:title="mainAction.tooltip || mainAction.label"
target="_blank"
rel="noopener noreferrer"
>
🔗 ITSM 中打开
{{ mainAction.label }}
</a>
<!-- 次要动作收纳按钮PRD-REQ-坐席-011 §6.4 决策 C-8 -->
<div class="task-actions-menu-wrapper" v-if="moreActions.length > 0">
<button
class="tic-action-btn tic-action-more-btn"
type="button"
:aria-expanded="isMoreMenuOpen"
aria-haspopup="menu"
aria-label="更多操作"
:title="'更多操作'"
@click="toggleMoreMenu"
>
</button>
<ul v-if="isMoreMenuOpen" class="task-actions-menu" role="menu">
<li
v-for="item in moreActions"
:key="item.key"
class="task-actions-menu__item"
:class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
role="menuitem"
:title="item.tooltip || item.label"
>
<a
v-if="item.href && !item.disabled"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="closeMoreMenu"
>
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</a>
<span v-else class="task-actions-menu__label-disabled">
<span class="task-actions-menu__icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</span>
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -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<Props>()
// ============================================================================
interface Emits {
/** 操作按钮事件 */
/** 操作按钮事件(v1.8 后保留接口,当前主/次按钮均为原生跳转,无 onclick 透传) */
(e: 'action', action: string): void
}
defineEmits<Emits>()
// ============================================================================
// 操作配置(PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳)
// ============================================================================
const { main: mainActionRef, more: moreActionsRef } = useTaskActions({ todoItem: props.todoItem })
/** 主操作按钮(ComputedRef → 解包后给模板用,避免 vue-tsc 复杂类型推断) */
const mainAction = computed<TaskMainAction>(() => mainActionRef.value)
/** ⋯ 次要动作列表 */
const moreActions = computed<TaskMoreAction[]>(() => moreActionsRef.value)
// ============================================================================
// ⋯ 菜单状态
// ============================================================================
const isMoreMenuOpen = ref<boolean>(false)
/**
* 切换 ⋯ 菜单开关
* - 关闭用点击外部/Escdocument 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<string, string> = {
resolved: '已解决',
}
// ============================================================================
// 计算属性
// ============================================================================
/** ITSM 工单跳转链接 */
const itsmUrl = computed<string>(() => {
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;
}
</style>
@@ -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 中打开」enabledfallback
*
* 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 → 主「接单」disabledU-1.1 阻塞)
* - B-2: ticket processing → 主「结单」disabledU-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> = {}): 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<typeof buildTicketMoreActions>,
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 + primaryfallback', () => {
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)
})
})
@@ -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'
/** 跳转 URLhref 模式);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
/** 跳转 URLhref 模式) */
href?: string
}
/** TaskDetailView 操作区整体配置 */
export interface TaskActionsConfig {
/** 主操作按钮(1 个) */
main: ComputedRef<TaskMainAction>
/** ⋯ 次要动作列表(0~5 个) */
more: ComputedRef<TaskMoreAction[]>
}
/** 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 → 「📥 接单」+ 跳转 ITSMdisabled 占位)
* - processing→ 「✅ 结单」+ 跳转 ITSMdisabled 占位)
* - 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<string>(() => {
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<TaskMainAction>(() => {
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<TaskMoreAction[]>(() => {
if (todoItem.type === 'ticket') {
return buildTicketMoreActions(todoItem, itsmUrl.value)
}
if (todoItem.type === 'approval') {
return buildApprovalMoreActions(todoItem, wecomApprovalUrl || '')
}
return []
})
return { main, more }
}