我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

任务详情页架构设计:流程驱动、模块化编排、权限可控

任务详情页架构设计:流程驱动、模块化编排、权限可控 写给熟悉 Vue2.3 的你这篇讲的是「任务详情页」的整体架构设计比列表页更复杂——要管数据加载、流程步骤、操作按钮、附件、时间线、权限……一大堆东西。碰到 React 新概念我会用 Vue 类比碰到 TypeScript 语法我会用大白话解释。先记住几个核心词controller— 控制器总指挥官协调各个子模块orchestration— 编排把多个模块按顺序组织起来协同工作permission— 权限用户能不能做某件事gate— 门控像门禁一样控制请求能不能进去mutation— 变更修改数据的操作比如增删改audit log— 审计日志操作记录谁在什么时候做了什么readonly— 只读的只能看不能改mock— 模拟数据开发时用假数据代替真实接口前言详情页是后台系统中信息密度最高、交互最复杂的页面类型之一。用户需要在一个页面上完成查看信息、修改状态、添加记录、管理附件、评价反馈等一系列操作。当功能模块越来越多时如何保证代码不膨胀、不耦合、可扩展本文以任务详情页为例分享一个生产级详情页的完整架构设计。本文基于真实生产项目脱敏整理核心架构和设计思路保持原貌。一、整体架构总览小白课堂这一章讲什么跟列表页一样先看地图。详情页比列表页功能多、模块多但核心思想是一样的分层、解耦、各司其职。你先建立一个整体印象详情页从上到下有哪些部分有多少个子模块它们之间怎么配合1.1 页面布局结构TaskDetailPage ├── 处理流程步骤条待确认 → 处理中 → 已解决 → 已关闭 ├── 顶部操作栏[编辑][确认][标记已解决][重新打开][刷新]├── 摘要区 附件面板双栏 │ ├── 标题 核心标签 描述 更多信息 │ └── 附件预览 管理入口 └── 操作记录表 时间线双栏 ├── 操作日志表格 └── Markdown 时间线 添加补充记录 结构上从上到下就是流程步骤 → 操作按钮 → 摘要附件 → 操作记录时间线。跟 Vue 项目里的详情页结构差不多就是组件划分更细一些。1.2 模块化架构TaskDetailController(总控制器)├── useTaskDetail 基础数据加载 ├── useTaskFlow 流程步骤 ├── useTaskActions 操作栏 ├── useTaskSummary 摘要区 ├── useTaskAttachments 附件管理 ├── useTaskTimeline 时间线 ├── useTaskAuditLog 操作记录 ├── useTaskEvaluation 评价 └── useTaskPermissions 权限小白课堂什么是总控制器模式想象一个交响乐团小提琴手useTaskFlow只管拉小提琴鼓手useTaskActions只管打鼓钢琴手useTaskAttachments只管弹钢琴指挥TaskDetailController负责让大家在正确的时间演奏正确的音符总控制器不做具体的业务它只做一件事编排。把各个子 Hook 的结果收集起来整理好再统一交给组件用。对应 Vue 项目里总控制器就像 Vuex/Pinia 的根 store把各个模块module组合在一起。二、总控制器模式Hook 组合的艺术小白课堂总控制器模式为什么重要如果没有总控制器每个子模块各管各的状态会出现什么问题子模块之间要通信怎么办比如操作成功后要刷新附件列表多个地方同时触发刷新怎么办重复请求权限判断散落在各个组件里改起来很麻烦总控制器就是中央指挥部所有状态和操作都从这里出去子模块之间通过控制器协调不会直接互相调用。对应 Vue 项目里这就是状态管理的作用——把共享状态放到 store 里统一管理而不是组件之间互相传。2.1 为什么需要总控制器详情页的子模块很多如果每个模块各自为政状态会散落在各处很难协调。总控制器Controller把所有子能力的 Hook 组合在一起提供统一的状态和操作入口。2.2 总控制器实现// src/pages/task/detail/hooks/use-task-detail-controller.tsimport{useCallback}fromreact;import{useTaskDetail}from./use-task-detail;import{useTaskFlow}from./use-task-flow;import{useTaskActions}from./use-task-actions;import{useTaskAttachments}from./use-task-attachments;import{useTaskTimeline}from./use-task-timeline;import{useTaskAuditLog}from./use-task-audit-log;import{useTaskEvaluation}from./use-task-evaluation;import{useTaskPermissions}from./use-task-permissions;import{createDetailRefreshGate}from./refresh-gate;// 导入所有子 Hook 和工具函数// 创建一个刷新门控实例全局只有一个放在模块作用域constrefreshGatecreateDetailRefreshGate();// 这个变量定义在组件外面属于模块级变量// 模块级变量 整个模块共享的变量所有组件实例共用一份// 就像 Vue 里写在 export default 外面的变量// 它不是响应式的变了不会触发组件重新渲染// 总控制器 Hook接收 taskId返回所有状态和操作exportfunctionuseTaskDetailController(taskId:string|null){// 1. 基础数据加载最核心的数据其他 Hook 都依赖它constdetailuseTaskDetail(taskId);// detail 包含data任务详情数据、isLoading、error、refresh 等// 2. 流程步骤依赖任务详情数据constflowuseTaskFlow(detail.data);// 把任务详情数据传进去流程 Hook 根据当前状态计算步骤条的位置// 3. 权限判断依赖任务详情数据constpermissionsuseTaskPermissions(detail.data);// 权限 Hook 根据任务状态和用户权限计算每个操作能不能执行// 4. 附件管理依赖任务详情数据数据变更后触发刷新constattachmentsuseTaskAttachments(detail.data,{onChanged:detail.refreshAfterMutation,// onChanged 是回调函数附件增删改后调用这个函数刷新详情// 为什么要传回调因为附件 Hook 内部不知道怎么刷新详情// 由控制器来决定刷新逻辑解耦});// 5. 时间线consttimelineuseTaskTimeline(detail.data,{onChanged:detail.refreshAfterMutation,});// 6. 操作记录审计日志constauditLoguseTaskAuditLog(taskId);// 7. 评价constevaluationuseTaskEvaluation(detail.data,{onSubmitted:detail.refreshAfterMutation,});// 8. 操作栏依赖任务数据、权限、成功回调constactionsuseTaskActions({task:detail.data,permissions,onSuccess:detail.refreshAfterMutation,});// 统一的刷新入口 constrefreshuseCallback(async(){if(!taskId)return;// taskId 为空就不刷新// 通过刷新门控来刷新防止重复请求awaitrefreshGate.guard(()detail.refresh());// guard 守卫、门控// 它会检查现在是不是正在刷新是不是在节流期内// 满足条件才会真正执行 refresh 函数},[taskId,detail]);// 统一返回 return{// 基础数据task:detail.data,// 任务详情数据isLoading:detail.isLoading,// 加载状态error:detail.error,// 错误信息// 子模块flow,// 流程步骤permissions,// 权限attachments,// 附件timeline,// 时间线auditLog,// 操作记录evaluation,// 评价actions,// 操作栏// 公共方法refresh,// 刷新方法给外部手动刷新用refreshAfterMutation:detail.refreshAfterMutation,// 变更后刷新优先级更高给外部操作成功后调用};} 你发现了吗所有子 Hook 的依赖都是从上往下传的detail → flow / permissions / attachments / timeline / evaluation / actions这就像一条流水线数据从上游流到下游。好处是数据流清晰一眼就能看出谁依赖谁。2.3 子 Hook 的统一接口规范每个子 Hook 遵循同样的输入输出模式输入任务详情数据 回调选项输出该子能力的 state actions。设计哲学组合优于继承。不用类组件不用 HOC不用 render props就是纯纯的 Hook 组合。每个子 Hook 独立、可测、可复用总控制器负责编排。小白课堂什么是组合优于继承生活例子你想要一个会飞的超人玩具。继承方式先做一个人的类然后做一个会飞的人的子类然后做一个超人的子类……层层继承改起来很麻烦组合方式做一个人的部件做一个飞行能力的部件拼在一起就是超人。想加激光眼再加一个部件就行Hook 就是组合的思想每个 Hook 是一个独立的能力部件你需要什么能力就引入什么 Hook拼在一起就行。对应 Vue 里的 mixin 也是组合思想的一种但 mixin 有命名冲突、来源不清晰的问题Hook 解决得更好。三、数据加载层与刷新门控小白课堂刷新门控是什么想象公司只有一部电梯大家都想坐没有门控 → 一群人挤进去电梯超载报警谁都走不了有门控 → 一次只能进一拨人其他人在外面等着上一拨到了再进下一拨刷新门控就是管刷新请求的电梯正在刷新时新的刷新请求排队等着节流期内比如2秒内直接忽略重复的刷新请求刷新完成后如果有排队的请求就执行下一个对应 Vue 项目里你可能会用防抖或节流来处理类似问题但门控比单纯的节流更智能——它还有排队机制。3.1 基础详情 Hook核心要点使用 AbortController 取消未完成请求区分普通刷新和变更后刷新后者优先级更高直接打断当前请求taskId 为 null 时清空数据 基础详情 Hook 的实现思路跟列表页的 useLoadLedgerPage 差不多useState 存数据 useEffect 监听依赖变化 AbortController 取消请求。这里就不重复贴代码了重点看刷新门控这个新概念。3.2 刷新门控多个地方可能同时触发刷新SSE 事件、操作成功回调、手动刷新等需要门控防止重复请求constTHROTTLE_MS2_000;// 节流时间2000 毫秒 2 秒// 数字中间的下划线 _ 是 JS 的数字分隔符让大数字更好读不影响值// 2_000 就是 2000// 创建刷新门控的工厂函数// 工厂函数 返回一个对象的函数每次调用创建一个新实例// 对应 Vue 里你写一个 createXXX() 函数返回一个对象exportfunctioncreateDetailRefreshGate(){// 门的内部状态闭包变量外面访问不到constgate{isRefreshing:false,// 是否正在刷新pending:false,// 是否有排队的请求throttleUntil:0,// 节流截止时间时间戳};// 核心方法守卫式刷新asyncfunctionguard(refreshFn:()Promisevoid){// 1. 如果正在刷新 → 标记有排队请求直接返回if(gate.isRefreshing){gate.pendingtrue;return;}// 2. 如果在节流期内 → 直接返回不刷新也不排队if(Date.now()gate.throttleUntil)return;// Date.now() 返回当前时间戳毫秒// throttleUntil 是下次允许刷新的最早时间// 3. 开始刷新gate.isRefreshingtrue;try{awaitrefreshFn();// 刷新成功 → 更新节流截止时间gate.throttleUntilDate.now()THROTTLE_MS;}finally{// 4. 刷新结束不管成功失败把状态改回去gate.isRefreshingfalse;// 5. 如果有排队的请求 → 立即执行下一次if(gate.pending){gate.pendingfalse;voidguard(refreshFn);// void 在这里表示我不关心这个 Promise 的结果// 只是触发执行不用 await}}}// 返回对外的接口只暴露 guard 方法内部状态不暴露return{guard};}门控逻辑正在刷新时新请求排队节流期内跳过刷新完成后处理排队请求。小白课堂闭包是什么上面的代码里gate对象定义在createDetailRefreshGate函数内部但guard函数能访问它而且guard被返回到外面后仍然能访问和修改gate——这就是闭包Closure。生活例子你有一个保险柜gate 对象只有你有钥匙guard 函数。别人拿到你的钥匙调用 guard可以通过你操作保险柜但不能直接打开保险柜看里面有什么。好处数据私有化。内部状态isRefreshing、pending 等外面改不了只能通过 guard 方法操作保证了状态的安全和一致性。Vue 里也到处都是闭包比如 computed 的 getter 闭包了 data只是你可能没意识到。四、流程驱动步骤条 操作联动小白课堂流程驱动是什么想象你在快递公司寄快递第1步收件待确认第2步运输中处理中第3步派送中已解决第4步已签收已关闭每个阶段能做的操作不一样待确认时可以确认接单或拒绝处理中时可以标记解决或转派已解决时可以关闭或重开流程驱动就是步骤条显示当前在哪一步操作按钮根据当前状态动态显示。流程变了只需要改配置数据不用改组件代码。4.1 流程配置数据化任务有一套标准的处理流程每个步骤有对应的操作按钮。用配置数据而非硬编码// 流程步骤配置数据化exportconstTASK_FLOW_STEPS[{key:pending,label:待确认,status:pending},{key:processing,label:处理中,status:processing},{key:resolved,label:已解决,status:resolved},{key:closed,label:已关闭,status:closed},];// 这就是一个普通的对象数组每一项代表流程中的一步// 为什么写成配置而不是写死在组件里// 因为以后要加步骤、改顺序、改名字只改这一处就行// 状态 → 可用操作 的映射表也是数据化的exportconstTASK_STATUS_ACTIONS{pending:[confirm,assign,edit,reject],// 待确认状态下可以执行确认、指派、编辑、拒绝processing:[resolve,transfer,edit,add_record],// 处理中状态下可以执行解决、转派、编辑、添加记录resolved:[close,reopen,edit],// 已解决状态下可以执行关闭、重开、编辑closed:[reopen,evaluate],// 已关闭状态下可以执行重开、评价}asconst;// as const 是 TypeScript 的常量断言// 意思是把这个对象的类型锁死变成只读的字面量类型// 好处类型更精确TypeScript 能推断出具体的值// 比如 pending 对应的数组类型是 readonly [confirm, assign, edit, reject]// 而不是笼统的 string[]4.2 流程 HookuseTaskFlow 根据任务状态计算当前步骤索引和可用操作列表提供 isStepDone / isStepCurrent 等判断方法。设计思路流程配置是数据步骤条是数据的渲染操作按钮也是数据的映射。改流程只需要改配置不需要改组件。 你可能会问这跟 Vue 里把配置写在 data 里有什么区别没区别核心思想是一样的——数据驱动视图。只是 React 把它做成了自定义 Hook逻辑可以复用。Vue 里你也可以把这类逻辑抽成 mixin 或 composable。五、操作层权限控制 动作执行小白课堂权限控制为什么重要想象你去公司上班普通员工不能看财务数据实习生不能审批合同经理不能修改老板的文件权限控制就是该看的才给看该操作的才让操作。在后台系统里按钮级别的权限控制非常常见。权限判断通常分两层系统权限你有没有这个功能的权限点后台配置的业务规则当前状态下能不能做这个操作比如已关闭的任务不能编辑两层都满足才显示按钮、才允许执行。5.1 权限系统设计按钮级权限控制每个操作对应一个权限点。权限判断 权限点检查 业务规则检查两者是 AND 的关系// 权限点配置每个操作对应一个权限编码exportconstTASK_PERMISSIONS{edit:task.edit,// 编辑权限confirm:task.confirm,// 确认权限resolve:task.resolve,// 解决权限close:task.close,// 关闭权限reopen:task.reopen,// 重开权限// ...}asconst;// 又是 as const把对象锁死成只读的字面量类型// 权限 HookexportfunctionuseTaskPermissions(task){// useMemo 是 React 的 Hook用来缓存计算结果// useMemo 是什么// 生活例子你有一个复杂的数学题算出来要10秒钟。// 如果题目没变你就不用重新算直接用上次的结果。// useMemo 就是做这个的依赖没变就返回缓存的值。// 对应 Vue 里的 computed计算属性都是缓存计算结果的作用constpermissionsuseMemo((){constresult{};// 遍历所有权限点检查当前用户有没有for(const[key,code]ofObject.entries(TASK_PERMISSIONS)){result[key]hasPermission(code);// hasPermission 是一个全局函数检查当前用户有没有某个权限}returnresult;},[]);// 空依赖组件生命周期内权限不会变只算一次就够了// 核心方法能不能执行某个操作constcan(action){// 两层判断都要满足// 1. 没有任务数据 或 没有权限 → 不能if(!task||!permissions[action])returnfalse;// 2. 业务规则检查状态对不对switch(action){caseconfirm:returntask.statuspending;// 只有待确认状态才能确认caseresolve:returntask.statusprocessing;// 只有处理中状态才能解决caseclose:returntask.statusresolved;// 只有已解决状态才能关闭casereopen:returntask.statusresolved||task.statusclosed;// 已解决或已关闭状态都能重开default:returntrue;// 其他操作比如 edit默认只要有权限就可以}};return{permissions,can};} 为什么要把权限判断和业务规则判断放在一起因为如果分开的话每个使用方都要自己判断两次容易漏。统一成一个can(action)方法用起来就一行代码不容易错。5.2 操作栏 Hook 与组件操作栏 Hook 负责编排所有操作的执行逻辑每个操作一个 handleXxx 函数危险操作带 Modal 二次确认复杂操作带表单弹窗操作成功后调用 onSuccess 回调触发刷新操作栏组件根据权限动态渲染可用按钮无需在组件里写 if/else。 对应 Vue 项目里的实现方式操作逻辑写在 methods 里按钮用 v-if“can(‘xxx’)” 控制显示隐藏危险操作用 this.$confirm 弹确认框思路是一样的只是组织形式不同。六、附件管理双轨制方案小白课堂什么是双轨制想象公司换新系统了旧系统里有 10 年的历史数据。你不能一下子把所有历史数据都迁到新系统量太大风险太高但用户又希望在一个地方看到所有数据。怎么办双轨制新数据走新系统可以上传、删除、管理旧数据从旧系统读只能下载不能改用户看到的是合并后的列表感觉不到有两套系统就像你有两个衣柜一个新衣柜挂新衣服可以随便动一个旧衣柜放旧衣服只能拿出来穿不能往里塞。但你出门选衣服的时候是两个衣柜一起考虑的。6.1 为什么是双轨制老系统的历史附件只能下载不能管理新系统的附件走新的文件服务支持增删改查。两边同时存在采用双轨制两种数据来源统一展示但操作路径分离。export function FileGroupAttachment({ source, // 附件来源标识新系统/旧系统 readonlyAttachments [], // 只读附件来自旧系统 // [] 是参数默认值不传的话默认是空数组 previewLimit 2, // 预览区最多显示几个附件 editable, // 是否可编辑 }) { // 从附件源获取可编辑的附件列表和操作方法 const { attachments, upload, remove } useAttachmentSource(source); // useAttachmentSource 是自定义 Hook封装了附件的加载、上传、删除 // source 不同底层实现不同新系统调新接口旧系统调旧接口 // 合并可编辑的 只读的 const allAttachments [...attachments, ...readonlyAttachments]; // 数组合并先显示可编辑的再显示只读的顺序你说了算 // 删除处理 const handleRemove (fileId) { // 检查是不是只读附件 const isReadonly readonlyAttachments.some(a a.id fileId); // some 是 JS 数组方法只要有一个满足条件就返回 true if (isReadonly) return; // 只读附件不能删直接返回 remove(fileId); // 可编辑附件才调用删除 }; return ( div {/* 预览区 */} div classNamepreview {allAttachments.slice(0, previewLimit).map(file ( // slice(0, n) 取前 n 个附件做预览 AttachmentPreviewItem key{file.id} file{file} // 可编辑模式下才显示删除按钮 onRemove{editable ? () handleRemove(file.id) : undefined} // 三元表达式editable 为 true 就传删除函数否则传 undefined // 子组件收到 undefined 就不会显示删除按钮 / ))} {/* 超过预览数量的显示 N */} {allAttachments.length previewLimit ( // 短路运算符左边为 true 时才渲染右边的内容 // 对应 Vue 里的 v-if div{allAttachments.length - previewLimit}/div )} /div {/* 可编辑模式下显示管理附件按钮 */} {editable button onClick{openManager}管理附件/button} /div ); }6.2 数据源接口抽象通过 AttachmentSource 接口隔离具体实现方便后续切换或 Mock// 附件源接口定义规定了附件源必须有哪些方法exportinterfaceAttachmentSource{load:()PromiseAttachmentItem[];// 加载附件列表返回 Promise附件数组upload:(files:File[])PromiseAttachmentItem[];// 上传文件接收 File 数组返回上传后的附件信息remove:(fileId:string)Promisevoid;// 删除附件接收文件IDgetDownloadUrl:(fileId:string)string;// 获取下载链接}小白课堂为什么要定义接口想象你去买充电器只要是 Type-C 接口的不管什么牌子都能用。接口Interface就是约定——只要满足这个约定里面怎么实现都行。好处可替换今天用阿里云 OSS明天换成腾讯云 COS只要实现同一个接口外面的代码不用改可 Mock开发时后端接口还没好可以写个假的实现返回模拟数据前端照常开发可测试写单元测试时可以用 Mock 实现不用真的发请求对应 Vue 项目里你可能会把 API 调用封装成一个模块模块内部换实现外部无感知——思想是一样的。七、时间线与操作记录双栏布局详情页下半部分采用双栏布局左侧操作记录表表格形式系统自动生成的结构化日志右侧时间线Markdown 形式的人工补充记录两种视图各有侧重互补。时间线支持 Markdown 格式使用 react-markdown 渲染。 这部分主要是布局和组件选择架构上没有新概念。左边是表格Ant Design Table右边是时间线自己写或用组件库。对应 Vue 项目里的实现方式完全一样只是语法不同。八、设计亮点总结架构层面总控制器模式所有子能力通过 Hook 组合统一编排协调各模块刷新流程数据化流程步骤、状态操作都是配置数据改流程不改代码权限 业务规则双层判断既有权限检查又有状态合法性检查刷新门控防止重复刷新兼顾实时性和性能体验层面操作按钮动态显示根据状态和权限自动显示/隐藏用户看不到不能点的按钮危险操作二次确认确认/关闭/重开等操作都有 Modal 确认附件双轨制历史数据和新系统数据平滑过渡用户无感知工程层面子 Hook 独立可测每个子能力一个 Hook可单独开发和测试接口抽象AttachmentSource 等接口隔离具体实现便于 Mock 和替换请求取消AbortController 防止竞态和无效渲染统一刷新入口所有刷新走同一个门控避免重复请求九、可扩展方向字段级编辑每个字段点击即可编辑不用跳转到编辑页AI 辅助AI 自动生成处理建议、总结处理记录协作功能提及、评论回复、实时协同编辑历史版本对比查看任务字段变更历史支持 diff 对比移动端适配响应式布局手机上也能处理任务写在最后详情页是后台系统的主战场——用户大部分工作时间都在这里。一个好的详情页架构应该像搭积木一样新增功能就是加一块积木而不是把整栋楼拆了重盖。核心的设计哲学就是分层、解耦、数据驱动。总控制器管编排子 Hook 管各自的事流程和权限用配置数据驱动。当你发现某个模块越来越复杂的时候大概率就是该再拆一层了。希望这篇文章对你的详情页设计有帮助。有想法欢迎在评论区交流 关于本文基于真实生产项目脱敏整理技术栈为 React 19 TypeScript Vite Ant Design。文中代码为教学目的做了精简实际项目中请根据具体需求调整。 小白复习要点看完这份文档重点理解这几个核心概念概念一句话总结对应 Vue 2 里的什么总控制器模式一个总指挥协调所有子模块Vuex / Pinia store根 store 组合各 moduleHook 组合模式每个功能一个 Hook像搭乐高一样拼起来mixin / 组合式函数Vue 3 才有组合优于继承用拼接代替继承更灵活—刷新门控防止重复请求正在刷新时排队防抖/节流的升级版闭包 Closure函数能访问外面的变量数据私有化Vue 里到处都有只是你没注意流程数据化流程步骤和操作按钮都是配置数据用 v-for 遍历配置数组渲染双层权限判断系统权限 业务规则都满足才可以v-if 权限指令 业务判断useMemo缓存计算结果依赖变了才重新算computed计算属性双轨制新老系统并行用户无感知—接口抽象定义约定具体实现可以随便换API 封装模块as const常量断言把类型锁死成字面量—工厂函数返回一个对象的函数每次调用创建新实例—模块级变量定义在组件外面所有实例共享模块作用域变量如果你这些都理解了恭喜你——你已经掌握了一个生产级详情页的架构设计精髓
返回列表