从零搭建一套法院案件阅卷申请系统:vue + nodejs + Element UI + 小程序的完整实践
这段时间一直在折腾一个法院场景下的案件阅卷申请系统,技术栈是 vue + nodejs + Element UI 加小程序端。这类政务信息化项目跟普通业务系统最大的区别,不在于用多 fancy 的架构,而在于你把业务流程吃得多透、权限边界卡得多死。折腾完整个项目之后,我把从需求分析到上线的每个环节梳理了一遍,包括数据库怎么设计、接口怎么划分、小程序端怎么和 Web 端共用一套逻辑,以及那些只有实际踩过坑才写得出的问题排查经验。这套内容应该对正在做类似“管理后台 + 小程序 + 审批流”项目的开发者特别有用。
1. 业务模型拆解:法院阅卷申请到底在“申请”什么
1.1 先搞清业务流程,再谈技术架构
很多人拿到这类项目就开始建表写接口,这是最容易翻车的做法。法院案件阅卷申请不是简单的“用户填单 -> 管理员通过”就完事,它背后有一套严格的线下流程需要被数字化还原。
一个完整的阅卷申请链路是这样的:
- 申请人(律师、当事人或其代理人)在系统里看到自己关联的案件列表,发起阅卷申请,选择想要查看的卷宗册次,填写申请事由,上传必要的身份证明和委托材料。
- 系统生成一条待审批记录,推送给法院内部负责档案管理的审核人员。
- 审核人员查看申请材料,初步判断申请资格和申请范围是否合规,先做一次预审。
- 预审通过后,可能还要经过业务庭室或者档案管理负责人的二次复核,尤其涉及刑事案件或者有敏感信息的卷宗,复核是免不了的。
- 复核通过后,系统给申请人开放在线查阅权限,同步生成水印化的卷宗预览;如果涉及线下阅卷,则生成预约时段和阅卷室信息。
- 整个过程中的每一次通过、驳回、退回补正,都要留下完整的操作记录,事后能追溯。
这一点恰恰是这类系统和普通后台管理系统的本质区别:普通系统关注的是“功能能不能跑”,阅卷系统关注的是“每一步操作合不合法、合不合规、能不能追溯”。代码写得好不好是次要的,流程上的每一个状态变更都必须合规。
1.2 角色权限的边界划分
在系统设计上,权限模型直接决定数据库表和接口的设计方向。我最终把角色分成三类,每一类的权限边界非常清晰:
- 申请人:仅能查看本人名下(或者通过委托关系关联)的案件,能发起申请,能查看自己申请单的办理进度,能下载已获准卷宗的阅卷副本。
- 档案审核员:能查看分配给自己的待审申请单,能对申请进行预审、提交复核或驳回,能管理卷宗目录的基础信息。
- 系统管理员:负责角色分配、用户管理、日志审计、基础参数配置,不参与具体审批业务。
这里有一个容易犯的错误:以为“档案审核员”就是最高权限,什么都能看。真正上线后你会发现,法院内部对卷宗的查阅权限是有严格分级的,审核员能看到卷宗的目录和基础信息,但未必有权限直接在线浏览卷宗内容。所以我把“卷宗信息查看”和“申请审核”拆成了两个独立权限点来配置,这是按真实业务经验做的取舍。
1.3 为什么这套业务模型必须先于代码确定
我见过不少项目在开发到一半时被业务方要求大改,根源就是把业务模型讨论这一步跳过了。比如“补正材料”这个动作,一开始我只做了“驳回”,结果实际业务中很多申请并非不能通过,而是材料不齐需要补充,直接驳回会让申请人重新走一次完整流程,体验极差,法院侧也觉得不合理。后来增加了“退回补正”状态,整个系统才算真正贴合实际场景。
所以在动任何代码前,我强烈建议你花时间把业务流程状态图画完整、把角色和动作的矩阵表列清楚。哪怕你是自己做一个演示项目,这一套思考过程也会让你的代码结构清晰很多。
2. 技术选型复盘:vue、nodejs、ElementUi 和小程序端为什么这么配
2.1 Vue 2 + Element UI 还是 Vue 3 + Element Plus
项目标题里写的是 ElementUi,实际操作中我做过一次权衡。如果完全从零开始,我建议直接上 Vue 3 + Element Plus,组件更活跃,生态也更现代。但如果你接手的是一个已经跑了一段时间的维护型项目,或者团队里大部分人还停留在 Vue 2 的习惯上,那 Vue 2 + Element UI 依然完全够用,毕竟这类系统的页面复杂度并不高,主要就是表格、表单、弹窗、步骤条这些基础组件。
我这次实际用的是 Vue 2 + Element UI,原因是团队里维护这套代码的同事更熟这一套,且系统不需要特别花哨的交互。如果你是自学做作品集,想显示自己跟得上技术趋势,就选 Vue 3 + Element Plus,这个选择本身没有对错,主要看你的使用场景。
2.2 Node.js 端框架选择:Express 还是 Egg/Koa
后端我用的是 Node.js 的 Express 框架,没有上更重的企业级框架。原因很简单:这个项目的核心业务是 CRUD 加状态流转,复杂度集中在业务规则和权限控制上,框架本身越稳越简单越好。Express 中间件生态丰富,文档多,遇到问题随便一搜就有解决方案。
当然,如果你预期这个系统将来会接入很多外部系统,比如统一身份认证、电子签章服务、短信通知平台,那么换 Egg.js 这类带约定式目录结构和插件机制的框架会让你后期舒服一些。我的建议是:初期用 Express 把业务跑通,当项目明显变复杂时再重构到更结构化的框架,这个节奏比一开始就选最重的方案更实际。
2.3 小程序端的技术路线:原生还是 uni-app
小程序端我选了原生小程序,而不是 uni-app 或 Taro。原因是我这个项目里 Web 管理后台本身就是 Vue,小程序业务相对独立,复用代码的需求不强,原生小程序没有额外编译层,调试排错最简单直接。如果你有大量代码要跨端复用,那选 uni-app 可能会省事,但它也会给你带来一层抽象,遇到底层兼容问题的时候排查起来更头疼。
原生小程序需要注意的一点是它的语法和 Vue 的响应式思想非常接近但又略有不同,最典型的就是setData的显式调用。很多从 Vue 转过来的开发者一开始都会忘记在数据变更后调用setData,导致界面不刷新,我后面在实操部分会专门提到。
2.4 数据库设计:核心表结构和关联关系
数据库我用的是 MySQL。表结构设计直接服务前面的业务模型,核心表大致如下:
- 用户表:记录系统用户的账号、姓名、角色、手机号,同时冗余一个用户在法院侧的身份标识。
- 案件表:记录案件编号、案件名称、案件类型、承办法庭、关联当事人信息等。这张表的数据往往来自法院内部业务系统,可能是同步过来的,所以需要预留外部案件 ID。
- 卷宗表:和案件是多对一关系,一个案件会有多本卷宗,每本卷宗的状态要独立维护。
- 阅卷申请表:核心业务表,记录申请人、申请案件、申请的卷宗范围、申请事由、附件材料地址、当前状态和处理时间线。
- 审批记录表:保存每一次审批动作的信息,包括审批人、审批动作、审批意见、操作时间。
- 附件表:统一管理所有上传的文件,包括申请材料、卷宗扫描件、水印预览件。
这里最核心的设计要点是把“申请单”和“审批记录”分开,而不是在申请单上堆一堆审批字段。这样做的好处是审批历史天然完整,以后要出审计报表、查操作轨迹都极其方便。
字段状态我设计了 draft、pending_first_review、pending_second_review、approved、rejected、returned、expired 这几个,分别对应草稿、待初审、待复核、通过、驳回、退回补正、已过期。状态流转只能按固定方向走,不允许往前跳。
3. 数据库设计与核心接口:从申请单到卷宗借阅的流转链路
3.1 申请模块的接口设计
我把申请相关的接口都收敛在一组路由下面:
// 路由示例 router.post('/api/application/create', authMiddleware, applicationController.createApplication); router.post('/api/application/update', authMiddleware, applicationController.updateApplication); router.post('/api/application/submit', authMiddleware, applicationController.submitApplication); router.get('/api/application/detail', authMiddleware, applicationController.getApplicationDetail); router.get('/api/application/list', authMiddleware, applicationController.getApplicationList); router.post('/api/application/withdraw', authMiddleware, applicationController.withdrawApplication);这里需要注意的一个设计细节是:创建和更新是独立接口,而提交也是一个独立动作。原因是在真实操作中,申请人往往是先填了一部分信息保存成草稿,等材料齐全了再正式提交。如果只做一个“创建即提交”,草稿功能就没了,后面会被业务方追着改。
接口权限控制上,所有申请人都必须带上 token,接口里再判断操作的数据是否属于当前用户。这里最容易被攻击的点是“水平越权”,也就是 A 用户尝试操作 B 用户的申请单。所以每个查询和更新方法里都强制拼接用户 ID 作为过滤条件,而不是单纯依赖前端传过来的申请单 ID。这一点在后端写的时候要形成肌肉记忆。
3.2 审核模块的状态推进逻辑
审核模块是这类系统里业务复杂度最高的地方,因为审批不是一个简单的“同意/拒绝”,它涉及多级流转。我设计了一个统一的状态推进方法:
async function transitionApplication(applicationId, currentStatus, targetStatus, operator, options = {}) { const app = await db.query('SELECT * FROM application WHERE id = ? FOR UPDATE', [applicationId]); if (!app) throw new Error('申请单不存在'); if (app.status !== currentStatus) throw new Error('状态异常,无法执行该操作'); const allowed = getAllowedTransitions(currentStatus); if (!allowed.includes(targetStatus)) throw new Error('非法状态流转'); // 更新状态 await db.query('UPDATE application SET status = ? , updated_at = NOW() WHERE id = ?', [targetStatus, applicationId]); // 写入审批记录 await db.query( 'INSERT INTO approval_record (application_id, operator, action, comment, created_at) VALUES (?, ?, ?, ?, NOW())', [applicationId, operator, targetStatus, options.comment || ''] ); // 如果审批通过,异步处理阅卷权限的开通 if (targetStatus === 'approved') { await enableAccessPermission(applicationId); } }代码里通过FOR UPDATE锁行来避免并发状态下重复审核,通过预置的getAllowedTransitions限制状态只能按官方流程走,即使有人恶意调用接口也无法把已驳回的申请直接改成已通过。这套思路同样适用于其他有状态流转的业务。
状态流转表长这样:
| 当前状态 | 允许流转到的状态 | 触发动作 |
|---|---|---|
| draft | pending_first_review | 申请人提交 |
| pending_first_review | approved / returned / rejected | 初审通过 / 退回补正 / 驳回 |
| returned | pending_first_review / draft | 申请人重新提交 / 申请人撤回 |
| pending_second_review | approved / rejected | 复核通过 / 复核驳回 |
| approved | expired | 超出授权查阅期限 |
3.3 卷宗目录与在线预览的实现思路
卷宗不是简简单单一个附件文件,它通常是一个层级目录:案件下有卷册,卷册下有文件页,文件页对应具体的扫描图片或 PDF。我为此单独设计了一张卷宗目录表,用 parent_id 做树形结构,前端通过一次接口拿到整棵目录树。
在线预览方面,我没有直接返回原文件,而是在审批通过后调用一个转换服务,把卷宗 PDF 加工成带水印的预览版本,水印上带上申请人姓名和申请单编号。这个模块可以用像 pdf-lib 这样的库去实现,核心思路是在每一页上叠加半透明的文字。原文件一律不允许在线预览,只能走线下流程或导出申请授权后的水印副本,这是整个系统最底线的一条规则。
3.4 申请列表的多条件筛选与分页
列表页看起来简单,实际上属于这类系统的门面,审核员一天到晚就对着列表工作。我在列表接口里一次性支持了案件编号模糊搜索、申请状态筛选、申请时间段筛选以及分页排序:
const sql = ` SELECT a.*, c.case_no, c.case_name, u.real_name FROM application a LEFT JOIN case_info c ON a.case_id = c.id LEFT JOIN user u ON a.user_id = u.id WHERE 1=1 ${caseNo ? 'AND c.case_no LIKE ?' : ''} ${status ? 'AND a.status = ?' : ''} ${startDate ? 'AND a.created_at >= ?' : ''} ${endDate ? 'AND a.created_at <= ?' : ''} ORDER BY a.updated_at DESC LIMIT ?, ? `;多条件筛选这类代码写起来不复杂,但字段的可选范围和组合关系必须在接口文档里写清楚,否则前端对接的时候会一个参数一个参数来问。这里我用了一个小技巧:所有筛选条件都是可选的,SQL 用WHERE 1=1加字符串拼接的方式,虽然看起来有点粗暴,但配合参数绑定后实际上非常安全,也不容易出现查询条件丢三落四的情况。
4. 页面与交互实操:管理后台和小程序端的关键实现
4.1 管理后台的页面结构设计
管理后台基于 Vue 2 + Element UI,整体采用经典的后台布局:左侧菜单栏 + 右侧内容区。菜单项包括工作台、案件管理、卷宗管理、阅卷申请管理、用户管理和系统日志。
阅卷申请管理页面是整个系统的核心页面,我拆成四个 Tab:
- 待办列表:只显示状态为待初审和待复核的申请单,对应审核员日常处理的工作台。
- 已办列表:显示当前审核员处理过的历史申请单。
- 全部申请:按各种筛选条件查看所有申请单,供管理员做统计和审计。
- 异常单列表:专门用来展示流转异常、超时未处理、反复退回的记录。
列表操作列放了“详情”“审批”“处理记录”三个入口。“审批”按钮打开对话框,里面有通过、驳回、退回补正三个动作,每个动作必须填写意见才能提交。这个“必须填写意见”的规则,是我在测试阶段被档案科同事吐槽之后加上的,否则大量审批连个理由都没有,出了问题根本没法追溯。
4.2 关键页面组件的拆解方式
在组件化拆分上,我没有做得很细,核心逻辑是把“申请详情”拆成了一个独立组件,列表页和详情页共用。这个申请详情组件内部包含了申请人的信息、案件基本信息、卷宗选择列表、附件材料预览、审批时间线和审批表单几个子模块。
时间线是用 Element UI 的el-steps组件实现的,但要注意el-steps默认是横向布局,当审批环节多的时候容易挤压,我加了一个简单的竖向样式覆盖。审批表单框使用el-radio-group加el-input组合,通过选中不同动作动态切换颜色提示,比如“通过”显示成绿色提示文案,“退回补正”显示成橙色提示文案,让审核员一眼就能看出当前操作的语义。
4.3 小程序端的核心页面与交互
小程序端我做了三个主要页面:案件列表页、申请发起页、申请进度页。
案件列表页展示当前用户有权限查看的案件列表,数据来自后端接口。这里最需要留意的是,用户只能看到自己关联的案件,所以后端在返回数据前必须根据用户 ID 做关联查询过滤,不能把所有案件都返回让前端筛选过滤。这是一个新手经常犯的逻辑错误,会造成数据越权暴露。
申请发起页是整个小程序端交互最繁琐的地方,因为它包含案件选择、卷宗多选、申请事由填写、材料上传四个功能。小程序里的文件上传用的是wx.uploadFile,与 Web 端的表单提交完全不一样,要注意它必须用 FormData 的方式将 token 一并传过去,否则后端鉴权直接给挡回来。
申请进度页就是把 Web 端的审批时间线在小程序端复刻一遍。小程序原生没有步骤条组件,我是自己写的一个横向块状时间线,当前节点高亮,历史节点打勾,不可达节点置灰。虽然看起来简单,但这里要处理动态数据渲染时setData的调取时机,否则会出现页面已经渲染完成数据才填充进去的闪烁问题。
4.4 接口联调与 Token 状态管理的统一方案
前后端联调时,最影响开发效率的问题就是 token 管理不统一。Web 管理后台,我把 token 存在本地 localStorage 中,每次请求在 axios 拦截器里带上;小程序端,我把 token 存在 storage 中,在wx.request的封装函数里统一追加。两个端各写各的,但逻辑保持完全一致:
// Web 端 axios 拦截器 axios.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = 'Bearer ' + token; } return config; }); // 小程序端请求封装 function request(url, data, method = 'GET') { const token = wx.getStorageSync('token'); return new Promise((resolve, reject) => { wx.request({ url, method, data, header: { 'Authorization': 'Bearer ' + token }, success: (res) => { if (res.data.code === 401) { wx.removeStorageSync('token'); wx.navigateTo({ url: '/pages/login/login' }); return; } resolve(res.data); }, fail: reject }); }); }统一将 401 视为登录失效,前端不管 Web 还是小程序都直接跳回登录页。这套约定让我在联调阶段省下了大量重复沟通的时间,也让两端的行为表现保持高度一致。
5. 部署、权限与安全:政务类项目最容易忽略的细节
5.1 权限校验的前后端双重验证
这类系统对权限的要求不是“功能隐藏”而是“数据隔离”。前端隐藏按钮、隐藏菜单只是用户体验层面的展示控制,真正的权限控制必须发生在后端接口层。我在后端写了一个简单的权限校验中间件,能够根据当前用户的角色以及接口配置的允许角色列表进行判断:
function requireRole(...roles) { return function (req, res, next) { const userRole = req.user.role; if (roles.includes(userRole)) return next(); return res.status(403).json({ code: 403, message: '无权限执行该操作' }); }; } router.post('/api/application/approve', authMiddleware, requireRole('archivist', 'admin'), approvalController.approveApplication);不要觉得接口层做权限校验麻烦,这是整个系统安全的地基。前端做得再花哨,接口没守住照样被掏空。我见过太多项目只在前端判断用户有没有某个按钮的可见权,后端接口裸奔,这几乎是政务类项目里最要命的漏洞。
5.2 文件上传的坑:类型校验、大小限制与存储路径
文件上传模块也是高风险区域。原则上只允许上传两种类型:PDF 和图片。但前端传什么类型完全不可信,后端必须用文件的真实二进制头来判断。常见做法是使用第三方库解析文件签名,而不是单纯信任file.originalname的后缀名。这种校验尤其不能省,否则有人传一个伪装成图片的可执行文件到服务器上,后果不堪设想。
文件大小限制我分了两档:申请材料的附件单文件限制 10 MB,卷宗扫描件单文件限制 50 MB。超过大小的文件前端直接拦截并提示,后端同时再限制一次。这样做可以避免大文件占用上传带宽导致整个后台卡死。顺带一提,如果条件允许,文件存储应该和数据库服务分离,放到独立的云存储或者对象存储服务上,这样即便文件访问量暴涨,也不会拖垮应用服务本身。
5.3 微信小程序登录态与 Web 端账号体系的打通
小程序端不是独立的一套用户体系,它是 Web 端账号体系的延伸。我的做法是:小程序通过微信授权拿到身份标识后,后端拿着这个标识去系统用户表里查找对应关系,查到就直接下发 token,查不到就提示"当前微信未绑定系统账号,请联系管理员"。这个设计保证了用户不需要在小小的手机屏上再注册一套账号密码,又让管理员可以从后台统一管理所有入口的用户。
小程序端在登录时调wx.login拿到临时凭证,然后传给后端,后端调用微信接口换取用户身份信息,完成匹配。这里最常遇到的坑是:临时凭证有效期很短,必须立即使用,不能存到数据库里,否则调试时会莫名其妙报错。我在第一次联调时就被这个问题坑了半个多小时,排查到最后才发现是调试工具的缓存导致第二次请求用了已经失效的凭证。
5.4 日志审计的设计细节
日志审计可分为两块:操作日志和登录日志。登录日志比较简单,记录登录者的账号、登录时间、IP 和登录结果。操作日志则要覆盖所有关键业务动作,尤其是审批动作的留痕。我在审批记录表里记录了审批人、审批意见、操作时间、申请单号,再加上操作时的 IP 字段,这样即便将来产生纠纷,也有一套完整的追责链路可以追溯。
日志记录本身用中间件做,能减少业务代码里的侵入。但是要特别注意的是,日志表不能随业务主表一起做物理删除,只允许逻辑删除。这类系统的审计数据是"只增不删"的,相关合规要求本质上盯的就是完整性和不可篡改性。
6. 上线部署与后续扩展建议
6.1 部署方案与环境区分
部署这块没有用太复杂的方案,因为项目规模不大,我用的是云服务器 + Nginx 反向代理 + PM2 进程守护的标准流程。前端的构建产物直接让 Nginx 托管,后端 Node.js 服务由 PM2 拉起,MySQL 单独使用一台数据库实例或者云数据库,避免把数据库和应用服务器挤在一台低配机器上。
环境区分我做了三套:开发环境、测试环境、生产环境。前端通过环境变量区分接口地址,后端通过环境变量区分数据库连接配置。最容易出状况的是前端开发时拿生产环境接口联调,这会导致线上数据被测试数据污染。我的建议是:开发环境一律指向测试接口,只有发版前才能动生产接口,并且要有一套给测试人员专用的数据清理脚本,每次测试完可以一键恢复干净的初始数据。
6.2 上线后最容易遇到的三类问题
上线后我遇到过几类比较典型的问题,在这里列一下:
一是附件路径写死。开发时我直接用了相对路径存储,上线后前段文件在 CDN 域名下,后端文件在服务器本地,结果预览全部裂开。解决方法是配置一个公共的文件访问基地址,后端返回附件 URL 时永远拼接这个基地址,而不是直接返回相对路径。
二是时间时区问题。MySQL 默认使用系统时区,而前端 JavaScript 默认使用浏览器本地时区,如果服务器设置了 UTC 时间,前端显示的时间就会差 8 个小时。这个问题排查起来非常隐蔽,因为数据和接口返回都在变,但总是感觉时间对不上。建议在数据库连接串里显式设置时区,并在后端统一返回时间戳,由前端做格式化。
三是小程序的合法域名配置。开发工具里可以勾选“不校验合法域名”,一上线就全部失效。必须在小程序管理后台把 HTTPS 的接口域名加到白名单里,且该域名必须已经备案。
6.3 下一步可以做的扩展方向
系统跑通以后,有几个方向可以继续扩展。一是对接电子签章服务,让申请人、审核人都在线签字,彻底实现无纸化审批。二是接入暗水印技术,在卷宗预览页面嵌入肉眼不可见但可提取追踪的数字水印,一旦有人截图外发,可以从泄露的图片中追溯到具体用户。三是引入消息推送服务,审批状态变化时主动推送给申请人的微信。四是把案件和卷宗的同步任务做成定时同步,而不是依赖人工导入,减少数据不一致的概率。
这些扩展方向取决于实际业务需求和资源投入,但即便都不做,当前的系统已经能完整覆盖“在线申请 + 多级审核 + 卷宗查阅权限管理”这个核心闭环,在日常使用层面是完全够用的。
最后说一点我自己的体会
这类项目做完以后,我最大的感受是:技术本身并不是难点,难点在于把业务边界想清楚,宁可多花时间在流程梳理和权限设计上,也不要急着写代码。阅卷申请系统本质上做的是“把不合适的申请挡在门外,把合规的申请用最高效的方式放行”,这句话听起来像业务口号,但它确实决定了系统里每一张表、每一个接口、每一处权限校验的最终走向。如果你手头刚好也在做类似的后台管理系统加小程序项目的组合,希望这篇内容能帮你少走一些弯路。