Vue 3 + TypeScript 审批流前端架构:状态机、组件化与Pinia实践

📅 2026/8/12 14:28:04
Vue 3 + TypeScript 审批流前端架构:状态机、组件化与Pinia实践
1. 项目概述为什么审批流是前端绕不开的“硬骨头”做过后台管理系统的朋友对“审批流”这三个字一定不陌生。它几乎是所有涉及业务流程系统的标配从请假报销到采购合同再到内容发布但凡需要多级、多人确认的环节都离不开它。听起来就是个增删改查加几个状态对吧但真上手用Vue去实现你会发现坑一个接一个状态流转的逻辑怎么设计才清晰不同角色的操作权限如何动态控制审批链路的可视化展示怎么做还有那烦人的数据一致性、操作回退、消息通知……每一个点都够你琢磨半天。我接手过好几个从“简单状态机”演变成“意大利面条代码”的审批模块后期维护起来简直是一场噩梦。所以这次我们不谈空泛的概念直接基于Vue 3 TypeScript的组合从零开始手把手搭建一个高内聚、低耦合、可扩展的审批流前端实现方案。我会把我在实际项目中踩过的坑、总结的最佳实践以及那些官方文档里不会写的细节毫无保留地分享出来。无论你是刚接触这类需求的新手还是想优化现有代码的老鸟相信都能从中找到可以直接“抄作业”的灵感。2. 核心设计状态驱动与组件化架构审批流的核心是“状态”与“流程”。一个糟糕的设计会把业务逻辑、UI渲染、状态管理全部搅在一起。我们的目标是实现关注点分离让数据流清晰可预测。2.1 状态机业务流程的“骨架”审批流本质是一个状态机。我们首先要抽象出一个与UI无关的、纯粹的业务状态模型。我强烈建议使用XState或类似的有限状态机库但为了降低理解成本我们先从自己实现一个轻量版开始。核心是定义一个ProcessStatus枚举和对应的流转规则。// types/approval.ts export enum ProcessStatus { DRAFT draft, // 草稿 PENDING pending, // 审批中 APPROVED approved, // 已通过 REJECTED rejected, // 已驳回 CANCELLED cancelled, // 已撤回 // 可能还有更多如 RE_SUBMIT重新提交 } // 状态流转映射当前状态 - 可执行的操作 - 下一个状态 export const STATUS_TRANSITIONS: RecordProcessStatus, Array{ action: string; nextStatus: ProcessStatus; allowedRoles: string[] } { [ProcessStatus.DRAFT]: [ { action: submit, nextStatus: ProcessStatus.PENDING, allowedRoles: [applicant] }, ], [ProcessStatus.PENDING]: [ { action: approve, nextStatus: ProcessStatus.APPROVED, allowedRoles: [approver, admin] }, { action: reject, nextStatus: ProcessStatus.REJECTED, allowedRoles: [approver, admin] }, { action: cancel, nextStatus: ProcessStatus.CANCELLED, allowedRoles: [applicant] }, // 申请人可撤回 ], [ProcessStatus.REJECTED]: [ { action: resubmit, nextStatus: ProcessStatus.PENDING, allowedRoles: [applicant] }, ], [ProcessStatus.APPROVED]: [], [ProcessStatus.CANCELLED]: [], };这个映射表就是我们的“宪法”所有前端操作权限、按钮显示逻辑都源于此。它的好处是当业务流程变更时比如增加一个“会签”环节你只需要修改这个配置表而不是在几十个Vue组件里搜索硬编码的逻辑。实操心得很多团队喜欢把状态流转逻辑写在组件的methods里或者Vuex的action里用一堆if-else判断。这会导致业务逻辑分散且难以测试。集中管理状态流转规则是保持代码清晰的第一步。2.2 数据模型如何组织审批单与审批记录一个审批单比如一个请假申请通常包含两部分数据表单内容FormData和审批过程记录ApprovalHistory。在设计接口时一定要将它们分开。// types/approval.ts export interface ApprovalForm { id: string; sn: string; // 单据编号 title: string; applicantId: string; applicantName: string; currentStatus: ProcessStatus; currentAssignee?: string; // 当前待处理人用于待办列表 formData: Recordstring, any; // 具体的表单数据JSON结构 createdAt: string; updatedAt: string; } export interface ApprovalHistory { id: string; approvalId: string; // 关联的审批单ID operatorId: string; operatorName: string; operatorRole: string; action: string; // ‘submit‘, ‘approve‘, ‘reject‘... fromStatus: ProcessStatus; toStatus: ProcessStatus; comment?: string; // 审批意见 attachments?: string[]; // 附件 operatedAt: string; }为什么要把历史记录单独拎出来因为它们的读写频率和场景不同。加载审批单详情时我们可能需要同时获取表单和最新的几条记录。但在“审批时间线”组件里我们可能只需要分页查询历史记录。分离设计让前后端接口更灵活前端状态管理也更清晰。2.3 组件化设计高内聚与低耦合基于以上模型我们可以规划出几个核心组件ApprovalFormViewer只负责渲染审批单的表单内容。它接收一个formDataprop根据表单类型请假、报销等动态渲染字段。这个组件应该是“纯净”的不关心当前状态和操作。ApprovalActionBar操作按钮栏。它接收当前状态currentStatus、用户角色userRole和审批单ID。其内部逻辑就是查询STATUS_TRANSITIONS计算出当前用户可执行的操作并渲染对应的按钮提交、同意、驳回、撤回等。点击按钮后它只发出一个事件如actionhandleApprove由父组件或状态管理容器处理具体业务。ApprovalTimeline审批时间线组件。接收一个historyListprop将其渲染为可视化的步骤条或时间线清晰展示“谁在什么时候做了什么”。ApprovalDetailContainer容器组件。它负责组合上述三个展示组件并承担数据获取、状态管理的职责。它知道如何根据审批单ID去调用API获取ApprovalForm和ApprovalHistory然后将数据分发给子组件。这种设计的好处是每个组件职责单一易于单独开发和测试。比如UI设计师想改时间线的样式你只需要改动ApprovalTimeline完全不用担心会影响到审批逻辑。3. 状态管理与数据流在Vue 3中我们有多种状态管理选择Pinia、Composition API的reactive/ref、甚至Provide/Inject。对于审批流这种涉及多个组件、且状态逻辑复杂的场景我推荐使用Pinia。3.1 设计Pinia Store我们会创建两个主要的Store一个管理审批单列表用于“我的申请”、“待我审批”等列表页一个管理审批单详情。// stores/approvalListStore.ts import { defineStore } from pinia; import { ApprovalForm, ProcessStatus } from /types/approval; import { fetchMyApplications, fetchMyTodoList } from /api/approval; interface ApprovalListState { myApplications: ApprovalForm[]; myTodoList: ApprovalForm[]; // 可以增加分页信息、加载状态等 } export const useApprovalListStore defineStore(approvalList, { state: (): ApprovalListState ({ myApplications: [], myTodoList: [], }), actions: { async loadMyApplications() { try { const res await fetchMyApplications(); this.myApplications res.data; } catch (error) { console.error(加载我的申请失败, error); // 这里应该有一个统一的错误处理例如使用ElMessage } }, async loadMyTodoList() { // 类似... }, // 一个关键动作当在详情页完成操作后更新列表中的对应项 updateItemInList(approvalId: string, newStatus: ProcessStatus) { // 同时更新‘我的申请‘和‘待我审批‘列表中的对应条目 const updateFn (list: ApprovalForm[]) list.map(item item.id approvalId ? { ...item, currentStatus: newStatus } : item ); this.myApplications updateFn(this.myApplications); this.myTodoList updateFn(this.myTodoList); }, }, });详情页的Store会更复杂一些因为它要管理表单数据、历史记录以及执行审批动作。// stores/approvalDetailStore.ts import { defineStore } from pinia; import { ApprovalForm, ApprovalHistory, ProcessStatus, STATUS_TRANSITIONS } from /types/approval; import { fetchApprovalDetail, fetchApprovalHistory, executeApprovalAction } from /api/approval; interface ApprovalDetailState { currentApproval: ApprovalForm | null; historyList: ApprovalHistory[]; loading: boolean; } export const useApprovalDetailStore defineStore(approvalDetail, { state: (): ApprovalDetailState ({ currentApproval: null, historyList: [], loading: false, }), getters: { // 基于当前审批单状态和用户角色计算可用的操作 availableActions: (state) { const { currentApproval } state; if (!currentApproval) return []; const userRole approver; // 这里应从用户Store获取真实角色 const transitions STATUS_TRANSITIONS[currentApproval.currentStatus] || []; return transitions.filter(t t.allowedRoles.includes(userRole)).map(t t.action); }, // 判断当前用户是否是处理人用于显示操作栏 isAssignee: (state) { // 逻辑对比 currentApproval.currentAssignee 和当前用户ID return true; // 简化示例 }, }, actions: { async loadApprovalDetail(id: string) { this.loading true; try { const [detailRes, historyRes] await Promise.all([ fetchApprovalDetail(id), fetchApprovalHistory(id), ]); this.currentApproval detailRes.data; this.historyList historyRes.data; } catch (error) { console.error(加载审批详情失败, error); throw error; // 抛出错误由调用组件处理如显示404页面 } finally { this.loading false; } }, async submitAction(action: string, comment?: string, attachments?: File[]) { if (!this.currentApproval) return; this.loading true; try { const params { action, comment, attachments }; await executeApprovalAction(this.currentApproval.id, params); // 操作成功重新加载最新详情 await this.loadApprovalDetail(this.currentApproval.id); // **关键步骤**通知列表Store更新状态 const listStore useApprovalListStore(); listStore.updateItemInList(this.currentApproval.id, this.currentApproval.currentStatus); return true; } catch (error) { console.error(执行操作【${action}】失败, error); return false; } finally { this.loading false; } }, }, });注意事项Store之间的通信。这是Pinia使用中的一个常见痛点。approvalDetailStore在执行完操作后需要主动调用approvalListStore的方法来更新列表数据以保持整个应用状态的一致性。你也可以考虑使用$subscribe监听变化但主动调用在逻辑上更清晰可控。3.2 组合式APIComposition API的运用在组件层面我们使用setup语法糖配合组合式API让逻辑更聚合。以ApprovalDetailContainer.vue为例!-- ApprovalDetailContainer.vue -- script setup langts import { onMounted, computed } from vue; import { useRoute } from vue-router; import { useApprovalDetailStore } from /stores/approvalDetailStore; import ApprovalFormViewer from ./ApprovalFormViewer.vue; import ApprovalActionBar from ./ApprovalActionBar.vue; import ApprovalTimeline from ./ApprovalTimeline.vue; const route useRoute(); const approvalId route.params.id as string; const detailStore useApprovalDetailStore(); const { currentApproval, historyList, availableActions, isAssignee, loading } storeToRefs(detailStore); // 加载数据 onMounted(() { detailStore.loadApprovalDetail(approvalId); }); // 处理操作按钮点击 const handleAction async (action: string, comment?: string) { const success await detailStore.submitAction(action, comment); if (success) { // 可以在这里给出成功提示 ElMessage.success(操作成功); } }; /script template div classapproval-detail-container v-loadingloading el-card v-ifcurrentApproval !-- 标题区 -- template #header div classheader span classtitle{{ currentApproval.title }}/span el-tag :typestatusTagType(currentApproval.currentStatus) {{ currentApproval.currentStatus }} /el-tag /div /template !-- 表单内容区 -- ApprovalFormViewer :form-datacurrentApproval.formData / !-- 操作按钮区 -- ApprovalActionBar v-ifisAssignee :available-actionsavailableActions actionhandleAction / !-- 审批时间线 -- el-divider / h3审批记录/h3 ApprovalTimeline :history-listhistoryList / /el-card /div /template可以看到容器组件非常干净它只做三件事1. 从路由获取ID2. 调用Store加载数据3. 将数据和回调函数分发给子组件。所有业务逻辑都沉淀在Store和子组件中。4. 核心功能实现与难点攻克有了清晰的架构和状态管理我们来逐一攻克审批流中的几个典型难点。4.1 动态表单渲染如何应对千变万化的业务表单审批流承载的业务五花八门请假单、报销单、采购合同的字段完全不同。我们不可能为每一种业务写一个单独的渲染组件。解决方案是基于JSON Schema的动态表单渲染。思路后端为每种审批类型定义一个JSON Schema描述表单的字段、类型、验证规则等。前端根据这个Schema动态生成表单UI。定义Schema接口interface FormFieldSchema { key: string; // 字段名如 ‘days‘ label: string; // 显示标签如 ‘请假天数‘ type: input | number | date | select | textarea | upload; // 组件类型 componentProps?: Recordstring, any; // 传递给具体组件的属性如 select的options rules?: Array{ required?: boolean; message: string; trigger: string }; // 校验规则 } interface FormSchema { type: string; // ‘leave‘, ‘reimburse‘ fields: FormFieldSchema[]; }创建动态表单渲染组件!-- DynamicFormRenderer.vue -- script setup langts import { computed } from vue; import { FormFieldSchema } from /types/form; const props defineProps{ schema: FormFieldSchema[]; modelValue: Recordstring, any; }(); const emit defineEmits([update:modelValue]); const formModel computed({ get: () props.modelValue, set: (val) emit(update:modelValue, val), }); // 组件映射字典 const componentMap { input: resolveComponent(ElInput), number: resolveComponent(ElInputNumber), date: resolveComponent(ElDatePicker), select: resolveComponent(ElSelect), textarea: resolveComponent(ElInput, { type: textarea }), // upload 需要特殊处理 }; /script template el-form :modelformModel label-width100px el-form-item v-forfield in schema :keyfield.key :labelfield.label :propfield.key :rulesfield.rules component :iscomponentMap[field.type] v-modelformModel[field.key] v-bindfield.componentProps / !-- 对于upload类型需要渲染ElUpload组件并处理文件上传 -- /el-form-item /el-form /template在ApprovalFormViewer中使用ApprovalFormViewer组件根据审批单的类型从currentApproval.formData或额外接口获取请求对应的JSON Schema然后将Schema和表单数据formData传给DynamicFormRenderer。对于“查看”模式将所有字段设置为只读即可。踩坑记录动态表单的验证和文件上传是两大难点。对于验证确保在提交前调用动态表单实例的validate方法。对于文件上传ElUpload组件需要单独处理它的v-model绑定的是文件列表且上传动作是异步的需要在上传成功后将返回的URL地址赋值给formModel中对应的字段。4.2 条件审批与分支流程简单的线性审批A批完给B很容易但现实业务中常有“如果金额大于1万需要总监审批否则经理审批即可”这样的条件分支。前端如何优雅地支持策略前端不负责流程分支的逻辑判断这个规则应该由后端工作流引擎如Flowable、Activiti或业务代码定义。前端只需要在提交申请时将完整的表单数据formData发送给后端。后端根据数据和预设规则计算出下一个或一批处理人并返回给前端例如在提交成功的响应里。前端在“时间线”或“当前处理人”区域展示后端返回的下一步信息。对于更复杂的可视化流程设计器让管理员拖拽节点配置流程那是一个独立的复杂功能通常需要集成一个专门的流程图库如G6、jsPlumb其数据结构和交互逻辑与核心审批流相对独立这里不展开。4.3 实时更新与消息通知审批后如何让相关用户及时感知有两种常见方案WebSocket长连接建立连接后服务端在审批状态变更时主动推送消息给前端如“您有一个新的待办”、“您的申请已被批准”。前端收到消息后更新对应的Store数据如刷新待办列表并给出桌面通知Notification API。轮询Polling作为备选方案在列表页面定时如每30秒调用接口检查更新。虽然实时性稍差但实现简单兼容性好。在approvalDetailStore的submitAction成功后我们已经通过重新调用loadApprovalDetail和更新列表Store来保证了当前页面的数据最新。结合全局的WebSocket消息推送就能实现完整的实时体验。// 一个简单的WebSocket集成示例在App.vue或根组件 import { useApprovalListStore } from /stores/approvalListStore; const listStore useApprovalListStore(); // 假设已经建立了WebSocket连接 ws ws.onmessage (event) { const message JSON.parse(event.data); if (message.type APPROVAL_UPDATED) { const { approvalId, newStatus } message.payload; // 更新列表Store中的对应项 listStore.updateItemInList(approvalId, newStatus); // 如果当前正在查看这个审批单的详情页可以提示用户刷新或自动刷新 if (currentRouteApprovalId approvalId) { ElMessage.info(单据状态已更新正在刷新...); // 触发详情页刷新逻辑 } } };5. 性能优化与体验打磨功能实现后性能和使用体验是区分好坏的关键。5.1 列表页性能优化“我的申请”和“待我审批”列表可能数据量很大。分页与虚拟滚动必须支持后端分页。对于超长列表考虑使用Element Plus的ElTable的虚拟滚动功能或vue-virtual-scroller等第三方库。接口防抖与缓存在搜索框输入时对搜索请求进行防抖处理。对于已加载的审批单详情可以使用Pinia Store进行内存缓存在一定时间内避免重复请求相同ID的详情。// 在approvalDetailStore中增加缓存逻辑 const detailCache new Mapstring, { data: ApprovalForm; timestamp: number }(); const CACHE_TTL 5 * 60 * 1000; // 5分钟 actions: { async loadApprovalDetail(id: string, forceRefresh false) { const cached detailCache.get(id); if (!forceRefresh cached (Date.now() - cached.timestamp) CACHE_TTL) { this.currentApproval cached.data; // 历史记录通常不需要强缓存可以单独请求或包含在缓存里 return; } // ... 正常请求逻辑 detailCache.set(id, { data: this.currentApproval, timestamp: Date.now() }); }, }5.2 操作体验优化按钮防重复提交在submitAction执行期间禁用操作按钮并显示加载状态。这可以通过Store中的loading状态传递给ApprovalActionBar组件来实现。乐观更新Optimistic Update为了更快的响应可以在调用审批接口的同时先在前端更新Store中的数据状态和UI。如果接口最终失败再回滚状态并提示错误。这能极大提升用户体验但需要更谨慎的错误处理。async submitAction(action: string, comment?: string) { if (!this.currentApproval) return; const oldStatus this.currentApproval.currentStatus; const oldHistory [...this.historyList]; // 1. 乐观更新预测下一个状态根据STATUS_TRANSITIONS const predictedNextStatus this.predictNextStatus(oldStatus, action); this.currentApproval.currentStatus predictedNextStatus; // 2. 在历史记录前面插入一条“本地”记录灰色显示 this.historyList.unshift({ id: temp- Date.now(), action, comment, fromStatus: oldStatus, toStatus: predictedNextStatus, operatorName: 我处理中..., operatedAt: new Date().toISOString(), } as ApprovalHistory); // 3. 调用真实接口 try { await executeApprovalAction(this.currentApproval.id, { action, comment }); // 成功用真实数据替换本地记录 await this.loadApprovalDetail(this.currentApproval.id); } catch (error) { // 失败回滚状态 this.currentApproval.currentStatus oldStatus; this.historyList oldHistory; ElMessage.error(操作失败 error.message); } }5.3 可访问性与国际化键盘导航确保所有操作按钮可以通过键盘Tab键聚焦并响应Enter键触发。ARIA标签为屏幕阅读器用户添加适当的aria-label。国际化i18n使用vue-i18n库将所有的状态枚举值ProcessStatus、操作名称、按钮文字、提示信息都提取到语言包中。6. 部署、监控与迭代6.1 构建与部署使用Vue CLI或Vite进行构建。注意配置正确的公共路径publicPath。对于现代浏览器可以配置构建工具生成带哈希的文件名并配置强缓存同时使用{ index: path }配置确保History模式路由在刷新时能正确回退到前端路由。6.2 错误监控与日志前端错误监控至关重要。全局错误捕获在Vue应用入口使用app.config.errorHandler捕获组件渲染和事件处理器的错误。API请求监控在axios拦截器中记录失败的请求信息URL、参数、错误码。集成监控平台将错误信息发送到Sentry、Fundebug等前端监控平台帮助快速定位线上问题。// main.js import * as Sentry from sentry/vue; const app createApp(App); Sentry.init({ app, dsn: YOUR_DSN, integrations: [new Sentry.BrowserTracing()], tracesSampleRate: 0.2, // 性能监控采样率 }); app.config.errorHandler (err, vm, info) { Sentry.captureException(err, { extra: { component: vm?.$options.name, info } }); console.error(Vue Error:, err, info); };6.3 迭代与扩展随着业务发展审批流可能会需要支持加签/转办在STATUS_TRANSITIONS中增加add-reviewer加签和transfer转办动作并设计相应的选择处理人的UI。并行审批状态模型需要扩展一个节点可能对应多个处理人。前端展示上“当前处理人”可能是一个列表并且需要计算“已处理/总人数”。移动端适配基于相同的Store逻辑和组件使用Vant或NutUI等移动端UI库可以快速构建出移动端H5审批应用。实现一个健壮的Vue审批流关键在于前期的领域建模和状态设计。把业务流程抽象为状态机把数据模型与UI组件分离用集中式的状态管理来驱动视图。这样构建出来的系统不仅易于开发和维护更能从容应对未来复杂多变的业务需求变化。在实际编码中多思考“这个逻辑放在哪里最合适”时刻警惕让业务逻辑渗透到视图层你的代码质量就会有质的飞跃。