多角色督办任务详情页:从权限矩阵到组件拆分的完整实现
先说个挺现实的业务场景。IPO项目管理里有个“督办催办”模块:高管发起一个督办任务,指定拆解人把它拆成若干子任务,负责人去执行,审核人审批完成,最后由结单人做最终关闭。同一个详情页,五种角色看到的和能做的完全不同。子任务还有自己的状态流转:从DRAFT一路走到CLOSED。

想象一下,状态机有6种状态,角色有5种,算下来潜在的if-else组合高达30种。最直接的做法就是在模板里堆v-if,开工第一天看起来很爽,但不出两周,这段代码就会变成没人敢碰的“意大利面条”。核心矛盾其实就一句话:同一页面、同一接口返回的数据,不同用户看到不同的UI——到底怎么设计才能让复杂度可控?
权限模型:用配置表解出角色 × 状态矩阵
设计思路
面对5×6的矩阵,与其用条件判断堆砌,不如把它抽象成声明式的配置。核心原则很朴素:把“谁在什么状态下能干什么”变成数据,而不是逻辑。
角色推导:determineModes
页面加载后,接口返回任务数据(含responsibleUserId、closeUserId、decomposerUserId、creator),第一步就是判断“当前用户是什么角色”。
function determineModes(raw: any, isSub: boolean, currentUserId: number): DetailMode[] {
const uid = String(currentUserId)
const responsible = String(raw.responsibleUserId)
const closer = String(isSub ? raw.decomposerUserId : raw.closeUserId)
const creator = String(raw.creator)
const modes: DetailMode[] = []
if (isSub) {
if (closer === uid) modes.push('reviewer')
if (responsible === uid) modes.push('owner')
if (creator === uid) modes.push('senior')
return modes
}
// 主任务
if (closer === uid) modes.push('closer')
if (responsible === uid) modes.push('decomposer')
if (creator === uid) modes.push('senior')
return modes
}
留意一个关键设计决策:用户可能同时匹配多个角色,所以返回数组而非单个值。比如发起人同时也是负责人时,两者的按钮都应该展示。组件消费modes[]而非单一mode,避免了“只能选一个”的信息丢失。
按钮定义:BUTTON_DEF 配置表
按钮的可见性不像传统项目那样在每个组件里写v-if,而是集中在一张配置表里:
export const BUTTON_DEF: Record boolean
disabled?: (subTaskStatus?: SubTaskStatus, progress?: number) => boolean
}> = {
submit_progress: {
label: '提交进展', type: 'primary',
show: (m, s) => ['decomposer', 'owner'].includes(m)
&& (s === 'IN_PROGRESS' || s === 'OVERDUE'),
},
approve_review: {
label: '同意', type: 'success',
show: (m, s) => m === 'reviewer' && s === 'COMPLETION_REVIEW',
},
receive: {
label: '接收任务', type: 'primary',
show: (m, s) => m === 'owner' && s === 'WAIT_RECEIVE',
},
// ... 其余 9 个按钮定义
}
每个按钮的show是一个纯函数,接收角色和状态,返回是否可见。新增一个角色或按钮时,只需要加一条配置,不用翻遍所有组件的v-if。
按钮过滤层:useTaskButtons
有了modes[]和BUTTON_DEF,最后一步是运行时的匹配计算:
const visibleButtons = computed(() => {
if (!modes.value.length) return []
const info = taskDetail.value?.taskInfo
// 终态只展示返回按钮
if (info?.status === 'CLOSED' || info?.status === 'COMPLETED') {
return [{ key: 'back', label: '返回', type: '', disabled: false }]
}
const shown = new Set()
const buttons: { key: string; label: string; type: string; disabled: boolean }[] = []
for (const m of modes.value) {
for (const [key, def] of Object.entries(BUTTON_DEF)) {
if (shown.has(key)) continue
if (def.show(m, status, info?.progress)) {
shown.add(key)
buttons.push({ key, label: def.label, type: def.type, disabled: def.disabled?.(...) ?? false })
}
}
}
return buttons
})
用Set去重:同一个按钮对两种角色同时可见时只出现一次。整个权限模型的流程捋下来就是:
接口数据 → determineModes() → modes[] → useTaskButtons → visibleButtons[] → ActionButtons 组件渲染
这一套下来,修改权限规则的代价从“改一堆组件”变成了“改一行配置”。
页面骨架:把复杂页面拆成可维护的零件
组件树
详情页(TaskDetailPage.vue)是核心,组件层级这样搭:
index.vue
├── TaskDetailPage ← 主容器:表单 + 按钮 + 子任务
│ ├── TaskHeader ← 标题、状态、任务编号、创建人
│ ├── DataForm × 4 ← 信息区 / 总进度 / 进度填报 / 进展说明
│ ├── SubTaskTable ← 子任务只读列表(decomposer/closer 可见)
│ ├── FlowRecordDrawer ← 流转记录抽屉
│ └── ActionButtons ← 底部操作栏(根据 visibleButtons 渲染)
└── DecomposeDrawer ← 独立于详情页的拆解抽屉(顶层管理)
DecomposeDrawer没有嵌套在TaskDetailPage内部,而是提升到index.vue层级与详情页平级。因为它的打开/关闭生命周期完全独立于详情的刷新——拆解完成后只需要触发父级刷新,抽屉本身不需要知道详情内部的表单状态。
Hooks 分层
四个hook分别承担不同层级的职责:
useTaskDetail —— 数据层:fetch 详情 + 子任务、角色推导、全局错误
useTaskActions —— 操作层:提交进展、接收、审批、驳回等所有 API 调用
useTaskButtons —— 表现层:modes[] → 可见按钮列表
useDecompose —— 独立场景:子任务拆解抽屉的状态机(增删改查 + 保存)
分层带来的好处很直观:每个hook可以独立阅读和修改。比如要改“驳回时弹出的输入框文案”,只动useTaskActions,不影响数据加载或按钮渲染逻辑。
数据流
单向向下,事件向上:
useTaskDetail.fetchDetail()拉数据 → 写入taskDetailref → 组件watch后调用reSetFormData回填表单- 用户点击按钮 →
ActionButtonsemitactionkey →TaskDetailPage的actionMap分发到对应useTaskActions方法 - 操作成功后
await fetchDetail()原地刷新,页面状态更新
没有全局store,所有状态都在组合式API的ref中闭环。毕竟详情页的数据不需要跨页面共享。
三个核心交互场景
拆解子任务
拆解是最高频的操作。拆解人在DecomposeDrawer(80%宽度的抽屉)中编辑子任务列表。几个关键设计点值得一提:
防重复空白行:addRow前检查是否存在标题为空的行,有则skip并提示,避免用户狂点新增按钮导致列表中间出现N行空白。
const addRow = () => {
if (subTaskList.value.some((t) => !t.title)) {
ElMessage.warning('请先填写当前空白行')
return
}
subTaskList.value.push(EMPTY_SUB_TASK())
}
编辑保护:非DRAFT状态的子任务所有字段只读。isNotDraft一行判断搞定,不必列举哪些状态能编辑:
const isNotDraft = (row: { status: string }) => row.status !== 'DRAFT'
占比校验:保存时校验三项——必填项、每行占比 > 0、合计 = 100%。在抽屉底部用颜色提示(绿色100%、红色超出、橙色不足)。
进度输入的小数支持:ProgressInput组件使用parseFloat + 正则过滤非数字字符,保证输入灵活但不越界。
提交进展
负责人或拆解人可以填入当前进度百分比 + 进展描述 + 附件。验证规则很直接:进度必须 > 0;申请完成时进度必须 = 100%。表单数据走DataForm.getFormData()提取,经actionMap分发:
submit_progress: async () => {
if (!(await validateProgressReport())) return
const reportData = progressReportFormRef.value?.getFormData() || {}
const descData = descriptionFormRef.value?.getFormData() || {}
handleSubmitProgress(
reportData.progress || 0,
descData.description || '',
fromMulFile(reportData.attachments || [])
)
}
actionMap是一个 key→函数 的映射表,按钮点击时只emit key字符串,由handleAction统一分发。好处很明显:ActionButtons组件不需要知道任何业务逻辑,它只负责“渲染按钮列表 + 回传key”。
审核流转
审核人和结单人的操作链路类似:确认弹窗 → 调接口 → 刷新。驳回比同意多一步——需要输入理由。
const handleRejectApprove = async () => {
const result = await ElMessageBox.prompt('请输入驳回理由', {
inputType: 'textarea',
}).catch(() => null)
if (!result) return
// 调接口驳回,传入 reason
await rejectComplete({ taskId: info.id, reason: result.value })
ElMessage.success('已驳回')
await fetchDetail()
}
一个重要的决策:所有操作完成后统一“留在当前页刷新”。之前的实现是操作后跳转到上级页面,用户如果想连续审批多个任务需要重复进入,体验很割裂。改进后操作成功只刷新当前数据,用户可以立即看到最新状态并继续操作。
优化与打磨:从“能用”到“好用”
完成核心功能后,还有一批P0-P2的细节能让页面更健壮:
错误重试:fetchDetail失败后页面展示el-result error组件,但之前没有重试入口,用户只能手动刷新浏览器。加了一个“重试”按钮,调用refreshAll()同步刷新详情和子任务列表。
空状态提示:子任务列表为空时,之前只展示空表头,用户不确定是加载失败还是确实没有数据。改为,同时在接口报错时展示错误结果。
无权限角色提示:用户未匹配任何角色(modes为空数组)时,按钮区一片空白。加了一条:“您不是该任务的负责人/审核人/创建人,仅可查看”——让用户知道原因而不是疑惑。
未保存确认:拆解抽屉关闭时,如果用户编辑了子任务但没有点确认,数据会丢失。改进方案是打开抽屉时保存初始快照,关闭时对比当前数据,有差异则弹确认框。
防多次提交:所有操作的submitLoading统一管理,按钮在请求进行中全部禁用,避免重复提交。
总结:可复用的四种模式
这个模块做下来,有四个模式可以迁移到其他复杂详情页:
1. 配置驱动的权限模型
角色 × 状态的矩阵用声明式配置管理。新增角色只需加条目,不需要翻遍组件的条件分支。核心公式:determineModes() + BUTTON_DEF + useTaskButtons = 可维护的按钮控制。
2. Hooks 职责分层
数据层 / 操作层 / 表现层三层分离。每层只做一件事,每个hook的文件不超过160行。对比一个500行的“上帝hook”,四个100行的小hook更容易阅读和修改。
3. 以“操作场景”为边界的组件拆分
DecomposeDrawer独立于TaskDetailPage不是因为视觉上它是抽屉,而是因为它代表一个完整的独立业务场景——有自己的loading、错误、数据和行为。组件的边界应该跟业务操作对齐,而不是跟视觉区域对齐。
4. 防御性设计的清单
空状态、错误重试、未保存确认、防重复提交、输入容错——这些不是“锦上添花”,而是前端质量的基本功。每一项都在几十行代码内解决,但每一项的缺失都会导致用户困惑或数据丢失。
模块源码位于 src/modules/project/pages/controlTower/superviseAndUrge/,基于 Vue 3 + TypeScript + Element Plus。
