如果你所在的企业还在用微信群报修、Excel 记台账,这套系统的价值会比较直接——设备档案、报修、派工、保养、维修记录、备件消耗全部在一个页面里闭环流转。我先后用 Vue 2 + Node.js + Express + Element UI 做过两个设备维护网站,一个给制造车间用,一个给物业维修团队用,整体技术路线踩得比较熟。这篇文章会把需求拆解、架构方案、数据库设计、核心功能实现、常见坑位和部署扩展建议一次讲透。适合正在准备毕设、企业内部信息化小项目的开发者,也适合想从前端切入全栈、用真实业务驱动自己提升的初级工程师。
1. 项目需求与核心设计思路
1.1 设备维护业务里最容易被低估的三个痛点
做企业内部系统,最忌讳的是上来就写代码。设备维护这个场景表面看是“报修 + 记录”,但真正跑业务的时候会发现三个问题:台账散、流转乱、数据不沉淀。
第一个是台账散。很多非纯工厂的企业,比如物业、连锁门店、中小型车间,设备登记都在 Excel 里,设备编码可能是“A-01”“A01”“001”这样的混用,同一个设备在不同表格里名字都不一样,时间一长根本说不准公司里到底有多少台可用设备。设备没有唯一标识,后面所有工单、维修记录、备件消耗都成了无源之水。
第二个是流转乱。报修靠微信电话,派单靠口头传达,维修工做完之后在群里发个“修好了”就算结束。这样带来两个后果:管理者不知道工单积压在谁手里,维修耗时也被严重低估。你说一个上午能修完,现场可能要拖三天,因为没有时间节点在系统里记录。
第三个是数据不沉淀。维修记录如果既不涉及大额成本,也不涉及重复故障,确实容易被忽略。但这类数据恰恰是采购、保养计划、设备报废决策的基础。我接触过的企业里,最常见的矛盾就是“坏到不能用了才想起来之前也老坏”,结果从前修了多少次、花了多少钱、换过什么备件,谁都讲不清楚。
所以这个系统的设计核心不是“做一个好看的CRUD”,而是先把业务闭环建起来:从设备台账出发,以工单驱动维修过程,让每一次维修都留下结构化记录,再把记录聚合到看板和报表里。简单说,就是先让维护过程可见,再让维护数据可用。
1.2 功能模块拆分:从“能报修”到“能闭环”
我的习惯是先画业务流,再切模块。这个项目的核心流程其实只有一条主线:普通用户发现设备故障 -> 提交报修工单 -> 管理员派单给工程师 -> 工程师接单处理 -> 提交维修结果 -> 报修人验收 -> 工单关闭。围绕这条主线,再加上保养、备件、统计和权限,才能凑成一个真正能落地的系统。
| 模块 | 核心功能 | 业务价值 |
|---|---|---|
| 设备档案 | 设备新增、编辑、停用、二维码生成、状态查询 | 让每台设备有唯一身份和历史记录 |
| 工单管理 | 报修、派单、接单、处理、验收、转单 | 维修过程全程可跟踪 |
| 保养管理 | 保养周期设置、周期生成预防性工单、超期提醒 | 把“坏了再修”变成“按期保养” |
| 备件管理 | 备件入库、出库、工单领用记录、库存预警 | 管住维修成本,避免库存黑洞 |
| 维修记录 | 工时、费用、维修内容、图片上传 | 形成设备故障历史,支撑后续决策 |
| 统计看板 | 工单趋势、故障设备排行、人员工效、备件消耗 | 给管理者一个可量化的数据视图 |
| 用户与权限 | 用户管理、角色分配、操作日志 | 区分管理员、工程师、普通用户的操作范围 |
这里有一个容易漏掉的细节:保养管理并不是简单做一个“到期提醒”,而是要根据设备的保养周期,在系统里自动生成一张预防性维护工单,再走和报修工单一样的派单流程。比如某台空压机每个季度要换油、清滤芯,系统在到期前一周自动生成工单并指派给责任人,这才算把“保养”纳入闭环。
1.3 为什么我锁定 Vue + Node.js + Element UI 这套组合
技术选型永远是“够用、可控、团队能上手”优先,而不是“最新、最炫”优先。Vue 本身具备双向数据绑定,做表单密集型的后台管理系统很顺手;Element UI 又直接补上了表格、弹窗、表单校验、分页、日期时间选择这些组件的空缺。设备维护网站是典型的中后台项目,页面无非是表格 + 表单 + 状态流转,Element UI 的成熟度刚好覆盖需求,开发效率很高。
Node.js + Express 作为后端也很合适:一是前后端都是 JavaScript,数据和接口定义不用切换语言思维,团队沟通成本低;二是 Express 中间件机制足够清晰,JWT 鉴权、文件上传、参数校验这些都能通过中间件组合出来;三是项目规模不大,不需要 Spring Boot 那种完整框架的全套约束,启动速度快,部署也简单。
如果你是现在才决定立项,我更推荐 Vue 3 + Element Plus + Node.js 的组合。Vue 2 已经停止维护,数据响应式也有天然的性能边界;不过只要接口设计、状态管理和权限模型这些核心思路一致,后续从 Vue 2 迁移到 Vue 3 并不伤筋动骨。真正要动脑子的地方,永远在业务建模和权限设计,而不是框架版本。
2. 系统架构与数据库设计
2.1 前后端分离的目录结构与请求链路
项目采用前后端分离,物理上是两个独立工程:client存放 Vue 前端,server存放 Node.js 后端。开发环境的请求链路是:浏览器访问 Vue 开发服务器(默认 5173 或 8080),由 Vite/Webpack 代理把/api开头请求转发到 Node 服务(3000 端口)。生产环境则直接用 Nginx 托管前端构建产物,再反向代理/api到后端服务。
server/ app.js # Express 应用入口 routes/ # 各业务路由 device.js workOrder.js user.js part.js record.js upload.js controllers/ # 控制器,处理业务逻辑 models/ # Sequelize 模型定义 middlewares/ # 认证、权限、错误处理中间件 utils/ # 公共工具函数 uploads/ # 上传图片存储目录 client/ src/ api/ # axios 请求封装 router/ # 前端路由与守卫 views/ # 页面组件 Login.vue Dashboard.vue DeviceList.vue WorkOrderList.vue PartList.vue components/ # 可复用组件 utils/ # 工具函数,如时间格式化我坚持前后端分离的原因很简单:维修人员和报修人经常需要在外场办公,前端可以做独立的移动端适配,后端不需要跟着前端一起发布。而且前后端独立部署后,后端只暴露接口,前端由 Nginx 托管静态文件,整体运维复杂度并没有增加太多。
2.2 核心数据表怎么设计
数据表是整个系统的地基。我一般是围绕设备、工单、维修记录、备件、用户这五类核心实体来建模,再加上操作日志和备件领用记录两张辅助表。以期望的完整度来看,核心表字段设计大致是这样。
用户表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| username | varchar(50) | 登录名,唯一索引 |
| password_hash | varchar(255) | 加盐哈希后的密码 |
| real_name | varchar(50) | 真实姓名 |
| role | varchar(20) | admin / engineer / user |
| department | varchar(50) | 部门或班组 |
| enabled | tinyint | 是否启用 |
| created_at | datetime | 创建时间 |
设备表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| device_code | varchar(50) | 设备编码,唯一 |
| device_name | varchar(100) | 设备名称 |
| model | varchar(100) | 型号 |
| location | varchar(100) | 安装位置 |
| install_date | date | 投入使用日期 |
| status | varchar(20) | running / maintenance / scrapped |
| qr_code | varchar(255) | 二维码内容或路径 |
工单表是业务核心,字段需要覆盖整个状态流:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| order_no | varchar(50) | 工单编号,唯一 |
| device_id | int | 关联设备 |
| reporter_id | int | 报修人 |
| assignee_id | int | 指派工程师 |
| order_type | varchar(20) | repair / preventive |
| priority | varchar(10) | high / normal / low |
| status | varchar(20) | pending / processing / finished / closed |
| description | text | 故障描述 |
| handle_result | text | 处理结果 |
| report_time | datetime | 报修时间 |
| assign_time | datetime | 指派时间 |
| finish_time | datetime | 完成时间 |
| close_time | datetime | 验收关闭时间 |
| cancel_reason | varchar(255) | 取消原因 |
维修记录表:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | int | 主键 |
| work_order_id | int | 关联工单 |
| device_id | int | 关联设备 |
| operator_id | int | 维修操作人 |
| content | text | 维修内容 |
| cost | decimal(10,2) | 维修费用 |
| meter_hours | decimal(10,2) | 维修工时 |
| record_time | datetime | 记录时间 |
在设计时我会特别强调三个原则。
第一,不要物理删除关键业务数据。尤其设备、工单、维修记录,一旦删除,过往统计口径就变了。推荐用状态字段或deleted_at软删除,管理员看到的“移除”只是把设备标记为停用或归档。
第二,高频查询字段要建索引。工单表上最常见的查询条件是device_id、status、assignee_id、report_time,这些字段必须加索引。否则数据到几万条以后,维护列表页会明显变慢。
第三,外键关系要在数据库层面定义清楚。比如维修记录属于工单,工单关联设备,删设备时不能把维修记录一起物理删除,否则历史追踪全部断裂。订单表建议命名为work_orders,避免和 SQL 关键字order产生歧义,也方便后续扩展。
2.3 权限模型与登录认证方案
设备维护网站一般有三种角色:管理员、工程师、普通用户。管理员管设备档案、用户和派单;工程师处理派给自己的工单;普通用户主要提交报修、查看自己设备的维修状态。权限模型我采用“角色即权限”的简单方案,不引入复杂的 RBAC 表结构,因为这类企业内部项目通常不会出现“某个人拥有三个角色中的两种”这种需求。
认证使用 JWT。用户登录成功后,后端签发一个 token,载荷部分存放user_id、username、role,过期时间设为 7 天。前端把 token 存到 localStorage,每次请求通过 Authorization 头携带。
这里必须强调一句话:前端隐藏按钮只是体验优化,真正的权限校验必须在后端。我见过很多项目只在路由里做了meta.roles判断,接口本身没有任何防护,结果用户绕过前端直接调接口,照样能删数据。所以后端每个接口都要有认证中间件和角色校验中间件,这是底线。
function auth(req, res, next) { const token = (req.headers.authorization || '').replace('Bearer ', ''); if (!token) return res.status(401).json({ message: '未登录或登录已过期' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { return res.status(401).json({ message: '登录状态无效' }); } } function requireRole(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ message: '没有权限执行该操作' }); } next(); }; }前端侧再配合路由守卫,做一个基本的页面跳转拦截,避免未登录用户直接访问管理页面。不过这只是“体验”,安全边界仍然交给后端。
3. 核心功能实现拆解
3.1 登录、token 刷新与 axios 请求封装
登录接口的逻辑不复杂,但细节决定成败。密码绝不能明文存,实现时我推荐使用bcryptjs或 Node 内置的crypto.scrypt做加盐哈希。bcryptjs是纯 JS 实现,省去在 Windows 机器上编译原生模块的麻烦;如果追求安全和性能,可以用bcrypt原生版,但要提前确认服务器能编译。
登录时先按用户名查用户,再用bcrypt.compare校验密码,成功后签发 token:
POST /api/auth/login body: { username: "engineer01", password: "xxxxxx" } response: { token: "eyJhbGciOi...", userInfo: { id, realName, role } }前端把 token 写进 localStorage,并由 axios 统一注入 header:
const service = axios.create({ baseURL: '/api', timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); service.interceptors.response.use( res => res.data, err => { if (err.response && err.response.status === 401) { localStorage.removeItem('token'); window.location.href = '/login'; } return Promise.reject(err); } );这里有一个容易被忽视的坑:如果 token 失效后,用户正在某个接口页面上,全局跳转/login会把当前页面的输入内容全部丢失。比较好的做法是把跳转放在一个白名单里,比如只在非登录态请求时跳转,同时在登录页保留一个“登录成功后回跳原页面”的参数,否则用户体验会有点生硬。
另外,JWT 的密钥JWT_SECRET必须放在环境变量里,不要写进代码库。项目上线后我还建议至少每周轮换一次密钥,并保持前端能自动续签,这里可以根据实际体量决定是否实现 refresh token 机制。
3.2 工单状态机:把流转规则写进后端
工单是这个系统的发动机。如果让前端把状态随便改,后面统计口径一定会乱。我的做法是把状态流转定义成一个状态机,前端只负责展示和触发动作,状态变更是否合法由后端判断。
工单状态大致是:待接单 -> 处理中 -> 待验收 -> 已关闭。管理员可以取消工单,但取消必须填原因,不能无痕迹消失。
| 当前状态 | 可流转状态 | 触发动作 |
|---|---|---|
| pending | processing | 工程师接单 |
| pending | closed/cancelled | 管理员取消工单 |
| processing | finished | 工程师提交处理结果 |
| finished | closed | 报修人验收通过 |
| finished | processing | 验收不通过,退回重处理 |
后端用一张流转映射表来约束:
const allowedTransitions = { pending: ['processing', 'cancelled'], processing: ['finished', 'cancelled'], finished: ['closed', 'processing'], cancelled: [], closed: [], }; async function updateOrderStatus(orderId, toStatus, operatorId) { const order = await WorkOrder.findByPk(orderId); if (!allowedTransitions[order.status] || !allowedTransitions[order.status].includes(toStatus)) { throw new Error(`非法状态流转: ${order.status} -> ${toStatus}`); } const updateData = { status: toStatus }; if (toStatus === 'processing') updateData.assignee_id = operatorId; if (toStatus === 'processing') updateData.assign_time = new Date(); if (toStatus === 'finished') updateData.finish_time = new Date(); if (toStatus === 'closed') updateData.close_time = new Date(); await order.update(updateData); // 记录操作日志 await OperationLog.create({ user_id: operatorId, action: `update_order_status:${toStatus}`, target: `work_order:${orderId}`, detail: order.order_no, }); }前端页面上可以配合 Element UI 的el-steps组件展示当前进度。但要注意,步骤条的状态值只从接口返回中去取,不要在前端维护一份“临时状态”,否则刷新页面就会不一致。每次操作之后重新拉取工单详情,是最稳妥的做法。
3.3 设备二维码、报修拍照与图片上传
设备档案要落地价值,二维码是很好的桥梁。我实现的方案是用qrcodenpm 包把设备编码生成 PNG,打印后贴在设备机身。二维码内容可以是前端报修页的 URL,例如:
https://maintain.example.com/device/DEV001移动端扫码后进入设备详情页,页面里展示设备信息、最近维修记录和“发起报修”按钮,点按钮自动带上deviceId和deviceName,用户只需要补填故障描述,省去查设备编码的麻烦。
这个流程对报修人非常友好,维修效率提升也明显。以前报修要先拍照、再写清楚“哪台设备”,现在扫一下码,位置、设备名全带出来了。
图片上传则是维修记录里最实用的功能。维修前拍的问题照片、维修后的现场照片,都是后续审计和问题分析的重要材料。Node 端我用multer实现:
const multer = require('multer'); const path = require('path'); const storage = multer.diskStorage({ destination: 'uploads/', filename: (req, file, cb) => { const unique = Date.now() + '-' + Math.round(Math.random() * 1e9); cb(null, unique + path.extname(file.originalname)); }, }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter(req, file, cb) { const ext = ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(file.originalname).toLowerCase()); cb(null, ext); }, }); router.post('/upload', auth, upload.single('file'), (req, res) => { res.json({ url: '/uploads/' + req.file.filename }); });前端用el-upload的时候有一个关键坑:它默认用 XMLHttpRequest 发请求,不会经过 axios 拦截器。所以必须手动传headers,把 token 塞进去:
<el-upload action="/api/upload" :headers="{ Authorization: 'Bearer ' + token }" :on-success="handleUploadSuccess" > <el-button type="primary">上传图片</el-button> </el-upload>另外,不要把上传文件直接放到前端 dist 目录里。生产环境下由 Nginx 单独托管uploads目录,前端只存相对路径;数据库里也只保存/uploads/filename.jpg,不要保存完整的http://...前缀,这样在环境迁移时不用改数据。
3.4 看板与报表:让维护数据回答管理问题
看板模块是给管理者用的,三个页面比较实用:月度维修趋势、故障设备排行、工程师工时统计。报表的数据聚合尽量放到 SQL 里做,前端只负责渲染图表,不要一次性拉全量数据到浏览器再去计算。
月度维修趋势:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS total FROM work_orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 MONTH) GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month;工程师工效统计:
SELECT assignee_id, COUNT(*) AS order_count, ROUND(AVG(TIMESTAMPDIFF(MINUTE, assign_time, finish_time)), 1) AS avg_minutes FROM work_orders WHERE status IN ('finished', 'closed') GROUP BY assignee_id;前端我用 ECharts 渲染折线图和柱状图。这里要提醒一句:数据不足 3 个月时,空数据很容易让图表看起来像故障率飙升,所以最好按月份补齐缺失月份为 0,不要直接让 SQL 只返回已发生数据的月份。
看板里还可以增加一个“备件消耗排行”,对应到工单领用表。这能直观看到哪些备件是消耗大户,为采购计划提供依据。总之,报表不是花哨的图形堆叠,而是回答业务里最关键的三个问题:哪里坏得最多、谁干得最慢、备件为什么消耗这么快。
4. 实战中踩过的坑与排查记录
4.1 Element UI 表格不刷新、弹窗不清空
用 Element UI 做管理系统,最常见的问题是“数据改了,表格不更新”。有一次后台改完工单备注,返回列表,界面还是显示旧备注。追查下来,是后端返回的数组被整体替换后,又没有触发 Vue 2 的响应式更新。
解决方案通常是两种:要么直接重新调用fetchList()刷新数据,要么保证赋值的是新数组引用:
this.tableData = [...this.tableData, newItem]; // 新增 this.tableData = this.tableData.map(item => item.id === newItem.id ? newItem : item); // 更新不要在一个 object 上直接改深层属性:
// 这种写法 Vue2 可能不响应 this.tableData[index].remark = 'new remark';弹窗不清空是另一个高频问题。el-dialog默认关闭只是隐藏,内容不会销毁。第二次打开同一个弹窗,上次填写的表单值还在。处理办法是给el-dialog加:destroy-on-close="true",或者在打开弹窗时手动resetFields():
<el-dialog :visible.sync="dialogVisible" destroy-on-close> <el-form ref="editForm" :model="form"> <!-- ... --> </el-form> </el-dialog>这个坑在“编辑 + 新增共用一个弹窗”时最容易出现。新增之后马上点编辑,表单里还会残留上一次编辑的数据,等用户点保存就会提交脏数据。所以每次打开弹窗时必须重置表单,并在nextTick里清除校验结果。
4.2 时间格式化和时区问题的糊涂账
设备维护系统的工单列表里,几乎每一个页面都要显示时间。前后端时间处理不一致,会出现两个常见毛病:一是显示“2027-04-12T07:30:00.000Z”这种让人看不懂的字符串;二是同一台设备在不同页面显示的维修时间差了8个小时。
我的建议是统一后端返回YYYY-MM-DD HH:mm:ss格式的字符串,不要直接返回 Date 对象给前端。Express 默认序列化 Date 会成为 ISO 格式,所以我在 Sequelize 配置里就指定好时间类型:
new Sequelize(process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD, { host: process.env.DB_HOST, dialect: 'mysql', timezone: '+08:00', // 根据你的业务时区统一 });如果数据库存在 UTC 而应用存在东八区,写入时会错一场。更稳妥的方案是全部按 UTC 存储,展示时由前端用 dayjs 转成本地时间,但这样对报表 SQL 的DATE_FORMAT也要小心。我的实践经验是:小团队项目就统一用一种时区策略,比如“MySQL 连接和 Node 进程都设置为东八区”,同时所有接口返回YYYY-MM-DD HH:mm:ss,简单直接,不绕弯。
前端拿到时间后,再统一格式化:
import dayjs from 'dayjs'; export function formatTime(value) { if (!value) return '-'; return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); }4.3 并发重复提交与重复指派
并发问题在管理系统中容易被忽略,但恰好是设备维护里最真实的问题。一个设备坏了,报修人可能在电脑上连续点了两次提交,生成两张一模一样的工单;两台工程师同时点“接单”,系统就把同一台设备指派给了两个人。这两个问题都要在接口层解决,前端按钮做 loading 只是第一道防线。
前端最简单的手段是提交按钮绑定 loading 状态:
<el-button :loading="submitting" @click="submitOrder">提交</el-button>但请求超时重试、用户刷新页面等场景仍然可能产生重复请求,所以后端也要做幂等。方法是前端生成一个requestId(UUID),每次创建工单时带上;后端在work_orders表加一个unique_request_id字段,重复请求会因为唯一索引冲突而失败:
const existing = await WorkOrder.findOne({ where: { request_id: requestId } }); if (existing) return res.status(400).json({ message: '工单已提交,请勿重复操作' });对于重复指派,不能先查再更新,要用条件更新保证原子性:
UPDATE work_orders SET assignee_id = ?, assign_time = NOW(), status = 'processing' WHERE id = ? AND status = 'pending';如果affectedRows为 0,说明工单已经被别人接走,返回提示“工单已被处理”。这个方式比“先 select 再 update”安全得多,也能避免两个工程师同时看到同一张工单的竞态问题。
4.4 常见问题速查表
| 现象 | 常见原因 | 处理建议 |
|---|---|---|
| 刷新页面后用户态丢失 | token 在 localStorage,但用户信息没有恢复 | 登录成功后调/api/user/me拉取并缓存到 Vuex/Pinia |
| 上传图片后 401 | 上传组件没带 token 请求头 | el-upload手动配置:headers |
| 导出的 Excel 中文乱码 | 没有添加 UTF-8 BOM | 在 csv/xlsx 导出文件前加\uFEFF |
| 跨域报 CORS 错误 | 开发/生产代理未生效 | 开发用 Vite proxy,生产用 Nginxlocation /api反向代理 |
| 时间显示为 ISO 字符串 | 后端返回了 Date 对象 | 后端统一格式化或前端用 dayjs 转换 |
| 删除设备后工单查询异常 | 外键约束导致关联记录无法处理 | 用软删除,工单和维修记录保留历史设备快照 |
| 多人同时操作同一工单 | 前端状态未实时刷新 | 操作后强制重新拉取详情,后端用条件更新兜底 |
这个表格里的每一项,我几乎都上线踩过一遍。整理成速查表的好处是,团队里新成员遇到同类问题可以少走弯路。
5. 部署、性能调优与迭代方向建议
5.1 Nginx + PM2 + MySQL 的轻量部署方案
这类企业内部系统,不需要一上来就上容器编排,最简单的方案是单台服务器跑 Nginx + Node.js + MySQL。前端构建完成后,把client/dist下的静态文件放到 Nginx 的网站根目录,后端用 PM2 启动。
Nginx 配置大概是这样:
server { listen 80; server_name maintain.example.com; root /var/www/client/dist; 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 /uploads/ { alias /var/www/server/uploads/; expires 30d; } location / { try_files $uri $uri/ /index.html; } }try_files那一条对 Vue Router 的 history 模式至关重要。如果少了它,刷新/device/DEV001这类页面就会报 404,因为服务器根本没有这个物理文件。这个坑在第一次上线时很常见,我至今记得调试到深夜才意识到是路由回退没配。
后端服务用 PM2 管理:
pm2 start app.js --name device-maintenance pm2 save pm2 startupMySQL 则要定期备份,我建议至少每天凌晨做一次增量备份,同时保留最近 7 天全量备份。业务数据不丢,是系统能长期运转的前提。
5.2 性能优化的三个切入口:SQL、静态资源、并发
目录系统数据量不大时,性能问题不明显;但设备数量超过 5000、工单超过 10 万条之后,首当其中的就是 SQL 效率。最实用的优化是给高频查询加复合索引,比如工单列表页经常按device_id + status筛选,就建一个(device_id, status)复合索引。其次是分页偏移量过大的问题,使用传统的LIMIT 100000, 20时 MySQL 要扫描前面十万行,很慢;换成游标分页后,性能能提升一个量级:
-- 传统分页 SELECT * FROM work_orders ORDER BY id LIMIT 100000, 20; -- 游标分页 SELECT * FROM work_orders WHERE id > 100000 ORDER BY id LIMIT 20;静态资源方面,前端构建时使用 gzip 压缩,图片上传时限制大小并在服务端做压缩,不要把 5MB 原图直接丢给 Nginx 展示。必要时接入对象存储,比放在本地硬盘更省心,但维护成本也会高一点。
并发层面,如果报修高峰期集中在上午,Node 进程可以考虑用 PM2 启动 cluster 模式,利用多核 CPU 提升吞吐。不过要注意,如果用本地文件存上传图片,cluster 模式下要确保所有 worker 挂载同一存储,否则会偶发图片 404 的问题。
5.3 后续可以继续扩展的方向
设备维护网站做完第一版后,只算完成 60%。往后迭代的方向大概有这么几条。
移动端报修体验是第一优先级。虽然前端可以做成响应式,但现场维修工使用场景里,手机上的表单效率才是真正的关键。更理想的是做一个小程序或者 PWA,扫码、拍照、填故障描述,两步完成报修,这比 Web 页面的体验好很多。后端的 API 基本不用大改,前端重写一套界面就行。
消息通知也是价值很高的扩展点。工单被指派、处理完成、保养到期、备件库存预警,这些事件如果都不通知,系统就会变成“人要主动进来看才有用”的被动工具。可以利用 WebSocket 做站内消息,或者对接企业 IM 机器人推送通知,让工单流转真正“推”起来。
再往下就是 IoT 方向了。如果设备本身有传感器,可以定时把运行状态、温度、电流等数据传到系统里,设定阈值后自动生成保养或维修工单。这一步会把系统从“事后记录工具”升级成“事前预警平台”,价值完全不同,但复杂度也会明显提升,需要单独规划。
最后再聊两句我的实际体会
做完这套系统后,我最大的感受是:这类内部管理系统的成功与否,不在于技术多炫,而在于业务闭环和数据模型是否扎实。前端页面再漂亮,如果工单状态流转混乱、备件领用记录缺失、维修历史统计口径不一致,那系统很快就会变成没人维护的“僵尸系统”。
如果你准备从零开始做,我的建议是先花两天整理清楚业务方最关心的三个问题:他们要统计什么、权限怎么分、每天产生多少条工单。把这三个问题写进需求文档,再开始建表写代码,你会少走很多冤枉路。另一个容易被低估的点是,开发环境一定要提前准备好接口 mock,否则前端写页面时会反复等后端,进度会拖得非常难受。
希望这篇分享能帮你把“vue+nodejs+ElementUi的企业设备维护网站”这个题目做得更扎实。技术栈会过时,但对业务的理解和数据模型设计的敏锐度,才是真正值钱的部分。