做档案室管理系统这个需求,这几年我前后接触过好几次。场景基本都一样:单位里纸质档案堆满几个房间,借阅登记靠一个本子手写,谁借走了哪卷档案,什么时候还,管理员心里得靠记忆去翻,有时候换个管理员,整个档案底账都理不清。Excel里倒是录了一部分,可字段混乱、缺档号、重复录入也时有发生。我这次接手的这个项目,就是给某单位档案室做一套覆盖档案登记、检索、借阅、归还、统计全流程的管理系统,技术栈锁定了前端Vue + Element UI,后端Node.js,数据库用MySQL。整个系统从需求梳理到上线部署,大概六周时间完成,这篇文章就把过程和一些关键实现拆开讲清楚,包括项目初期的架构设计、数据库的表结构规划、前后端联调时的坑,以及上线之后暴露的问题和补救办法。
如果你现在也准备做一个类似的内部管理系统,或者刚接触Vue + Node.js这个组合,又不想去看那种又长又散的教程,这篇文章应该能帮你省不少时间。我会把整个系统的骨架、每一步的设计逻辑、以及我怎么排查问题的思路都放出来。
1. 需求拆解与整体设计思路
1.1 档案室管理到底在管什么
别一上来就谈技术,先弄明白业务。我最初接触需求时,对方负责档案管理的同事给了我一份纸质表格,上面写着:档案编号、题名、责任者、形成日期、保管期限、密级、页数、存放位置。就这么一张表,加上一个借阅登记簿,就是他们档案室的全部家当。
实际跟了几圈流程之后,我发现核心痛点其实是四件事:
第一,档案检索效率低。很多档案是纸质件,电子台账零散,检索的时候既不知道Excel里有没有记录,也不知道档案实体放在哪个房间哪组柜子的第几层。如果台账登记不全,就只能靠人翻,一等就是半天。
第二,借阅过程完全靠自觉。档案借出去之后,借阅人、借出时间、审批人这几条关键信息全部依赖手工,经常出现档案“借出容易归还难”的情况,档案室到底有多少卷在外头,谁也说不清。
第三,权限管理非常粗放。哪个员工能看普通档案,哪些档案涉及单位内部事项甚至密级,没有机制去区分。纸质时代靠锁柜子,电子化之后如果用一套系统,就必须有角色和审批流程的约束。
第四,盘点与统计基本是做样子。上级检查要求提供档案数量、借阅频次、当前在借列表一堆报表,手工去抄得花一天。
所以我们最终确认的业务范围就是:档案目录管理、多条件检索、借阅申请与审批、归还登记、用户与角色管理、分类字典管理、统计看板。这个范围做出来,已经能覆盖九成以上档案室的日常需求。
1.2 技术选型:为什么是Vue + Element UI + Node.js
关于技术选型,我倒是想多写几句,因为这个组合经常被初学者当成“培训班标配”,容易被低估。但实际上,它特别适合做这类内部管理系统。
Vue端选Vue 2还是Vue 3?我这次实际用的是Vue 2 + Element UI,原因很直接:团队对这个组合最熟悉,而且Element UI的表格、表单、弹窗、分页组件都内置了完整交互,做中后台管理系统是出了名的省力。如果你是现在新起一个项目,我建议直接上Vue 3 + Element Plus,组件API更统一,响应式性能也更好,思路是完全一样的。考虑到很多存量项目还停留在Vue 2生态,这里我用Vue 2语法来讲,你迁移过去不会有什么障碍。
Element UI这个组件库的价值,在于它替你解决了一整套“看起来不丑且能干活”的界面基础。你自己写一个带搜索表单、操作按钮、分页的资产负债表格,通常要两三天;用Element UI拼装,一个下午就能搭完,而且交互细节、浏览器兼容性都已经被大量项目踩过坑。做内部系统最重要的不是炫技,而是稳定、清晰、改动成本低。
后端选Node.js,一方面是因为前后端统一使用JavaScript,上下文切换成本很低;另一方面是Node.js的异步模型处理JSON请求性能很足够。像档案管理系统这种场景,并发量根本到不了十万级别,难的是业务状态多、流程长、数据校验琐碎。Node.js加上Express框架,中间件的组织方式非常直观,要加鉴权就在路由前面插一个中间件,要加日志就再插一个,调试起来特别顺手。
数据库选MySQL,没那么多花哨原因:档案数据是强关系结构,卷宗、分类、借阅记录、用户之间互相引用,用关系型数据库天然严谨。MySQL加上一张表一张表的规范化设计,后续统计查询写SQL也非常直观。
1.3 系统模块规划与页面草图
和负责人对齐需求后,我把系统拆成了六个核心模块,这里直接列出来,你可以对照自己的项目看:
| 模块 | 核心功能 | 涉及角色 |
|---|---|---|
| 首页看板 | 档案总数、本月借出、待审批数量、即将到期提醒 | 所有登录用户 |
| 档案管理 | 档案列表、新增/编辑/删除、多条件检索、分类筛选、批量导入 | 管理员、档案员 |
| 借阅管理 | 借阅申请、审批通过/驳回、登记归还、借阅历史 | 所有用户 |
| 分类字典 | 档案分类、密级、保管期限选项维护 | 管理员 |
| 用户管理 | 用户新增、角色分配、重置密码 | 管理员 |
| 系统设置 | 登录日志、操作日志查询 | 管理员 |
角色先定为三种:管理员、档案员、普通用户。管理员能管理用户和系统配置;档案员负责档案登记上架、处理借阅;普通用户只能申请借阅和查看档案目录信息。这样三个角色对应的菜单权限就清楚多了。
第一版页面设计的时候我直接在纸上画了草图,没有先进原型工具。每张清单页都遵循同一个骨架:顶部筛选栏(下拉选择器 + 搜索输入框 + 查询/重置按钮)、中部表格(列内容按业务字段定)、底部为分页器,新增、导入操作放右上角。这个骨架用户接受度很高,因为不管进入哪个模块,第一眼就知道到哪里搜、到哪里操作。
2. 数据库设计与核心表结构
2.1 实体关系图与表清单
系统的数据关系不算复杂,但每一步都要想清楚。我总共设计了五张主要表:用户表、角色权限表(简化为在用户表里加角色字段)、档案分类表、档案主表、借阅记录表。
先看建表SQL中几个关键表的设计思路。
用户表核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT AUTO_INCREMENT | 主键 |
| username | VARCHAR(50) | 登录名,唯一 |
| password | VARCHAR(100) | 加密后的密码 |
| nickname | VARCHAR(50) | 显示名称 |
| role | VARCHAR(20) | admin / archivist / user |
| status | TINYINT | 1启用,0禁用 |
| create_time | DATETIME | 创建时间 |
密码我用了bcryptjs做不可逆加密,字段长度直接留100,避免后续改加密方式时又要扩字段。
档案分类表设计得短小精悍:
CREATE TABLE t_category ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT '分类名称,如文书档案、人事档案', parent_id INT DEFAULT 0 COMMENT '父分类,0为顶级', sort_order INT DEFAULT 0 COMMENT '排序号' );分类支持两级就够了,没必要无限层级,档案分类在绝大多数单位内都是有限枚举,比如文书、人事、财务、科技、音像、实物这么几类。
档案主表是整个系统的核心,字段需要仔细设计。我挑几个容易踩坑的说明:
CREATE TABLE t_archive ( id INT AUTO_INCREMENT PRIMARY KEY, file_no VARCHAR(30) NOT NULL COMMENT '档号,业务唯一标识', title VARCHAR(200) NOT NULL COMMENT '题名', category_id INT NOT NULL COMMENT '所属分类', author VARCHAR(100) COMMENT '责任者', file_date DATE COMMENT '形成日期', retention_period VARCHAR(20) DEFAULT '长期' COMMENT '保管期限', secret_level VARCHAR(10) DEFAULT '普通' COMMENT '密级', page_count INT DEFAULT 1 COMMENT '页数', location_desc VARCHAR(200) COMMENT '存放位置,如A区-03柜-02层', status TINYINT DEFAULT 1 COMMENT '1在库,2借出,0下架', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_file_no (file_no) );这里我把“存放位置”作为一个普通字符串字段,而不是拆成几个字段。因为档案室的物理位置描述往往非常本土化,有的是“第3排第2列第5格”,有的是“A1-3-02”,拆成三个字段反而画蛇添足,还是用一个文本字段让管理员自己填最灵活。
借阅记录表是这个系统里状态变化最多的一张表:
CREATE TABLE t_borrow ( id INT AUTO_INCREMENT PRIMARY KEY, archive_id INT NOT NULL COMMENT '档案ID', archive_file_no VARCHAR(30) NOT NULL COMMENT '冗余档号,方便查询', user_id INT NOT NULL COMMENT '借阅人ID', apply_reason VARCHAR(500) COMMENT '借阅事由', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '申请时间', status TINYINT DEFAULT 0 COMMENT '0待审批,1借出中,2已归还,3已驳回,4已过期', approver_id INT DEFAULT NULL COMMENT '审批人ID', approve_time DATETIME DEFAULT NULL COMMENT '审批时间', borrow_time DATETIME DEFAULT NULL COMMENT '实际借出时间', return_time DATETIME DEFAULT NULL COMMENT '归还时间', remark VARCHAR(500) COMMENT '备注' );为什么借阅表里要冗余一个file_no字段?这是我觉得值得分享的一个小经验。业务人员查借阅记录时,最常用的搜索条件是档号,如果每次都要join档案主表去取档号,虽然也能查,但写SQL麻烦、索引利用也不直接。冗余一个字段,查询借阅记录时就能直接where archive_file_no like '%xxx%',不用join,数据量大了之后性能优势更明显。
2.2 档号规则怎么设计
档号是档案系统的命脉,它是业务上用来定位一卷档案的唯一钥匙。设计档号规则前,我们专门跟档案管理员聊了很久对方现有的编号方式。
现成方式有两种:一是纯流水号,比如“A-592”;二是分类加年度加流水,比如“文书档案2024-001”。后者明显更好用,因为它一看到档号就能知道这是哪一类档案、哪一年归档的,检索上也有天然的前缀筛选效果。
我最终采用了组合编码:分类代码-年份-四位流水号。分类代码在分类表里维护一个短编码字段,比如文书档案用WS,人事档案用RS。这样生成的档号形如RS-2024-0017。唯一约束用SQL建在file_no字段上,后端插入前再做一次重复校验。这样既防止了并发情况下偶发的重复插入,又不至于把所有校验都压在数据库错误上。
2.3 借阅状态流转设计
借阅流程我设计了五个状态,这条状态机是整个系统逻辑里最关键的部分:
待审批 → 借出中 → 已归还
待审批 → 已驳回
借出中 → 已过期(管理员手动标记或定时任务扫描)
用户提交申请后,状态为0待审批;档案员进入系统看到待审批列表,可以点通过或驳回;点通过后状态变为1借出中,同时给档案主表的status字段置为2(借出中);归还登记后状态变为2已归还,同时把档案主表status置回1在库。
这里有一个我建议你特别注意的地方:档案主表的status和借阅记录的状态必须同步更新,而且必须在同一个数据库事务里执行。否则就会出现“借阅记录显示已归还,但档案状态还是借出中”的数据不一致。我就是第一次上线之后才在一次人工盘点中发现的这个漏洞,修复时查了半天历史数据。
3. 前端页面的搭建与核心交互
3.1 工程结构与登录拦截
前端我用Vue CLI老一套方式初始化项目,目录结构大概是这样的:
src/ api/ # 按模块拆分的接口请求 archive.js borrow.js login.js components/ # 共用组件 Pagination.vue router/ index.js # 路由配置 + 导航守卫 store/ index.js # Vuex存用户信息 modules/ user.js views/ Login.vue Dashboard.vue archive/ ArchiveList.vue ArchiveForm.vue borrow/ BorrowList.vue BorrowApply.vue manager/ UserList.vue路由这边我重点想做的是登录拦截。系统要求未登录不能访问任何页面,这个效果用Vue Router的全局前置守卫实现:
router.beforeEach((to, from, next) => { const token = sessionStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else { next(); } });token存sessionStorage,关掉浏览器就失效,对内部系统来说这种策略简洁可靠。用户信息放在Vuex里,store初始化时从sessionStorage重新读取,避免刷新页面后用户信息丢失。
另外每个页面的菜单是根据角色生成的,不应该在前端写死。我做法是后端登录接口返回用户信息时带一个permissions数组,前端根据这个数组动态渲染菜单项。比如普通用户的菜单里没有档案分类管理这一项,即便手动输URL进入对应页面,前端也会在守卫里再拦一道。
这一块其实还藏着一个很多人忽略的问题:前端菜单隐藏只是体验上的,真正的安全防线必须做在后端。前端隐藏菜单、路由守卫拦一下,只能防普通用户误操作,不该让普通用户看到的东西,后端必须同样拦截。我在后端每个管理接口上都加了角色判断中间件,比如管理员、档案员才能调用档案新增和修改接口。这一点务必记得。
3.2 档案列表页的表格、搜索与分页
档案列表页是整个系统使用频率最高的页面,交互设计要求就是快和顺手。
搜索区我放了一排控件:关键字输入框(匹配档号或题名)、分类下拉选择、密级下拉、保管期限下拉、一个查询按钮一个重置按钮。表单提交后重新拉第一页数据。
表格列设置了:档号、题名、分类、形成日期、密级、保管期限、存放位置、状态、操作。状态列用el-tag展示,绿色“在库”,橙色“借出”,红色“下架”。操作列根据档案状态动态显示:在库的显示“借阅”按钮,借出的显示“查看借阅记录”,管理员和档案员多一个“编辑”和“删除”。
分页用的el-pagination组件,逻辑是:当前页pageNum,每页条数pageSize。翻页时请求后端接口,等数据返回之后更新表格。这块代码几乎每个Vue项目都会写,示例直接放出来:
<el-table :data="archiveList" v-loading="loading" border stripe> <el-table-column prop="file_no" label="档号" width="160"></el-table-column> <el-table-column prop="title" label="题名" min-width="200" show-overflow-tooltip></el-table-column> <el-table-column prop="categoryName" label="分类" width="120"></el-table-column> <el-table-column label="状态" width="100"> <template slot-scope="scope"> <el-tag :type="statusMap[scope.row.status].type">{{ statusMap[scope.row.status].text }}</el-tag> </template> </el-table-column> <el-table-column label="操作" width="200" fixed="right"> <template slot-scope="scope"> <el-button size="mini" type="text" @click="handleBorrow(scope.row)">借阅</el-button> <el-button size="mini" type="text" v-if="canEdit" @click="handleEdit(scope.row)">编辑</el-button> <el-button size="mini" type="text" class="danger-text" @click="handleDelete(scope.row)">删除</el-button> </template> </el-table-column> </el-table>题名列一定要加show-overflow-tooltip属性,因为档案题名动不动就三四十个字,不省略会把表格撑得没法看。这是使用Element UI表格时很细节但体验提升明显的点。
新增和编辑用同一个弹窗组件,通过传入的row数据区分是新增还是编辑。表单校验规则是根据业务要求定的:档号必填且校验格式,题名必填,分类必选,形成日期必填。校验通过后再调接口。
还有一个交互细节:编辑弹窗关闭之后表单校验状态要重置,否则下次打开时上一个表单的红包提示还留着。我用this.$refs.form.resetFields()清掉校验状态和数据。
3.3 借阅申请的交互流程
借阅流程的交互我走了三步:
第一步,用户在档案列表页点击“借阅”,弹出对话框填写借阅事由。这里注意到前端只是收集信息,具体能不能借还要看后续审批。
第二步,档案员在借阅管理页面看到待审批列表,点“详情”查看申请的事由和相关档案信息,然后点通过或驳回。通过时系统弹窗询问实际借出时间,默认当前时间。
第三步,归还操作。用户在借阅记录列表找到“借出中”的记录,点“归还”,系统自动记录归还时间并修改档案状态。
这个流程的技术点在于:前端页面之间的状态联动。档案列表页显示的是档案主表的status字段,借阅管理页则查借阅记录表的状态。前端操作归还时,后端接口要同时修改借阅状态和档案状态,我设计成纯后端事务操作,前端叫一个接口就完事。这样做的好处是就算前端有多个入口触发同一个操作,也不会出现业务逻辑分散问题。
4. Node.js后端接口与关键实现
4.1 后端工程结构与统一响应
后端用的是Express框架,项目结构按模块划分:
server/ app.js # 入口,中间件注册 config/ db.js # MySQL连接池 jwt.js # 密钥与过期时间配置 middlewares/ auth.js # 登录校验 roleCheck.js # 角色校验 upload.js # 文件上传配置 routes/ login.js archive.js borrow.js category.js user.js stats.js controllers/ # 各模块业务逻辑 utils/ response.js # 统一响应封装这里我建议把接口返回格式统一成这种结构:
{ "code": 0, "message": "success", "data": { } }前端axios拦截器统一处理:code不为0时弹出message对应的错误提示。这样写接口时不用每处都自己封装重复的成功失败逻辑,调试和对接效率高很多。
实现上我写了一个工具函数:
exports.ok = (data) => ({ code: 0, message: 'success', data }); exports.fail = (message, code = 1) => ({ code, message, data: null });所有接口返回都包裹这两个方法。
4.2 登录鉴权与JWT实现
登录流程算是这个系统最核心的基础设施。用户输入用户名密码,后端用bcryptjs比对密码哈希,比对成功则签发一个JWT返回前端。之后前端请求在axios拦截器里带上Authorization头,后端中间件统一解析验证。
签发JWT的代码:
const jwt = require('jsonwebtoken'); const { secret, expiresIn } = require('../config/jwt'); exports.generateToken = (user) => { return jwt.sign( { id: user.id, username: user.username, role: user.role }, secret, { expiresIn } ); };校验中间件:
const auth = (req, res, next) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) return res.status(401).json({ code: 401, message: '未登录' }); try { const payload = jwt.verify(token, secret); req.user = payload; next(); } catch (e) { return res.status(401).json({ code: 401, message: '登录已过期' }); } };角色校验中间件则是在auth之后再用:
const roleCheck = (...roles) => { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 403, message: '无权限操作' }); } next(); }; }; // 使用示例:管理员和档案员才能新增档案 router.post('/archive', auth, roleCheck('admin', 'archivist'), archiveCtrl.add);关于token过期时间,内部系统我设置了12小时,对绝大多数上班场景足够,也不会造成频繁重新登录。token过期后前端拦截器检测到401,自动跳转登录页。
4.3 档案分页查询与多条件筛选
这是所有查询页面都会用到的模式,值得认真写一下。
前端传过来的查询参数包括:pageNum、pageSize、keyword(档号或题名的模糊匹配)、categoryId、secretLevel、status。后端动态拼接SQL:
async list(req, res) { const { pageNum = 1, pageSize = 10, keyword = '', categoryId, secretLevel, status } = req.query; const offset = (pageNum - 1) * pageSize; let where = ' WHERE 1=1 '; const params = []; if (keyword) { where += ' AND (file_no LIKE ? OR title LIKE ?) '; params.push(`%${keyword}%`, `%${keyword}%`); } if (categoryId) { where += ' AND category_id = ? '; params.push(categoryId); } if (secretLevel) { where += ' AND secret_level = ? '; params.push(secretLevel); } if (status !== undefined && status !== '') { where += ' AND status = ? '; params.push(status); } const [rows] = await db.query( `SELECT a.*, c.name AS category_name FROM t_archive a LEFT JOIN t_category c ON a.category_id = c.id ${where} ORDER BY a.id DESC LIMIT ? OFFSET ?`, [...params, Number(pageSize), offset] ); const [countRows] = await db.query( `SELECT COUNT(*) AS total FROM t_archive a ${where}`, params ); res.json(gen.ok({ list: rows, total: countRows[0].total, pageNum: Number(pageNum), pageSize: Number(pageSize) })); }这里有几个小坑要提醒:
- LIMIT和OFFSET在mysql2驱动里传参数时,直接用参数占位符是没问题的,但注意必须转成Number类型,否则数据库驱动可能报语法错误。
- 查询条件多时,动态拼接where很容易忘加空格。我习惯用WHERE 1=1开头,条件再逐条 append,这样每个条件前面都有AND,逻辑不会乱,性能影响可以忽略。
- 模糊匹配两边的%必须手动拼接,直接传参数进去不会自动带,所以我用了
%\${keyword}%的写法。
4.4 文件上传:Excel批量导入与附件管理
纸质档案数量比较多,一间档案室动辄几千卷,逐条手输效率太低。我在系统里加了Excel批量导入功能,管理员下载模板,填好内容后上传,后端解析批量写入数据库。
Excel解析用的库是node-xlsx,结合muler实现文件上传。核心流程:
- 前端用el-upload组件选文件,上传到后端的/api/archive/import接口。
- 后端先把文件保存到uploads目录的临时位置。
- 用xlsx库读取工作表,把每一行映射成档案对象。
- 校验必填字段和档号格式。
- 分批插入,每100条执行一次batch insert,提高效率。
- 返回导入成功多少条、失败多少条,失败时把原因汇总成文本返回前端展示。
这个环节有一次印象很深:某同事导入了一个1200行的Excel,第一次跑的时候超时了。排查发现是因为逐行插入,1200条大概要十几秒。改成批量插入之后,时间从十几秒降到了两秒以内。内部系统虽然并发要求不高,但也别忽略这种明显可以优化的性能瓶颈。
文件附件的应用场景相对少,因为这款系统是档案目录管理,不要求持久化存储扫描件。如果需要做扫描件上传,我建议把文件存储交给独立文件服务,比如FastDFS或者对象存储,而不是直接丢到应用服务器本地磁盘,否则做负载均衡的时候文件同步会是噩梦。
4.5 借阅事务与并发控制
借阅数据的一致性,我在前面已经提到要靠事务保证。写出来代码逻辑是这样的:
const connection = await db.getConnection(); try { await connection.beginTransaction(); // 1. 校验档案当前状态 const [archives] = await connection.query( 'SELECT * FROM t_archive WHERE id = ? FOR UPDATE', [archiveId] ); const archive = archives[0]; if (!archive || archive.status !== 1) { throw new Error('当前档案不可借出'); } // 2. 更新档案状态为借出 await connection.query( 'UPDATE t_archive SET status = 2 WHERE id = ?', [archiveId] ); // 3. 更新借阅记录状态为借出中 await connection.query( 'UPDATE t_borrow SET status = 1, borrow_time = NOW(), approver_id = ? WHERE id = ?', [req.user.id, borrowId] ); await connection.commit(); } catch (e) { await connection.rollback(); throw e; } finally { connection.release(); }这里SELECT FOR UPDATE是防止并发下两个审批人同时审批同一笔借阅,都查到档案是“在库”状态,然后都去更新。加上行级锁后,第二个事务的查询会等第一个事务提交,查询结果就会变成“借出中”,从而触发异常提示。这是并发场景下应该想到的基本手段。
5. 联调与部署过程中的经验坑
5.1 跨域与前端代理
开发阶段前端跑在8080端口,后端跑在3000端口,跨域问题不可避免。开发时我推荐用Vue CLI的proxy配置,简单无感:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } } };前端所有请求都以/api开头,代理就把请求转发到后端。这样前端代码里不用写完整的后端地址,之后部署到线上配合Nginx还能继续用同样的代理逻辑。
生产环境我在Nginx里做反向代理配置:
server { listen 80; server_name archive.example.com; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { try_files $uri $uri/ /index.html; } }前端打包后用try_files保证刷新页面时路由不会404,这是SPA部署的一个经典坑。
5.2 后端进程守护
Node.js后端直接裸跑的话,进程挂了就完蛋。我当时用PM2做进程守护,一条命令搞定:
pm2 start app.js --name archive-server --max-memory-restart 300M pm2 savePM2的好处是自带崩溃自动重启、资源限制和日志管理,对中小型Node服务来说非常实用。后端代码更新部署时,git pull后直接pm2 reload archive-server即可,几乎零停机。
5.3 时间字段时区问题
联调过程中有一个特别容易忽略的问题:前端显示的时间和数据库存的时间差8小时。排查下来发现是MySQL连接时区设置的问题。后端连接数据库时在连接字符串上加一个参数解决:
// db.js const db = mysql.createPool({ host: 'localhost', user: 'root', password: '******', database: 'archive_db', charset: 'utf8mb4', dateStrings: true });dateStrings:true会让MySQL返回的DATE和DATETIME类型直接以字符串形式返回,避免经过Node层的Date对象转换引入时区偏移。这个配置我强烈建议做MySQL + Node项目的时候直接写上,省掉很多时间格式化的麻烦。
前端展示时再用dayjs把时间格式化成项目想要的格式,比如“2024-11-30 14:30”。组件库里的时间日期选择器返回的值本身也是字符串,传参时直接传给后端,后端再落库,全程无隐式转换,干净利落。
5.4 导出结果的几个踩坑细节
系统里有导出Excel的需求,我也用node-xlsx实现。第一个坑是导出大数据量,比如导出全年所有借阅记录,一次性查全量内存容易飙升。我的处理是导出时也带查询条件,并且把导出接口的并发量做限制。对内部系统来说,按条件分批导出的体验并不差。
第二个坑是Excel中的档号被自动当成数字处理,导致以0开头的档号前面0被吞掉。这是Excel内部机制,不是后端代码问题。规避方案是导出Excel时,把档号列的单元格格式设置为文本型。xlsx库设置单元格类型为z(文本),前端拿到数据后大部分现代浏览器打开的Excel能正确显示完整档号。
5.5 操作日志的必要性
档案系统涉及大量数据变更,一旦有人误删或者修改了关键档案信息,没有日志就无从追溯。我在系统里加了一个操作日志表,记录操作人、操作类型、操作对象、操作时间、请求参数摘要。实现方式不复杂,后端写个中间件,对需要记录的操作路由包裹一下,在controller执行前后把日志落库。
一开始我甚至考虑过用AOP类似的切面日志,后来觉得对这个体量来说太重了。内部系统最重要的是人能查到谁在什么时候动了什么数据,满足审计线索要求即可。日志查询入口放在管理员的系统设置模块里,权限只开放给管理员。
6. 上线后的问题反思与可扩展方向
6.1 上线初期暴露的问题
系统上线之后前两周我一直在盯着使用反馈。问题主要集中在几类:
第一类是部分老同事不习惯系统操作。有些操作员之前在Excel里用筛选习惯了,对系统里“下拉选择+关键字搜索”的组合还不熟。我把搜索区做得更直白,把常用查询条件放成默认展示,并且增加搜索示例文案,比如“输入档号或题名关键字”,操作员理解成本降低了很多。
第二类是档案员对状态流转偶尔理解错。比如申请已驳回,用户再点借阅时如果没有提示,会以为是系统bug。我特意在申请按钮下面加了一行提示文字,说明被驳回后必须重新发起申请,并附上驳回理由的展示。很多问题不是技术上的而是理解上的,前端文案要多站在用户视角写。
第三类是数据治理问题。系统上线后发现,历史Excel导过来的数据里有不少档号重复、题名残缺、分类误填。因此我增加了一个数据校验报告功能,在导入完成后输出异常数据清单,提醒管理员修订。这个在上线初期帮上大忙。
6.2 后续可以扩展的方向
如果这个系统继续往下做,我会优先考虑以下几个方向。
第一,扫码盘点。每卷档案贴一个二维码,管理员用手机小程序扫码核对档案在库状态,盘点的效率会比手工翻台账快非常多。二维码里编码档案ID,小程序扫码后调后端接口返回该档案目录和状态,管理员确认即可。
第二,OCR识别录入。如果要做档案扫描件的自动化著录,可以用OCR从扫描图片中抽取题名、责任者、日期等元数据,辅助管理员快速建档。这块自然语言处理和图像识别的工程量不小,但不是做不了,可以一期先做一个简单的扫描件上传和预览功能,OCR抽取留到二期。
第三,提醒与日志增强。到期未归还的提醒从站内消息扩展到企业微信或邮件通知,操作日志从简单文本升级成结构化审计报表。这些扩展不需要动核心数据表结构,系统整体架构还有很大弹性。
就我个人做了几个内部管理系统之后的体会来说,vue + nodejs + Element UI这套组合做中后台业务系统,开发效率、维护成本、上手难度综合来看性价比很高。项目难的不是框架本身,而是对业务的抽象和信息模型的设计。档案室管理系统这个案例尤其典型——字段不算多,但互相之间的状态流转、权限边界、数据一致性都需要想清楚。把业务细节和数据表结构梳理明白,代码只是表达这些设计的过程而已。如果你正准备做类似系统,建议从数据库表设计和档案编号规则开始动手,表结构定了,后面都是水到渠成的事。