游乐游手机版
首页/前端开发/文章详情

多角色督办任务详情页从权限矩阵到组件拆分的完整实现

时间:2026-06-22 10:39
基于权限矩阵配置表,将角色与状态组合的按钮可见性声明式管理,避免条件判断堆砌。采用组件拆分与hooks分层(数据层、操作层、表现层),实现可维护的详情页。核心交互包括子任务拆解、进度提交与审核流转,并通过错误重试、空状态提示等优化提升易用性。

多角色督办任务详情页:从权限矩阵到组件拆分的完整实现

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

多角色督办任务详情页:从权限矩阵到组件拆分的完整实现

想象一下,状态机有6种状态,角色有5种,算下来潜在的if-else组合高达30种。最直接的做法就是在模板里堆v-if,开工第一天看起来很爽,但不出两周,这段代码就会变成没人敢碰的“意大利面条”。核心矛盾其实就一句话:同一页面、同一接口返回的数据,不同用户看到不同的UI——到底怎么设计才能让复杂度可控?

权限模型:用配置表解出角色 × 状态矩阵

设计思路

面对5×6的矩阵,与其用条件判断堆砌,不如把它抽象成声明式的配置。核心原则很朴素:把“谁在什么状态下能干什么”变成数据,而不是逻辑

角色推导:determineModes

页面加载后,接口返回任务数据(含responsibleUserIdcloseUserIddecomposerUserIdcreator),第一步就是判断“当前用户是什么角色”。

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()拉数据 → 写入taskDetail ref → 组件watch后调用reSetFormData回填表单
  • 用户点击按钮 → ActionButtons emit action key → TaskDetailPageactionMap分发到对应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。

来源:https://juejin.cn/post/7651920393360343078
上一篇TinyRobot输入区组件设计原理与实现深度解析 下一篇Vue模板编译原理:HTML如何转化为JS
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
checked表单属性与CSS变量实现换肤原理
前端开发 · 2026-07-02

checked表单属性与CSS变量实现换肤原理

先聊一个有意思的现象:不需要编写任何 JavaScript,仅靠一个 :checked 伪类,就能驱动整个主题切换系统。听起来很神奇,但原理其实并不复杂——核心在于,:checked 是浏览器原生状态的实时镜像,而不是 JS 模拟出来的开关。 用户点击 ,或者用键盘空格键选中它,状态更新的那一刻,C

HTML meta标签页面定时跳转实现
前端开发 · 2026-07-02

HTML meta标签页面定时跳转实现

说到前端开发中最简洁的页面跳转方式,meta http-equiv= "refresh " 绝对算得上一个经典方案。不过别看它结构简单,格式上稍有疏忽,页面就可能原地卡死,或者直接跳到一个错误地址。下面把几个最容易踩坑的细节彻底讲清楚,帮你避开这些常见陷阱。 使用 http-equiv= "refresh

Cypress跨测试用例状态传递的不推荐但可选方案
前端开发 · 2026-07-02

Cypress跨测试用例状态传递的不推荐但可选方案

Cypress 默认的设计哲学很干脆:每个测试用例都必须是独立小王国,谁也不靠谁。这意味着 it() 执行前,浏览器上下文会被“一键还原”——页面状态、LocalStorage、Cookies 统统清空,强制维护测试隔离。这一规则让很多新手头疼:明明前一个测试已经创建了员工,后一个测试怎么就没法直接

全面深度解析HTML主体main标签唯一性原则与使用规范
前端开发 · 2026-07-02

全面深度解析HTML主体main标签唯一性原则与使用规范

在进行前端无障碍审计时,不少开发者会遇到一个奇怪的场景:浏览器不报错,但Lighthouse却直接标红“duplicate-main”。这其实是语义层与渲染层之间的根本差异。 为什么浏览器不报错但 Lighthouse 直接标红 duplicate-main 关键原因就在于:`main` 是语义锚点

HTML main标签在文档结构中的唯一性详解
前端开发 · 2026-07-02

HTML main标签在文档结构中的唯一性详解

先做一个快速检测:打开你最近开发的一个页面,按下 Ctrl+F 搜索 。如果搜索结果里出现2个以上,那这篇文章建议你认真读完。 本期要聊的主题,是HTML标签中一个看似简单、实际极易踩坑的核心知识点:main标签的唯一性。很多开发者知道这个标签的存在,但真正写到项目里,尤其是用了React、Vue这