
1. 项目背景与核心需求做工厂设备维护报修管理系统是我近几年接过比较典型的企业内部工具类项目。这类系统单看技术含量不算顶尖但真要落地好用涉及的业务细节一点都不少。这个项目用 Node.js Vue 实现了生产设备的台账管理、故障报修、维保计划、工单流转和统计报表覆盖了工厂设备科日常最头疼的那几件事设备坏了找谁修、修到什么程度、备件够不够、哪些设备该保养了。先说为什么需要这么一套系统。很多工厂的设备管理还停留在微信群里报修、Excel 记台账、纸质工单签字的阶段。设备一多问题就来了报修消息被聊天记录淹没维修工不知道优先级设备的历史故障记录散落各处下次出问题还得翻聊天记录维保计划全凭老师傅脑子记漏保养是常态。这些问题叠加起来轻则影响生产效率重则造成设备损坏甚至安全事故。所以工厂需要的不只是一个报修小程序而是一套能管设备全生命周期的系统从建档、保养、报修、派单、维修到统计复盘形成闭环。这套系统适合谁来参考如果你是刚入行的全栈开发者想找一个既有业务深度又有技术覆盖面的练手项目这个选题非常合适。它不像电商系统那样业务庞杂但包含用户权限、工作流状态机、文件上传、数据可视化、前后端交互等常见模块麻雀虽小五脏俱全。如果你正好在工厂或制造企业做信息化这套系统的设计思路也可以直接迁移。开发这套系统的过程中我最大的体会有两点第一设备管理系统的核心不在技术炫技而在工单流转是否顺畅、数据是否准确第二前后端分离的 Node.js Vue 组合在这类内部管理系统里确实省心开发效率高后期维护也容易找人接手。2. 技术选型与整体架构2.1 为什么选 Node.js Vue 这个技术组合选技术栈之前我先考虑了工厂信息化的实际环境这类系统往往部署在企业内网服务器上访问量不大但并发提交可能集中比如上班后半小时内密集报修对并发处理的要求不算极端但对开发迭代速度要求高。Node.js 的异步非阻塞模型处理这类 I/O 密集型请求很合适同时 JavaScript 全栈可以复用很多代码逻辑比如枚举定义、工具函数。Vue 这边我选了 Vue 3 组合式 API 写法配合 Vite 构建工具。相比 Vue 2 的 Options API组合式 API 在管理复杂状态逻辑时更清晰。工厂设备管理页面有大量表格、表单、弹窗交互组件之间需要共享设备状态、用户信息用 Pinia 做状态管理比 Vuex 更简洁。组件库我用的 Element Plus这是 Vue 3 生态最成熟的中后台组件库表格、表单、日期选择器、步骤条这些组件开箱即用。设备维修记录里经常要展示时间线Element Plus 的 Timeline 组件能省不少事。2.2 整体架构设计系统采用经典的前后端分离架构大致分层如下前端Vue 3 Vite Pinia Vue Router Element Plus后端Node.js Express也可以换 Koa但 Express 中间件生态更全数据库MySQL 8.0工厂内部系统通常已有 MySQL 环境复用成本低认证方案JWT无状态便于水平扩展接口风格RESTful API前后端通过 HTTP 接口通信前端开发时用 Vite 的 proxy 代理解决本地跨域生产环境用 Nginx 反向代理。这种模式下前端只负责渲染和交互后端统一处理业务逻辑和数据校验职责清晰。2.3 数据库设计设备管理系统的关键表数据库设计是这类系统的地基我踩过不少坑最重要的心得是宁可一开始多花时间设计表结构也不要等上线后频繁改表。下面是核心表的设计思路。用户表sys_user存储系统用户包括管理员、设备科人员、维修工、车间操作工等角色。字段包括 id、用户名、密码哈希、姓名、手机号、角色类型、部门、创建时间。密码不要存明文用 bcrypt 加密。设备表device这是系统的核心主数据。字段包括 id、设备编号唯一、设备名称、型号规格、所属车间、安装位置、设备状态运行/停机/维修中/报废、购置日期、保修截止日期、维保周期类型、维保周期天数、备注。这里有个容易忽略的细节设备状态和报修工单状态要联动。设备在维修中时应该禁止重复提交报修或至少给出提示避免同一台设备被多人重复报修。报修工单表repair_order这是流转最复杂的表。字段包括 id、工单编号、设备 id、报修人 id、故障描述、故障图片、紧急程度普通/紧急/非常紧急、工单状态、指派人 id、处理结果、完成时间、评价等级、评价内容。工单状态我用的是待派单、处理中、已完成、已驳回、已取消。状态变更要记录到一张工单操作日志表里方便追溯。维保计划表maintenance_plan字段包括 id、设备 id、计划维保日期、实际维保日期、维保类型日常保养/一级保养/二级保养、执行人 id、维保内容、使用备件、工时、费用。维保计划要支持按周期自动生成待办提醒这需要在后端做定时任务扫描。备件表spare_part字段包括 id、备件编码、备件名称、规格型号、库存数量、安全库存、所在仓库位置、供应商。维修工单挂接备件时要联动扣减库存库存不足时要提示。这些表之间的关联关系归纳起来就是用户创建设备设备产生报修工单和维保计划维修工处理工单时消耗备件。工单和备件之间我用一个关联表 repair_order_part 来记录哪个工单用了哪些备件、用了多少这样成本核算和库存追溯都有据可查。3. Node.js 后端核心实现3.1 项目初始化与目录结构后端我用 Express 搭建项目初始化步骤如下创建项目目录并进入使用 npm init -y 初始化 package.json安装依赖express、mysql2、jsonwebtoken、bcryptjs、multer文件上传、dotenv、cors安装 nodemon 作为开发依赖实现代码热重载项目结构我习惯按功能模块划分而不是按文件类型controller/service 混着摆这样每个业务模块自成一体改动时不用跨目录找文件。server/ ├─ app.js ├─ config/ │ └─ db.js ├─ middleware/ │ ├─ auth.js │ └─ upload.js ├─ modules/ │ ├─ user/ │ │ ├─ user.controller.js │ │ ├─ user.service.js │ │ └─ user.route.js │ ├─ device/ │ │ ├─ device.controller.js │ │ ├─ device.service.js │ │ └─ device.route.js │ └─ repair/ │ ├─ repair.controller.js │ ├─ repair.service.js │ └─ repair.route.js ├─ utils/ │ └─ response.js └─ .env这种按业务模块组织的目录在项目膨胀之后优势会非常明显。我见过很多 Node 项目把所有 controller 塞一个文件夹、所有 service 塞一个文件夹结果改一个报修功能要在几十个文件里跳来跳去。3.2 基于 Express 实现报修工单接口先看入口文件 app.js 的核心部分const express require(express); const cors require(cors); const dotenv require(dotenv); dotenv.config(); const userRoute require(./modules/user/user.route); const deviceRoute require(./modules/device/device.route); const repairRoute require(./modules/repair/repair.route); const app express(); app.use(cors({ origin: process.env.FRONTEND_URL || http://localhost:5173, credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); // 静态资源存放上传的故障图片 app.use(/uploads, express.static(uploads)); // 路由挂载 app.use(/api/user, userRoute); app.use(/api/device, deviceRoute); app.use(/api/repair, repairRoute); // 统一错误处理中间件 app.use((err, req, res, next) { console.error(err.stack); res.status(err.status || 500).json({ code: 1, message: err.message || 服务器内部错误 }); }); app.listen(process.env.PORT || 3000, () { console.log(Server running on port ${process.env.PORT || 3000}); });有一个细节必须注意CORS 的 origin 不要简单设置成*。虽然开发时方便但生产环境如果带 cookie 会话*会导致跨域报错。这里推荐显式指定前端地址或者用数组形式支持多个可信来源。下面是报修工单新增接口的实现我重点解释几个关键点// repair.controller.js const repairService require(./repair.service); // 创建报修工单 async function createRepair(req, res, next) { try { const { deviceId, description, urgency, imageUrls } req.body; const reporterId req.user.id; // 来自 JWT 中间件 // 参数校验 if (!deviceId || !description) { return res.status(400).json({ code: 1, message: 设备ID和故障描述不能为空 }); } // 调用服务层 const orderNo await repairService.createRepair({ deviceId, description, urgency: urgency || 普通, imageUrls: imageUrls || [], reporterId }); res.status(201).json({ code: 0, message: 报修提交成功, data: { orderNo } }); } catch (err) { next(err); } } module.exports { createRepair };这里有个设计细节工单编号不要用自增 id 直接暴露给业务人员一是容易猜测总量二是业务侧希望工单号有可读性。我在 service 层生成编号时用了日期 设备编号 随机数的组合BX202501011234-DEV001-82。这样维修工在群里汇报时其他人一眼能看出是哪天的哪个设备。JWT 认证中间件也是核心它负责两件事校验 token 是否有效、把用户信息挂到 req 对象上。代码实现如下const jwt require(jsonwebtoken); module.exports function auth(req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 1, message: 未登录或登录已过期 }); } try { const token authHeader.split( )[1]; const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch (err) { return res.status(401).json({ code: 1, message: token 无效或已过期 }); } };注意这里有个安全细节JWT_SECRET 绝对不能写在代码里一定要从环境变量读取并且要使用足够长的随机字符串。我之前见过有人把 secret 写成123456结果被脚本扫描到服务器直接被拖库。3.3 报修工单状态机流转设计工单状态是这个系统最核心的业务逻辑一开始我直接硬编码 if/else后来状态多了根本维护不了。第二次重构时我改成了状态机配置代码清晰很多。// repair/repairStates.js const REPAIR_STATES { PENDING: 待派单, PROCESSING: 处理中, COMPLETED: 已完成, REJECTED: 已驳回, CANCELED: 已取消 }; // 允许的状态流转 const TRANSITIONS { [REPAIR_STATES.PENDING]: [REPAIR_STATES.PROCESSING, REPAIR_STATES.CANCELED], [REPAIR_STATES.PROCESSING]: [REPAIR_STATES.COMPLETED, REPAIR_STATES.REJECTED], [REPAIR_STATES.COMPLETED]: [], [REPAIR_STATES.REJECTED]: [REPAIR_STATES.PENDING], // 驳回后可重新派单 [REPAIR_STATES.CANCELED]: [] };状态变更接口统一走一个方法变更前先查状态机不允许的流转直接抛异常。这样设计的好处是以后增加状态比如待验收时只需要改配置不用在多个接口里挖逻辑。工单流转还涉及到操作日志我用一张 repair_log 表记录每次状态变更的时间、操作人、处理内容。这样设备科在复盘时能清楚地看到完整链路谁报修的、谁派的单、维修工几点接单、几点完成。4. Vue 前端核心实现4.1 前端项目初始化与路由权限控制前端我用 Vite 创建 Vue 3 项目npm create vitelatest frontend -- --template vue cd frontend npm install npm install vue-router4 pinia element-plus axios dayjs路由配置里最需要用心的是权限控制。系统有四种角色管理员能管所有配置、设备科能派单、查看统计、维修工能接单、更新进度、普通操作工只能发起报修。我用路由守卫检查用户角色来决定能否访问某个页面。// router/index.js import { createRouter, createWebHistory } from vue-router; import { useUserStore } from ../stores/user; const routes [ { path: /login, component: () import(../views/Login.vue) }, { path: /, component: () import(../layouts/MainLayout.vue), redirect: /dashboard, children: [ { path: dashboard, component: () import(../views/Dashboard.vue), meta: { title: 工作台 } }, { path: device, component: () import(../views/DeviceList.vue), meta: { title: 设备台账, roles: [admin, maintenance_admin] } }, { path: repair, component: () import(../views/RepairList.vue), meta: { title: 报修管理 } }, { path: maintenance, component: () import(../views/MaintenancePlan.vue), meta: { title: 维保计划, roles: [admin, maintenance_admin] } } ] } ]; router.beforeEach((to, from, next) { const userStore useUserStore(); if (to.path /login) return next(); if (!userStore.token) return next(/login); const roles userStore.roles || []; if (to.meta.roles !to.meta.roles.some(r roles.includes(r))) { return next(/dashboard); } next(); });路由守卫的加载顺序需要注意Pinia 必须先安装再调用路由守卫否则 store 还没初始化就读取会报错。我在 main.js 里是先app.use(pinia)再app.use(router)顺序反了会出现诡异的 undefined 错误。4.2 设备管理页面实现设备列表页面是整个系统最常用的界面核心需求是表格展示、按条件搜索、分页、新增和编辑设备。我用 Element Plus 的 el-table 渲染配合 el-pagination 做分页。下面这段代码是设备列表的核心逻辑template div classdevice-container el-card el-form :inlinetrue submit.prevent el-form-item label设备名称 el-input v-modelqueryParams.keyword placeholder输入设备名称或编号 clearable / /el-form-item el-form-item label设备状态 el-select v-modelqueryParams.status placeholder全部状态 clearable el-option label运行中 valuerunning / el-option label停机 valuestopped / el-option label维修中 valuerepairing / /el-select /el-form-item el-form-item el-button typeprimary clickhandleQuery查询/el-button el-button clickhandleReset重置/el-button /el-form-item /el-form el-button typesuccess clickopenDialog()新增设备/el-button el-table :datadeviceList border stripe el-table-column propdeviceCode label设备编号 width120 / el-table-column propdeviceName label设备名称 min-width150 / el-table-column propworkshop label所属车间 width120 / el-table-column propstatus label状态 width90 template #default{ row } el-tag :typestatusTagMap[row.status]{{ statusTextMap[row.status] }}/el-tag /template /el-table-column el-table-column proplastMaintenanceDate label最近维保时间 width130 / el-table-column label操作 width140 fixedright template #default{ row } el-button link typeprimary clickopenDialog(row)编辑/el-button el-button link typedanger clickhandleDelete(row)删除/el-button /template /el-table-column /el-table el-pagination v-model:current-pagequeryParams.page v-model:page-sizequeryParams.pageSize :totaltotal :page-sizes[10, 20, 50] layouttotal, sizes, prev, pager, next changefetchDeviceList / /el-card /div /template script setup import { ref, reactive, onMounted } from vue; import { ElMessage, ElMessageBox } from element-plus; import { getDeviceList, deleteDevice } from /api/device; const deviceList ref([]); const total ref(0); const queryParams reactive({ keyword: , status: , page: 1, pageSize: 10 }); const statusTagMap { running: success, stopped: info, repairing: danger }; const statusTextMap { running: 运行中, stopped: 停机, repairing: 维修中 }; async function fetchDeviceList() { const { data } await getDeviceList(queryParams); deviceList.value data.records; total.value data.total; } function handleQuery() { queryParams.page 1; fetchDeviceList(); } function handleReset() { queryParams.keyword ; queryParams.status ; queryParams.page 1; fetchDeviceList(); } async function handleDelete(row) { await ElMessageBox.confirm(确认删除设备「${row.deviceName}」吗, 删除确认, { type: warning }); await deleteDevice(row.id); ElMessage.success(删除成功); fetchDeviceList(); } onMounted(fetchDeviceList); /script写这段代码时我特别注意了操作列的宽度。很多新手把表格操作列宽度忽略不设结果在窄屏上按钮换行非常丑。fixedright 这个属性也建议加上当设备字段多、表格横向滚动时操作按钮始终可见对经常翻页的维修工来说体验提升明显。4.3 报修工单流程页面报修工单页面是这个系统的重头戏它串联了三种角色普通操作工发起报修设备科派单维修工处理和验收。前端我用标签页方式区分不同角色看到的视图。发起报修的表单比较简单核心是设备选择、故障描述、紧急程度和图片上传。图片上传部分我封装了 Element Plus 的 el-upload注意要设置 headers 带上 JWT token否则上传接口会被认证中间件拦下。工单详情页我用步骤条 时间线的方式展示处理进度效果直观el-steps :activecurrentStep align-center finish-statussuccess el-step title提交报修 description操作工提交故障信息 / el-step title设备科派单 description指派维修工 / el-step title维修处理 description维修工更新进度 / el-step title验收完成 description设备科确认结果 / /el-steps el-timeline el-timeline-item v-for(log, index) in repairLogs :keyindex :timestamplog.createdAt :typeindex 0 ? primary : info {{ log.operatorName }} 于 {{ log.action }}{{ log.content }} /el-timeline-item /el-timeline这里有个容易踩的坑后端返回的时间戳如果是 ISO 字符串比如 2025-01-01T08:30:00Z浏览器里显示时间会因为时区偏差跟本地时间不一样。解决方案是后端统一返回时间戳毫秒数前端用 dayjs 格式化。我前端专门封装了一个src/utils/date.js统一导出格式化函数避免每个人格式不一致。4.4 axios 请求封装与 token 拦截器前后端交互质量直接影响系统体验axios 封装是我每次必做的。核心是请求拦截器和响应拦截器。// src/api/request.js import axios from axios; import { ElMessage } from element-plus; import router from ../router; import { useUserStore } from ../stores/user; const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000 }); // 请求拦截器附带 token request.interceptors.request.use(config { const userStore useUserStore(); if (userStore.token) { config.headers.Authorization Bearer ${userStore.token}; } return config; }); // 响应拦截器统一处理错误 request.interceptors.response.use( response { const res response.data; // 后端统一返回 { code, message, data } if (res.code ! 0) { ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message)); } return res; }, error { if (error.response error.response.status 401) { const userStore useUserStore(); userStore.logout(); router.push(/login); ElMessage.error(登录已过期请重新登录); } else { ElMessage.error(error.response?.data?.message || 网络异常请稍后重试); } return Promise.reject(error); } ); export default request;响应拦截器里统一处理 401 是一个很省心的做法。后续如果要扩展刷新 token机制也只需要在这个拦截器里加请求队列重放逻辑不用每个接口单独写。5. 环境搭建常见问题与排查Node.js 相关的开发环境问题几乎每个新手都会遇到我系统整理一下常见问题和解决办法。5.1 npm 无法加载文件 npm.ps1禁止运行脚本微信群里经常有人发这个错误npm : 无法加载文件 d:\program files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这个问题的本质是 Windows PowerShell 的执行策略默认限制了 .ps1 脚本运行。解决方法很简单用管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned输入 Y 确认即可。RemoteSigned的意思是本地下载的脚本可以运行从网络下载的脚本必须有可信签名。这个设置足够日常开发使用。这里叮嘱一句网上有些教程让你执行Set-ExecutionPolicy Bypass虽然也能解决但会把本机的执行策略降到底不建议长期这么做。尤其公司电脑有安全基线要求的话这条策略会触发告警。5.2 Node.js 安装和环境变量配置下载 Node.js 时认准官网尽量下载 LTS 版本别下载 Current 尝鲜版。LTS 版本稳定性经过社区大量验证项目开发最怕依赖环境出幺蛾子。安装注意几点安装路径不要带中文和空格否则后续全局安装的模块路径解析容易出问题安装向导里的Add to PATH选项必须勾选如果电脑里同时有多个 Node 项目强烈建议装 nvm-windows 管理多版本。nvm 让开发环境可以随时切换 Node 版本比如老项目用 Node 16新项目用 Node 20这样不会再因为版本差异踩坑检查环境变量是否正确配置在命令行执行node -v npm -v如果提示无法识别 node 命令说明 PATH 没配好。需要检查系统环境变量里是否有C:\Program Files\nodejs\这个路径。5.3 前后端跨域问题前端地址是 http://localhost:5173后端接口是 http://localhost:3000浏览器默认会拦截跨域请求。解决办法有两种第一种是后端开启 CORS我上面代码里已经写过。适合前后端独立部署的场景。第二种是前端通过 Vite 代理转发适合开发环境。在 vite.config.js 里配置export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true } } } });配置之后前端请求/api/xxx会被 Vite 启动的开发服务器代理到http://localhost:3000/api/xxx浏览器端视角是同源请求不会触发跨域。这两种方案二选一即可同时配上也不冲突。生产环境用 Nginx 做反向代理时跨域问题自然消解。5.4 MySQL 连接超时与连接池配置Node.js 连接 MySQL 时如果不配置连接池频繁创建销毁数据库连接会导致握手开销大高并发时出现ETIMEDOUT错误。我用 mysql2 的 createPool 替代 createConnectionconst mysql require(mysql2); const pool mysql.createPool({ host: process.env.DB_HOST || localhost, port: process.env.DB_PORT || 3306, user: process.env.DB_USER || root, password: process.env.DB_PASSWORD || , database: process.env.DB_NAME || factory_mms, waitForConnections: true, connectionLimit: 10, queueLimit: 0, enableKeepAlive: true, keepAliveInitialDelay: 0 }); module.exports pool.promise();连接数设置在 10 左右对这类内部系统足够同时注意 MySQL 服务端max_connections默认是 151别把连接池上限设太大否则会反过来把数据库挤垮。5.5 vue devtools 调试技巧调试 Vue 项目时浏览器装一个 Vue Devtools 插件Vue 3 对应版本几乎是必备。它能在控制台直接查看组件树、props、Pinia 状态排查数据传递问题效率极高。我调试时最常用的一个功能是检查响应式数据有没有按预期更新。如果页面数据没变先在 Devtools 里看组件绑定的数据源再去看网络请求是否成功返回基本能定位是前端渲染问题还是接口数据问题。6. 部署与运维开发完不是终点把系统稳定跑起来才是真正的考验。工厂内部系统部署环境相对受限我一般按后端、前端、数据库三步走。6.1 后端部署PM2 进程管理后端是 Node.js 服务不能像 Java 那样打一个 war 包丢进 Tomcat 就跑。我推荐用 PM2 做进程守护它能在进程崩溃时自动重启还能查看日志、监控资源。部署步骤npm install -g pm2 pm2 start app.js --name factory-mms pm2 save pm2 startup # 设置开机自启常用的 PM2 命令pm2 logs factory-mms # 查看实时日志 pm2 restart factory-mms # 重启应用 pm2 monit # 查看CPU/内存占用生产环境的 .env 文件一定要单独配置不能复用开发环境的配置。数据库密码、JWT_SECRET 这些敏感信息通过.env文件管理并且把.env加进 .gitignore防止误传仓库。6.2 前端构建与 Nginx 部署前端构建很简单npm run build构建完成后dist 目录打包拷贝到服务器。我用 Nginx 来托管静态文件同时反向代理后端 API。一个典型配置如下server { listen 80; server_name factory.example.com; # 前端静态资源 root /var/www/factory-mms/dist; index index.html; # 单页应用路由防止刷新 404 location / { try_files $uri $uri/ /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; proxy_set_header X-Forwarded-Proto $scheme; } # 上传文件访问 location /uploads/ { alias /var/www/factory-mms/uploads/; } }try_files $uri $uri/ /index.html这一行是 Vue Router history 模式的关键。如果没有它刷新页面时 Nginx 会尝试找真实文件找不到就会 404。6.3 数据备份策略工厂设备维修记录是重要资产备份策略一定要提前定好。我每天凌晨通过 crontab 执行 MySQL 备份0 2 * * * mysqldump -uroot -p密码 factory_mms /backup/factory_mms_$(date %Y%m%d).sql --single-transaction --quick备份保留 30 天定期清理旧文件。这个操作虽然简单但能救命。我曾经遇到过数据库误删事故多亏有备份才能在 20 分钟内恢复。7. 实操经验与避坑记录这部分我把自己开发过程中的经验和踩过的坑整理一下希望能帮读者少走弯路。7.1 时间字段和时区统一工厂用户都在国内时间处理相对简单但项目里如果出现了海外服务器或者用户在不同时区就会很麻烦。我的原则是数据库统一存 UTC 时间后端接口返回时间戳前端展示时再转本地时区。这个坑我踩过。有一版后端直接返回了本地时间字符串前端在浏览器直接展示结果换了一个用户在其他时区访问看到的维保提醒时间差了几个小时被设备科投诉。后来全部改成时间戳传递问题彻底解决。7.2 权限控制要前后端同步前端隐藏了报修管理菜单不代表后端接口也安全。有些新手爬虫工程师会直接 POST 请求绕过前端调用接口。所以后端每个接口都要有对应的权限校验重点接口尤其如此。我的做法是写一个requireRole中间件配合 JWT 中的角色信息做二次校验。module.exports function requireRole(...roles) { return (req, res, next) { if (!roles.includes(req.user.role)) { return res.status(403).json({ code: 1, message: 没有权限执行操作 }); } next(); }; };使用示例router.post(/api/repair, auth, requireRole(admin, maintenance_admin), repairController.createRepair);7.3 设备状态与工单状态的联动设备状态不能只靠人工修改应该由工单流转自动驱动。比如维修工接单后设备状态自动从运行中变成维修中工单完成后设备状态恢复为运行中。这个联动如果放在前端做用户关掉页面就失效了一定要放在后端事务里处理。实现时我在一个事务里同时更新设备状态和工单状态保证一致性// repair.service.js const connection await pool.getConnection(); try { await connection.beginTransaction(); await connection.execute( UPDATE device SET status ? WHERE id ?, [repairing, deviceId] ); await connection.execute( UPDATE repair_order SET status ? WHERE id ?, [处理中, orderId] ); await connection.execute( INSERT INTO repair_log (order_id, operator_name, action, content) VALUES (?, ?, ?, ?), [orderId, operatorName, 派单, 维修工 ${workerName} 接单] ); await connection.commit(); } catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }7.4 表单校验前后端都要做只做前端校验的表单等于没校验直接 POST 请求就能绕过去。比如工单的紧急程度字段前端用下拉框限制了值但后端也必须校验传入值是否在[普通, 紧急, 非常紧急]数组里。我用的校验方式很简单const allowedUrgency [普通, 紧急, 非常紧急]; if (!allowedUrgency.includes(urgency)) { return res.status(400).json({ code: 1, message: 紧急程度不合法 }); }同理设备编号、手机号、金额这类字段前端正则校验了后端也要再校验一遍。宁可多写几行代码也不能让脏数据进表。7.5 工单并发提交的重复问题车间操作工经常会有设备坏了很着急连续点了三下提交的操作。如果不做处理同一台设备会生成 3 张重复工单。解决办法有几种一是前端在提交按钮上做 loading 禁止重复点击这个只管住正常用户。二是后端做防重校验在同一台设备已有待派单或处理中状态的工单时禁止创建新的报修工单。我在 service 层加了判断const existing await pool.execute( SELECT id FROM repair_order WHERE device_id ? AND status IN (待派单, 处理中) LIMIT 1, [deviceId] ); if (existing[0].length 0) { throw new Error(该设备已有未完成的报修工单请勿重复提交); }这道防线能拦截绝大多数的重复提交。7.6 枚举值的数据库设计设备状态、工单状态、紧急程度、角色类型这些枚举值在数据库里我建议直接存中文文本而不是存 0/1/2 数字。虽然数字更节省存储但排查问题时你看status 1不知道自己看到的是什么还得翻代码。存中文文本对后期维护和写报表 SQL 都更友好代价是多了几个字节的存储空间对内部系统来说完全可接受。如果项目规模大、业务方想改枚举名称可以在后端维护一个常量配置表通过接口提供给前端渲染避免硬编码。8. 扩展方向与个人体会系统做到这里已经能支撑工厂日常运维了但说实话内部系统永远有做不完的优化。我自己后续准备往这几个方向扩展。最想做的第一个扩展是接入设备传感器数据。现在很多工厂设备本身带 PLC 或传感器可以通过 Modbus 协议把运行状态、温度、电流等数据接入系统。一旦检测到异常参数系统自动生成报警工单并推送给维修工从人报修变成设备自己报修。这个能力需要硬件配合但技术上 Node.js 社区有不少工业协议库可以调研。第二个扩展是维修知识库。维修工在处理完故障后把故障现象、原因、解决方案沉淀成知识文章下次遇到相似问题可以直接搜索参考。这对老师傅经验传承非常有用能让维修团队整体水平快速提升。第三个扩展是智能派单。根据维修工的历史维修记录、技能标签、当前未完成工单数量自动推荐最合适的维修工替代现在设备科手动分配的方式。这个功能用简单的评分排序就能实现不一定需要多复杂的算法。最后再说两句。这段时间开发这个系统的感受是管理系统的价值不在技术多新而在流程能否真正落地。选择一个生产车间作为试点、跟设备科的老师傅反复确认操作习惯比闷头写代码重要得多。用户操作路径越顺畅系统数据越完整统计报表越可信整个设备管理体系才能良性循环。数据质量是这套系统的生命线而数据质量靠的是业务流程设计得足够贴合实际。希望这篇分享能给正在做类似项目的读者一点参考。