☰
Python+Django+Vue无纸化审批系统实战:从需求到部署全解析
2026/10/21 13:37:49 网站建设 项目流程

去年年初,某家公司行政负责人找到我,说他们内部被纸质审批流程折腾得不行了:请假要挨个找领导签字,报销单经常在几个部门间流转一周,月底财务对账时发现有一半附件是缺的。我当时接下了这个需求,前后花了两个多月,用 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_delete=models.PROTECT) applicant = models.ForeignKey(User, on_delete=models.PROTECT, related_name="approval_instances") status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending") form_data = models.JSONField(default=dict) amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) current_step = models.IntegerField(default=0) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class ApprovalRecord(models.Model): instance = models.ForeignKey(ApprovalInstance, on_delete=models.CASCADE, related_name="records") operator = models.ForeignKey(User, on_delete=models.PROTECT) action = models.CharField(max_length=20) comment = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True)

instance.records这个反向关联在审批详情页会用到,前端展示时间线的时候,直接按时间倒序取记录即可。用 Django ORM 可以这样写:

instance = ApprovalInstance.objects.select_related("applicant", "approval_type").prefetch_related("records__operator").get(id=instance_id)

3.2 状态机设计:审批流如何避免"乱序流转"的坑

状态机是这个项目里我花时间最多的地方。审批的流转规则非常简单,但实现不好就会出各种"乱序"问题:审批人还没批,发起人就能撤回;已经驳回的单子,还能被下一个审批人通过。

我在代码里用了一个合法的状态迁移表,而不是到处散落的 if else:

VALID_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(id=instance_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( codename="can_approve", name="Can 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(id=approval_type_id) instance = ApprovalInstance.objects.create( approval_type=approval_type, applicant=request.user, form_data=form_data, status="approving", current_step=0, ) # 写入第一条记录 ApprovalRecord.objects.create( instance=instance, operator=request.user, action="submit", 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(id=instance_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( instance=instance, operator=request.user, action="approve", comment=request.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(uploader=request.user, file=upload_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 vite@latest oa-frontend cd oa-frontend npm install vue-router@4 pinia element-plus axios

axios 封装是整个前端的基础,核心逻辑是请求拦截和响应处理。我把所有前后端交互都收敛到一个统一模块里,后端返回结构固定为{ 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 = true

Nginx 配置里最要留意的是上传大小限制,默认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 -p'password' 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 用得多熟,而是能否把审批规则、状态流转、权限边界这些业务逻辑想清楚。代码只是把想清楚的东西落地。如果你也要做这类系统,建议优先把状态机和权限模型画到纸上,写代码反而是后面的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询