Python+Django+Vue无纸化审批系统实战:从需求到部署全解析 去年年初某家公司行政负责人找到我说他们内部被纸质审批流程折腾得不行了请假要挨个找领导签字报销单经常在几个部门间流转一周月底财务对账时发现有一半附件是缺的。我当时接下了这个需求前后花了两个多月用 Python Vue 做了一套无纸化办公系统开发环境是 PyCharm后端先用了 Flask 做原型后来整体切到 Django前端是 Vue 3 Element Plus数据库用的 MySQL。这篇文章就把这套系统从需求梳理到技术选型、从后端接口到前端页面、再到部署上线的完整过程写出来适合正在做 OA 类项目、或者准备用 Python Vue 做内部管理系统的开发者参考。这类系统的难点通常不在某个单点技术上而是审批流怎么建模、权限怎么卡、数据怎么留痕。我不会只给结论会把每一个选择的理由和踩过的坑都讲清楚你可以直接拿去用。1. 一个被纸质流程逼出来的项目无纸化办公系统的功能边界1.1 需求从哪来一份流传了三年的Excel审批表很多人以为无纸化办公系统是给公司装个软件其实需求来源往往很朴素。那家公司的行政部一直用一张 Excel 做请假登记报销靠纸质单据用印申请要跑三个办公室。领导想看的统计报表行政要手动筛选汇总出错率高员工抱怨流程慢。我做的第一件事不是写代码而是花了一周时间跟着行政部走了一遍所有审批流程。最后把高频场景收敛成了六类请假、报销、用印申请、公文发布、会议通知、客户信息登记。这里面请假和报销最频繁公文发布是管理层最在意的用印申请则是风险控制最严格的环节。提示如果接到类似需求一定要先搞清楚现有流程里最痛的那一环。设计一个功能齐全但没人用的系统是最浪费时间的失败方式。1.2 功能边界先把最小可用闭环做扎实面对这么多需求我会克制住什么都想实现的冲动。这个项目的第一版只做了四个核心闭环认证与权限闭环登录、角色、菜单、按钮权限。审批闭环发起申请、逐级审批、驳回、撤回、查看审批记录。公文管理闭环草稿、审批、发布、归档。基础数据闭环部门、用户、审批类型、业务流程配置。即时通讯、日程管理、考勤硬件对接这些第一版全部砍掉。项目上线之后再根据使用反馈迭代。事实证明这个决定很关键四个闭环做完后系统已经能覆盖公司 80% 的日常行政需求团队在三个月内就顺利交付了。2. 技术选型实录Django、Flask 与 Vue 的三方博弈2.1 Django vs Flask一个成熟框架对复杂业务的兜底能力标题里同时出现了 Django 和 Flask我在这个项目里恰好都用了。最早用 Flask 做接口原型两个星期写完了用户认证和审批接口非常快。但越往后越觉得吃力用户模型要自己设计数据库迁移要自己写脚本Admin 后台要另找第三方库权限系统更是要从零搭建。后来我把后端整体切换到了 Django。对比下来用一张表说明更直观维度FlaskDjango项目起步速度极快一句话起服务稍慢自带脚手架ORM 与迁移需自行选型如 SQLAlchemy Alembic内置 ORM 和 makemigrations迁移一键完成Admin 后台第三方方案集成成本高自带 admin改配置即可生成管理页面用户认证体系需自行设计自带 User/Group/Permission数据库外键、查询优化手动控制ORM 的 select_related/prefetch_related 成熟适合项目形态轻量接口、微服务、快速原型业务系统、管理后台、多人协作项目在这个项目里审批流涉及多张关联表、多层权限判断、复杂的查询条件Django 内置的这些能力几乎是开箱即用。举一个具体例子Django 的 Group 和 Permission 其实已经实现了基础 RBAC我只需要把公司角色映射到 Group再给每个视图配置permission_classes或装饰器权限模块就完成了大半。如果这部分在 Flask 里做从数据表设计到鉴权逻辑都要自己写保守估计多花两周。我并不是说 Flask 不好。如果只是做一个供几百人使用的轻量查询服务Flask 依然是好选择。但对于一个要长期迭代、多人维护的办公系统Django 更省心。2.2 前端为什么选 Vue生态和团队的现实考量前端选型时也考虑过 React 和 Angular但最终还是选了 Vue。原因很简单第一Vue 的中文文档和社区资料非常完善Element Plus 组件库对后台系统场景的覆盖度高表格、表单、弹窗、时间线等组件直接拿来用开发效率提升明显。第二公司内部做后台系统时Vue 的单文件组件写法更容易让后端同事理解后期交接和维护成本低。第三Vue 3 Vite 的构建速度非常快这在频繁调整页面的开发阶段是实打实的体验提升。我用的组合是 Vue 3 Vite Vue Router Pinia Element Plus。如果团队之前接触过 Vue 2也可以继续用 Vue 2 Element UI但新项目没有历史包袱直接上 Vue 3 更长远。2.3 PyCharm 下的项目工程化配置整个项目在 PyCharm 里开发有几个配置值得说解释器统一用 venv 创建避免全局环境依赖混乱。Django 项目在 PyCharm 里要配置 Django Support绑定 settings.py这样可以直接运行 manage.py 命令、使用模板调试。数据库工具窗口可以直接连接 MySQL查看表结构和执行 SQL 很方便。前端部分我用 PyCharm 打开 Vite 项目也可以在同一个 IDE 里管理前后端两个目录。有一点小建议PyCharm Professional 对 Django 和 JavaScript 的支持更好一些Community 版在 JavaScript 插件方面会有限制。如果公司没有授权可以分开用 VS Code 写前端PyCharm 写后端。3. 数据库与审批状态机无纸化系统的骨架怎么搭3.1 表结构设计以审批实例为核心的建模思路审批流是 OA 系统的核心数据库设计首先要解决一个审批怎么流转的问题。我把数据模型拆成了五张关键表用户表扩展 Django 内置的 User 模型增加工号、所属部门、职位字段。部门表自关联结构支持多级部门树。审批类型表每个类型包含名称、编码、是否为多级审批等属性。审批实例表一次审批申请对应一条记录保存发起人、审批类型、当前状态、表单数据、发起时间。审批记录表每一步操作都记录一条历史包括操作人、动作提交/同意/驳回/撤回、意见、时间。审批实例里有一个字段值得单独说明我没有为请假、报销、用印分别建表而是统一存一个form_data的 JSON 字段。请假存的是开始日期、结束日期、事由报销存的是金额、明细、票据附件。用 JSON 字段的好处是灵活性极高新增一类审批不需要改表结构只需要增加审批类型配置和前端的表单渲染配置即可。当然JSON 字段也有代价复杂的统计查询会麻烦。所以我在 JSON 之外抽了几个公共字段比如金额字段amount对报销类型单独冗余方便财务做月度汇总。对应模型大致是这样class ApprovalInstance(models.Model): STATUS_CHOICES [ (pending, 待审批), (approving, 审批中), (approved, 通过), (rejected, 驳回), (withdrawn, 已撤回), (archived, 已归档), ] approval_type models.ForeignKey(ApprovalType, on_deletemodels.PROTECT) applicant models.ForeignKey(User, on_deletemodels.PROTECT, related_nameapproval_instances) status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultpending) form_data models.JSONField(defaultdict) amount models.DecimalField(max_digits12, decimal_places2, nullTrue, blankTrue) current_step models.IntegerField(default0) created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) class ApprovalRecord(models.Model): instance models.ForeignKey(ApprovalInstance, on_deletemodels.CASCADE, related_namerecords) operator models.ForeignKey(User, on_deletemodels.PROTECT) action models.CharField(max_length20) comment models.TextField(blankTrue) created_at models.DateTimeField(auto_now_addTrue)instance.records这个反向关联在审批详情页会用到前端展示时间线的时候直接按时间倒序取记录即可。用 Django ORM 可以这样写instance ApprovalInstance.objects.select_related(applicant, approval_type).prefetch_related(records__operator).get(idinstance_id)3.2 状态机设计审批流如何避免乱序流转的坑状态机是这个项目里我花时间最多的地方。审批的流转规则非常简单但实现不好就会出各种乱序问题审批人还没批发起人就能撤回已经驳回的单子还能被下一个审批人通过。我在代码里用了一个合法的状态迁移表而不是到处散落的 if elseVALID_TRANSITIONS { pending: [approving], approving: [approved, rejected, withdrawn], rejected: [pending], # 驳回后允许发起人修改后重新提交 withdrawn: [approving], # 允许撤回后再次发起 approved: [archived], }每一次状态变更都要做三个检查当前状态是否允许动作、操作人是否有权限、审批节点是否匹配。发起审批时pending只能流到approving审批人点通过时只有当前审批人是这里列出的节点审批人且状态为approving才允许。其中最容易忽略的是并发问题。如果两个审批人同时操作同一个实例就会产生竞态条件。我的处理方式是在生成新状态时使用 Django 的select_for_update()锁定该行from django.db import transaction with transaction.atomic(): instance ApprovalInstance.objects.select_for_update().get(idinstance_id) if instance.status not in VALID_TRANSITIONS.get(instance.status, []): raise ValidationError(当前状态不允许该操作) # 执行审批动作并记录3.3 操作留痕审计日志表是系统的安全网无纸化办公系统最关键的一点是出了事能追溯。因此审计日志不能等到后面才补必须在设计阶段就放进去。我在这里没有用 Django 的信号signal去记录日志而是选择在视图层显式写入ApprovalRecord。理由很简单信号虽然省事但隐藏了业务逻辑排查问题时不知道记录何时、何地被写入。把日志和审批动作写在同一个事务里代码可读性更好也方便统一控制。日志除了审批记录我还加了一个通用的操作日志表记录登录、修改用户、删除公文等管理动作。后续如果要做数据合规审计这张表的价值会非常大。4. 后端接口层权限治理与审批动作的实现细节4.1 认证方案选型JWT 在前后端分离下的落地前后端分离项目里认证方案我当时考虑过 Session 和 JWT。Django 自带 Session 机制很成熟但前后端分离后Session 需要在服务器端维护状态如果将来扩展小程序端或移动端不太方便。最终选了 JWT简单说就是用户登录成功后后端签发一个 token前端每次请求都带上它后端通过校验 token 来识别用户身份。JWT 的实现我直接用了djangorestframework-simplejwt这个库没有自己造轮子。配置相对简单REST_FRAMEWORK { DEFAULT_AUTHENTICATION_CLASSES: ( rest_framework_simplejwt.authentication.JWTAuthentication, ) }配套的还有 token 刷新机制访问 token 有效期设短一点比如两小时刷新 token 有效期设长一点比如七天。前端的 axios 拦截器里做自动刷新当接口返回 401 时自动用刷新 token 换取新 token再重放原请求。这块逻辑值得提前想清楚不然后期上线后用户会频繁被踢下线。4.2 RBAC 权限从数据表到接口鉴权权限设计我直接复用了 Django 内置的 Group 和 Permission没有自己设计表。角色和 Group 一一对应通过外键关联用户然后给接口配置权限。比如审批权限这种颗粒度我会在 Django 里先创建权限再进行角色授权from django.contrib.auth.models import Permission, Group permission Permission.objects.create( codenamecan_approve, nameCan approve approval instance, ) approver_group Group.objects.get(name部门经理) approver_group.permissions.add(permission)接口处使用 DRF 的permission_classes做鉴权class ApprovalActionView(APIView): permission_classes [IsAuthenticated, PermissionRequired] required_permission oa.can_approve def post(self, request, instance_id): ...这里我更推荐 DRF 的action装饰器结合自定义权限类而不是把所有判断堆在业务函数里。道理和状态机一样职责分离后期加权限点只改配置。前端菜单权限也不是简单靠is_staff判断而是登录成功后后端根据角色返回菜单树。前端拿到菜单树动态生成路由后端接口再严格拦截一遍。记住一个原则前端控制的是体验后端控制的是安全。千万不要只在前端隐藏按钮真正的权限必须落在接口层。4.3 审批动作接口发起、同意、驳回、撤回的代码级讲解审批动作一共有四个发起、同意、驳回、撤回。我分别实现了四个接口但核心逻辑都复用同一个变更状态 写记录 通知相关人的流程。发起审批的接口实现大致如下class ApprovalCreateView(APIView): def post(self, request): approval_type_id request.data.get(approval_type_id) form_data request.data.get(form_data, {}) approval_type ApprovalType.objects.get(idapproval_type_id) instance ApprovalInstance.objects.create( approval_typeapproval_type, applicantrequest.user, form_dataform_data, statusapproving, current_step0, ) # 写入第一条记录 ApprovalRecord.objects.create( instanceinstance, operatorrequest.user, actionsubmit, comment发起申请, ) return Response({id: instance.id})同意接口就要复杂一些因为要判断当前审批人是否匹配以及是否还有下一级审批人class ApprovalApproveView(APIView): def post(self, request, instance_id): with transaction.atomic(): instance ApprovalInstance.objects.select_for_update().get(idinstance_id) if instance.status ! approving: raise ValidationError(当前状态不允许审批) approver_list approval_type.steps # 如 [部门经理, 总经理] current_approver approver_list[instance.current_step] if request.user.username ! current_approver: raise ValidationError(你不是当前审批人) if instance.current_step len(approver_list) - 1: instance.status approved else: instance.current_step 1 instance.status approving # 仍然审批中 instance.save() ApprovalRecord.objects.create( instanceinstance, operatorrequest.user, actionapprove, commentrequest.data.get(comment, ), ) return Response({status: instance.status})比较关键的是下一步审批人的确定方式。这里我用了审批步骤的配置逻辑没有引入完整的 BPMN 工作流引擎。因为对这种内勤审批场景来说线性多节点流转就够用了引入 Camunda 或 Flowable 反而是过度设计。驳回类似直接把状态改成rejected审批记录写驳回到第几步发起人可以在前端看到驳回意见修改后重新提交状态再回到approving。撤回接口限制了条件只有发起人本人并且当前状态是approving且当前第 0 步也就是还没有其他人审批时才能撤回。这样既保证灵活性也避免撤回造成流程混乱。4.4 文件上传与在线预览无纸化的关键一小步无纸化不仅是审批单电子化还涉及附件。报销单要传电子发票用印申请要传盖章文件的扫描件公文要传正文 PDF。所以文件上传与在线预览是绕不开的一环。Django 端我直接用的FileField存储文件保存在本地磁盘。生产环境用 Nginx 做静态文件代理上传接口限制单文件大小和类型class AttachmentUploadView(APIView): def post(self, request): upload_file request.FILES.get(file) if upload_file.size 50 * 1024 * 1024: raise ValidationError(文件大小不能超过50MB) if upload_file.content_type not in [image/jpeg, image/png, application/pdf, application/msword]: raise ValidationError(不支持的文件类型) instance Attachment.objects.create(uploaderrequest.user, fileupload_file) return Response({id: instance.id, url: instance.file.url})在线预览这块图片和 PDF 直接在前端用新窗口打开或用嵌入组件展示。Word 文件预览比较麻烦我当时的替补方案是后端安装 LibreOffice把上传的 Word 文件转成 PDF 再利用浏览器预览文件小的时候效果还不错。5. Vue 前端从登录到审批流的完整装配过程5.1 前端工程初始化与 axios 封装前端我用 Vite 初始化了 Vue 3 项目路由用的 Vue Router 4状态管理用的 Pinia。初始化命令很简单npm create vitelatest oa-frontend cd oa-frontend npm install vue-router4 pinia element-plus axiosaxios 封装是整个前端的基础核心逻辑是请求拦截和响应处理。我把所有前后端交互都收敛到一个统一模块里后端返回结构固定为{ code, message, data }这样前端处理错误集中在一个地方// src/utils/request.js import axios from axios; import { ElMessage } from element-plus; const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 30000, }); request.interceptors.request.use((config) { const token localStorage.getItem(access_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); request.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res.data; }, (error) { if (error.response error.response.status 401) { // token 失效跳转登录页 router.push({ path: /login }); } ElMessage.error(error.response?.data?.message || 网络异常); return Promise.reject(error); } );5.2 动态路由与按钮级权限后端给什么就渲染什么菜单权限的实现方式我在前端是后端给什么就渲染什么。登录成功后请求/api/menus/获取当前用户可见的菜单树然后动态注册路由// 动态路由注册核心逻辑 const modules import.meta.glob(../views/**/*.vue); function generateRoutes(menus) { const routes []; for (const menu of menus) { const route { path: menu.path, name: menu.name, component: modules[../views/${menu.component}.vue], children: menu.children ? generateRoutes(menu.children) : [], }; routes.push(route); } return routes; } menus.forEach((menu) { router.addRoute(Layout, generateRoute(menu)); });按钮级权限更简单一点后端在登录返回的数据里带一个permissions数组前端注册一个全局指令v-permission只控制按钮显示隐藏。还是那句话前端只是体验优化真正安全靠后端接口鉴权。5.3 审批页面的交互设计发起、时间线、批量操作审批页面是这个系统前端部分交互最复杂的。我拆成了三个页面发起申请、待办审批、已办查询。发起申请页面根据审批类型展示动态表单。请假是日期选择器加 textarea报销是金额输入加附件上传用印申请则是模板勾选加说明。实现上我在前端做了一个表单配置的映射对象和审批类型的编码对应保证新增审批类型时前端改动最小const formConfigMap { leave: [ { field: start_date, label: 开始日期, component: DatePicker }, { field: end_date, label: 结束日期, component: DatePicker }, { field: reason, label: 事由, component: InputTextArea }, ], reimbursement: [ { field: amount, label: 报销金额, component: InputNumber }, { field: details, label: 费用明细, component: InputTextArea }, { field: attachments, label: 票据附件, component: Upload }, ], };待办审批页面核心是一个表格展示当前用户待处理的审批实例列表。查询条件有审批类型、发起人、申请时间区间。后端分页结构返回items和total前端用 Element Plus 的分页组件对接。详情弹窗里我用 Element Plus 的el-timeline组件渲染审批记录每一步的操作人、动作、意见、时间都按时间线排列用户在弹窗里就能看到完整流转脉络。操作区根据状态显示不同的按钮审批中显示通过/驳回驳回后只有发起人显示重新提交。批量审批是我后来加的功能。财务部门反馈说月末报销单几十条逐条点太累。我在待办列表增加了多选批量同意时后端循环调用审批逻辑同时做了事务包裹保证要么全部成功要么全部失败。这个功能上线后使用率很高反馈不错。6. 上线部署与真实踩坑从开发机到生产环境的迁移6.1 部署架构Nginx、uWSGI、MySQL、Redis 的分工部署方案用的是目前比较成熟的 Python Web 服务架构Nginx处理静态文件、反向代理、限制上传大小。uWSGI跑 Django 应用配置进程数和线程数。MySQL 8.0主数据库使用 utf8mb4 字符集。Redis目前主要用于缓存JWT 本身无状态暂未依赖 Redis。服务器配置我建议至少 2 核 4G这套系统在百人规模下完全够用。settings.py 里需要调整几个地方DEBUG False ALLOWED_HOSTS [oa.example.com] STATIC_ROOT os.path.join(BASE_DIR, staticfiles) MEDIA_ROOT os.path.join(BASE_DIR, media)uWSGI 的配置用 ini 文件管理关键设置如下[uwsgi] chdir /opt/oa/backend module oa_backend.wsgi:application master true processes 4 threads 2 http-timeout 60 harakiri 60 max-requests 5000 vacuum trueNginx 配置里最要留意的是上传大小限制默认client_max_body_size是 1MB不调整的话上传票据图片都会被拒绝server { listen 80; server_name oa.example.com; client_max_body_size 50m; location /static/ { alias /opt/oa/backend/staticfiles/; } location /media/ { alias /opt/oa/backend/media/; } location / { include uwsgi_params; uwsgi_pass unix:/opt/oa/backend/oa.sock; uwsgi_read_timeout 60s; } }6.2 三个最典型的线上问题跨域、上传限制、请求超时上线第一周果然遇到几个线上问题。跨域是第一个。开发环境前端用 Vite 的 proxy 转发生产环境前后端是同一个域名所以没有专门开 CORS。但如果前后端拆分到不同域名必须在 Django 里配置django-cors-headers。第二个问题就是上面提到的上传限制。用户在报销页面传图片附件时Nginx 直接返回 413排除了半天最后发现是client_max_body_size的问题。把限制调到 50MB 并重启 Nginx 后解决。第三个问题是长请求超时。审批通过时后端要发通知、发邮件偶尔会出现超过 30 秒的情况。前端 axios 的超时时间是 30 秒Nginx 的uwsgi_read_timeout也是 60 秒。最后把耗时的通知操作改成了异步任务用的django-q把通知任务放到队列里不影响接口响应时间。6.3 数据备份与日常维护建议无纸化系统的核心资产是数据。审批记录、公文、附件一旦丢失业务影响非常大。数据库备份我用了 mysqldump crontab 每天凌晨全量备份保留最近 30 天#!/bin/bash BACKUP_DIR/data/backup/mysql DATE$(date %Y%m%d%H%M) mysqldump -u backup_user -ppassword oa_database | gzip $BACKUP_DIR/oa_$DATE.sql.gz find $BACKUP_DIR -type f -mtime 30 -delete上传的文件目录同样需要定期备份用 rsync 同步到另一台机器即可。另外还有一个细节不要忽略Django 的MEDIA_ROOT目录权限要配好不然 Nginx 读取文件会 403。再说一个容易被忽略的坑时区。Django 默认USE_TZ True数据库存的是 UTC 时间但前端页面显示的是北京时间导致审批记录的时间差了 8 小时。解决办法是把TIME_ZONE设置为Asia/Shanghai并且USE_TZ继续为 True前端格式化时间时统一用dayjs或者原生toLocaleString处理。整个系统上线到现在运行了几个月稳定性没问题。回看这个项目我最深刻的体会是无纸化办公系统真正考验人的地方不是某个框架的 API 用得多熟而是能否把审批规则、状态流转、权限边界这些业务逻辑想清楚。代码只是把想清楚的东西落地。如果你也要做这类系统建议优先把状态机和权限模型画到纸上写代码反而是后面的事。