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:
@@ -84,29 +84,63 @@
|
|||||||
<div class="apv-action-hint">
|
<div class="apv-action-hint">
|
||||||
⚠️ 审批动作需由审批人本人在企微审批中完成,点击下方按钮将在新标签页打开该审批单
|
⚠️ 审批动作需由审批人本人在企微审批中完成,点击下方按钮将在新标签页打开该审批单
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<!-- 底部操作按钮 — PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳 -->
|
||||||
|
<!-- ================================================================== -->
|
||||||
<div class="tic-actions">
|
<div class="tic-actions">
|
||||||
<!-- 通过/拒绝/转交:三个动作共用同一个深链,仅作为跳转入口 -->
|
<!-- 主操作(按状态驱动:sp_status=1→审批通过 / 其他→在企微审批中打开) -->
|
||||||
<a
|
<a
|
||||||
v-for="item in approvalActions"
|
|
||||||
:key="item.action"
|
|
||||||
class="tic-action-btn"
|
class="tic-action-btn"
|
||||||
:href="wecomApprovalUrl"
|
:class="`tic-action-${mainAction.variant}`"
|
||||||
|
:href="mainAction.href"
|
||||||
|
:disabled="mainAction.disabled"
|
||||||
|
:title="mainAction.tooltip || mainAction.label"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@click="handleApprovalAction(item.action)"
|
@click="handleApprovalAction(mainAction.key)"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ mainAction.label }}
|
||||||
</a>
|
</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
|
<a
|
||||||
class="tic-action-btn tic-action-primary"
|
v-if="item.href && !item.disabled"
|
||||||
:href="wecomApprovalUrl"
|
:href="item.href"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
@click="handleApprovalAction('open')"
|
@click="handleApprovalAction(item.key); closeMoreMenu()"
|
||||||
>
|
>
|
||||||
🔗 在企微审批中打开
|
<span class="task-actions-menu__icon">{{ item.icon }}</span>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,8 +149,13 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 导入
|
// 导入
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||||
import type { TodoItemData } from '@/api/todo'
|
import type { TodoItemData } from '@/api/todo'
|
||||||
|
import {
|
||||||
|
useTaskActions,
|
||||||
|
type TaskMainAction,
|
||||||
|
type TaskMoreAction,
|
||||||
|
} from '@/composables/useTaskActions'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Props
|
// Props
|
||||||
@@ -140,17 +179,6 @@ interface Emits {
|
|||||||
|
|
||||||
const emit = defineEmits<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>('')
|
const approvalComment = ref<string>('')
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 计算属性
|
// 企微审批跳转链接(与 useTaskActions 工厂入参共用)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** 企微审批跳转链接 */
|
|
||||||
const wecomApprovalUrl = computed<string>(() => {
|
const wecomApprovalUrl = computed<string>(() => {
|
||||||
const spNo = props.todoItem.description?.sp_no
|
const spNo = props.todoItem.description?.sp_no
|
||||||
const templateId = props.todoItem.description?.template_id
|
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()}`
|
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 类 */
|
/** 审批状态 CSS 类 */
|
||||||
const apvStatusClass = computed<string>(() => {
|
const apvStatusClass = computed<string>(() => {
|
||||||
const status = props.todoItem.description?.sp_status
|
const status = props.todoItem.description?.sp_status
|
||||||
@@ -352,6 +435,8 @@ function formatApplyTime(applyTime: any): string {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tic-action-btn {
|
.tic-action-btn {
|
||||||
@@ -376,6 +461,13 @@ function formatApplyTime(applyTime: any): string {
|
|||||||
color: var(--accent);
|
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 {
|
.tic-action-primary {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--bg-secondary);
|
color: var(--bg-secondary);
|
||||||
@@ -386,4 +478,110 @@ function formatApplyTime(applyTime: any): string {
|
|||||||
background-color: var(--accent-hover);
|
background-color: var(--accent-hover);
|
||||||
color: var(--bg-secondary);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -75,17 +75,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<!-- 底部操作按钮 — 跳转到 ITSM 原系统操作 -->
|
<!-- 底部操作按钮 — PRD-REQ-坐席-011 §6.4 决策 C-8:主操作 + ⋯ 收纳 -->
|
||||||
<!-- ================================================================== -->
|
<!-- ================================================================== -->
|
||||||
<div class="tic-actions">
|
<div class="tic-actions">
|
||||||
|
<!-- 主操作(按状态驱动:pending→接单 / processing→结单 / resolved→已结单 / 其他→打开 ITSM) -->
|
||||||
<a
|
<a
|
||||||
class="tic-action-btn tic-action-primary"
|
class="tic-action-btn"
|
||||||
:href="itsmUrl"
|
:class="`tic-action-${mainAction.variant}`"
|
||||||
|
:href="mainAction.href"
|
||||||
|
:disabled="mainAction.disabled"
|
||||||
|
:title="mainAction.tooltip || mainAction.label"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
🔗 在 ITSM 中打开
|
{{ mainAction.label }}
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -94,13 +138,9 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 导入
|
// 导入
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onBeforeUnmount } from 'vue'
|
||||||
import type { TodoItemData } from '@/api/todo'
|
import type { TodoItemData } from '@/api/todo'
|
||||||
|
import { useTaskActions, type TaskMainAction, type TaskMoreAction } from '@/composables/useTaskActions'
|
||||||
// ============================================================================
|
|
||||||
// ITSM 运维平台基址(用于构建跳转链接)
|
|
||||||
// ============================================================================
|
|
||||||
const ITSM_BASE_URL = 'https://devops.dc.servyou-it.com/ITSM'
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Props
|
// Props
|
||||||
@@ -118,12 +158,72 @@ const props = defineProps<Props>()
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
/** 操作按钮事件 */
|
/** 操作按钮事件(v1.8 后保留接口,当前主/次按钮均为原生跳转,无 onclick 透传) */
|
||||||
(e: 'action', action: string): void
|
(e: 'action', action: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
defineEmits<Emits>()
|
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)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换 ⋯ 菜单开关
|
||||||
|
* - 关闭用点击外部/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<string, string> = {
|
|||||||
resolved: '已解决',
|
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;
|
gap: 8px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tic-action-btn {
|
.tic-action-btn {
|
||||||
@@ -283,6 +372,13 @@ function formatTime(isoString: string): string {
|
|||||||
color: var(--accent);
|
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 {
|
.tic-action-primary {
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
color: var(--bg-secondary);
|
color: var(--bg-secondary);
|
||||||
@@ -293,4 +389,114 @@ function formatTime(isoString: string): string {
|
|||||||
background-color: var(--accent-hover);
|
background-color: var(--accent-hover);
|
||||||
color: var(--bg-secondary);
|
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>
|
</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 中打开」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> = {}): 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 + 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<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 → 「📥 接单」+ 跳转 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<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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user