1. 宿舍报修管理,为什么值得单独做一个系统
先说一个我自己的观察。很多学校宿舍的报修流程,至今还停留在“去楼下值班室填一张纸质单子”的阶段。学生填完单子,宿管看见了就转给维修师傅,师傅修完再手写一个“已处理”,最后这张纸要么丢了,要么攒一堆月底统一录Excel。整个过程听起来没什么大问题,但实际一跑起来全是黑洞:报修单一多就容易漏、师傅修没修没人跟进、学生反复追问“怎么还没来修”、宿管自己也说不清哪些是紧急工单、哪些已经拖了一个礼拜。
我自己帮一所学校做后勤信息化调研的时候,宿管阿姨的原话是:“最怕周末晚上来三五个报修,我记在本子上,第二天一早忘了跟维修师傅说,学生就来投诉了。”这不是态度问题,是流程本身没有闭环。所以当我拿到“Nodejs+vue高校学生宿舍报修管理系统”这个项目需求时,第一反应是:这个场景值得认真做一套东西,技术上不算难,但业务逻辑必须捋清楚。
这套系统要解决的,本质上就是一个闭环:学生提交报修 -> 宿管接收/派单 -> 维修师傅接单 -> 维修完成 -> 学生确认 -> 数据留存统计分析。角色大概分三种,学生、宿管、维修工,可能还有超级管理员做整体配置。技术栈用 Node.js 写后端、Vue 写前端页面(PC后台给宿管和管理员用,学生端可以是H5也可以嵌套在公众号里),数据库用 MySQL,这套组合在校园内网项目里非常成熟,招人好招、维护成本低、跑起来也稳。
这篇文章我就按自己的实际开发习惯,把一个宿舍报修管理系统的完整实现拆开讲。从环境配置、数据库设计、后端接口、前端页面到部署时容易踩的坑,全部覆盖。尤其会重点讲Node.js环境配置和npm那堆破事,以及Vue项目在联调阶段最常见的几个问题——这些都是新手一做项目就卡住的重灾区。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型逻辑:Nodejs + Vue 这对组合到底合适在哪
2.1 为什么后端选 Node.js,而不是 Java 或 PHP
这是选型阶段被问得最多的问题。学校类管理系统,传统方案往往是 Java 的 Spring Boot、PHP 的 ThinkPHP 或者 Python 的 Django。但这次我选 Node.js,有几个非常实际的考量。
首先是开发效率。这类报修系统的业务逻辑并不重,核心就围绕工单的状态流转:创建、审核、派单、完成、评价。用 Node.js + Express 写 RESTful API,前后端都用 JavaScript,数据类型、字段命名、接口参数的沟通成本几乎为零。前端 Vue 组件的 props 是什么格式,后端接口直接照着返回就行,不用像 Java 项目那样前端和后端各写一套对象模型,还得整天对齐字段名。
其次是团队协作的认知门槛低。现在很多高校的计算机相关课程已经大面积使用 JavaScript 做教学语言,学生也好、刚工作的初级开发也好,对 Node.js 的接受速度远快于 Java 那一套 Maven、Spring IOC、MyBatis 的复杂体系。你让一个刚毕业的新人一天内看懂 Express 的路由和中间件完全没问题,但要他一天内弄懂 Spring Security 的过滤器链,恐怕得花一周。
如果不是这次项目对高并发、分布式、强事务一致性没有任何要求,我是不会用 Node.js 的。宿舍报修系统的并发量,撑死也就是全校几千人同时在线提交,QPS 连 100 都难摸到,Node.js 的事件循环模型应付这种场景绰绰有余。
2.2 为什么前端选 Vue 而不是 React 或原生 JS
前端这里我更果断。Vue 在国内生态太成熟了,Element UI 这类组件库对后台管理系统的覆盖几乎到了“无脑复制粘贴”的程度。宿管端和管理员端要做的就是典型的 CRUD 后台:表格展示工单列表、表单提交报修信息、弹窗确认派单、状态标签切换。这些需求用 Vue + Element UI,半天就能把页面骨架搭完,而用 React 的话,UI 组件选型就得纠结一阵子。
另外,Vue 对新手非常友好。模板语法直观,数据和视图自动同步,不需要像 React 那样手动处理 setState 和副作用。 这种友好度在校园项目里很重要,因为后期往往会有老师带着学生一起维护这个系统,代码写得是否“一眼能看懂”直接决定了项目能不能传承下去。
在构建工程上,我选择 Vite 而不是 Vue CLI。原因也很直接:Vite 冷启动快、热更新快,开发体验比 Webpack 时代的 Vue CLI 好太多。虽然 Vue CLI 更“稳”,但新项目没必要给自己找不自在。
2.3 数据库还是选了 MySQL,简单可靠
数据库这里没有任何犹豫,直接 MySQL。有的方案会推荐 MongoDB,理由是“工单信息字段灵活”。但实际做下来,报修工单的字段非常固定:报修人、宿舍楼栋、房间号、故障类型、故障描述、图片、联系电话、状态、报修时间、完成时间……这些完全可以用关系型表结构清晰表达,而且后期做统计报表(比如按楼栋统计报修次数、按故障类型统计占比)SQL 写起来非常顺手。
MySQL 在校园内网环境里部署也方便,一台普通服务器就够。云厂商的 RDS 数据库也可以,但校内系统往往要求数据不出校园网,自建 MySQL 更符合实际约束。
数据库连接这里我用的是 mysql2 驱动,支持 Promise 语法,省去了回调地狱的烦恼。实际项目里再套一个连接池配置,避免每次请求都重新建立数据库连接,并发一上来性能立马不一样。
3. 环境配置三大坑:Node安装、npm权限、Vue工程启动失败
这章的内容,是每个用 Nodejs+Vue 做项目的人几乎都会经历的劫难。我不是夸张,凡是卡在环境上的人,十个里有八个会倒在下面三个坑里。
3.1 Node.js 安装与环境变量配置
Node.js 安装本身不难,去官网下载 LTS 版本,Windows 上一直下一步就行。但安装完成之后,很多人的第一个坑就来了:命令行里输入 node -v 能显示版本号,但输入 npm -v 却提示找不到命令。
这个问题的根源,多数是安装时没有勾选 “Add to PATH” 选项,或者安装完之后环境变量没有刷新。解决方式也简单:打开控制面板 -> 系统 -> 高级系统设置 -> 环境变量,在系统变量里的 Path 中检查有没有 Node.js 的安装目录(比如 C:\Program Files\nodejs\)。没有就手动加上,然后关掉所有已经打开的命令行窗口,重新打开再试。
另一个更诡秘的问题是,由于某种原因,Windows 系统安装了 NVM 或者其他版本管理工具,导致实际生效的 Node 路径和预期不一样。排查方法是在命令行输入:
bash复制where node
where npm
看打印出来的路径是否指向同目录。如果一条指向 NVM 目录、一条指向 Program Files,那就是版本管理工具接管了环境变量,需要调整 PATH 顺序。
3.2 npm 脚本被禁止执行的权限问题
这是靠热搜词里的高频问题,也是我每次培训必讲的坑。报错信息长这样:
bash复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本
这个问题的本质是 PowerShell 的执行策略(Execution Policy)默认限制脚本运行。npm 不是 exe 文件,它本质上是 .ps1 的 PowerShell 脚本,当系统策略是 Restricted 时,PowerShell 不允许任何脚本执行,于是报错。
解决办法有几种,我推荐最直接的那种:以管理员身份打开 PowerShell,执行:
bash复制Set-ExecutionPolicy RemoteSigned
选择 Y 确认。这个策略的意思是从互联网下载的脚本必须有数字签名才能运行,本地创建的脚本不受限制,在安全性上是可以接受的。
如果你用的命令行工具是 CMD 而不是 PowerShell,这个报错永远不会出现,因为 CMD 直接调用的 npm 的 .cmd 文件。所以另一个务实方案就是:在 Windows 上做 Node 开发,命令行工具直接用 CMD 或 Git Bash,绕开 PowerShell 的脚本策略问题。
3.3 创建 Vue 工程时常见的依赖安装失败
环境变量和权限问题解决了,接下来新坑出现在创建 Vue 项目这一步。用 Vite 创建 Vue 项目:
bash复制npm create vite@latest dormitory-frontend -- --template vue
cd dormitory-frontend
npm install
npm install 是最容易出状况的一步。常见报错有网络超时、只有 lock 文件没有 node_modules、peer dependencies 冲突升级报错等等。
我自己的习惯是项目一开始就配置国内 npm 镜像。全局设置:
bash复制npm config set registry https://registry.npmmirror.com
这样再执行 npm install,几百个依赖包的下载速度快到飞起。如果某个依赖版本出现 peer 冲突(经常发生在 Vue 插件和 Vue 版本不匹配上),我的处理方式是先看一眼冲突的具体版本,手动指定兼容版本,不要一上来就加 --force 或者 --legacy-peer-deps 无脑跳过校验。跳过的后果是,依赖树里装上一个错误版本,运行的时候才报莫名其妙的白屏错误,查找成本远超当时多花的五分钟。
工程跑起来之后,验证一下开发服务器:
bash复制npm run dev
看到 Local: http://localhost:5173/ 这行输出,环境这道关就算过了。
4. 数据库设计:报修工单的状态流转,是整张表结构的灵魂
环境跑通后,优先做数据库设计,而不是急着写接口。很多项目做乱了,原因就是表结构没想清楚就开始写代码,写到一半发现字段不够用,再回头改表,牵一发动全身。
4.1 四张核心表,搞定基础业务
这个系统的核心表,我说四张就够:用户表、宿舍楼栋表、报修工单表、维修记录表。再加一张公告表算增补,但核心就是前面四张。
先看用户表,这是整个系统的地基:
sql复制CREATE TABLE `user` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名',
`password` VARCHAR(255) NOT NULL COMMENT '密码哈希',
`real_name` VARCHAR(50) NOT NULL COMMENT '真实姓名',
`role` TINYINT NOT NULL DEFAULT 2 COMMENT '角色:0-超管,1-宿管,2-学生,3-维修工',
`phone` VARCHAR(20) DEFAULT NULL,
`dormitory_id` INT DEFAULT NULL COMMENT '关联宿舍房间ID,学生和宿管用',
`avatar` VARCHAR(255) DEFAULT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
密码这里我要插一句:绝对不能明文存储。 Node.js 这边用 bcryptjs 做哈希,注册时加盐加密,登录时比对哈希值。
然后是宿舍楼栋表,直接和报修工单挂钩:
sql复制CREATE TABLE `dormitory` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`building` VARCHAR(20) NOT NULL COMMENT '楼栋号,如"3号楼"',
`room_number` VARCHAR(20) NOT NULL COMMENT '房间号,如"503"',
`floor` INT DEFAULT NULL COMMENT '所在楼层',
`capacity` INT DEFAULT 4 COMMENT '住宿人数上限',
`has_air_conditioner` TINYINT DEFAULT 0,
`has_bathroom` TINYINT DEFAULT 0,
UNIQUE KEY `uk_building_room` (`building`, `room_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里设计重点是 building + room_number 的组合唯一索引,保证同一栋楼的同一房间不重复。后面学生创建报修单时,前端直接根据用户关联的 dormitory_id 自动带出楼栋和房间号,避免学生自己填错楼栋信息。
报修工单表是整张业务表的重心:
sql复制CREATE TABLE `repair_order` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`order_no` VARCHAR(32) NOT NULL UNIQUE COMMENT '工单编号,如BX2025010110001',
`student_id` INT NOT NULL COMMENT '报修学生ID',
`dormitory_id` INT NOT NULL COMMENT '宿舍房间ID',
`fault_type` VARCHAR(30) NOT NULL COMMENT '故障类型:水电/家具/门窗/网络/其他',
`description` TEXT COMMENT '故障描述',
`image_urls` JSON DEFAULT NULL COMMENT '现场图片,多张存JSON数组',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0待接单,1待维修,2维修中,3已完成待确认,4已确认,5已取消',
`assignee_id` INT DEFAULT NULL COMMENT '维修工ID',
`assignee_name` VARCHAR(50) DEFAULT NULL,
`urgent_flag` TINYINT DEFAULT 0 COMMENT '是否加急:0否,1是',
`student_phone` VARCHAR(20) DEFAULT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`assigned_at` DATETIME DEFAULT NULL COMMENT '接单时间',
`completed_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`confirmed_at` DATETIME DEFAULT NULL COMMENT '学生确认时间',
KEY `idx_status_created` (`status`, `created_at`),
KEY `idx_dormitory` (`dormitory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
工单状态我用数字枚举,代码里维护一个状态机。这里我踩过的一个坑是:最初只设了“待处理、处理中、已完成”三个状态,结果学生根本没确认环节,维修工自己点个完成就算结束。学生不满意还得去宿管那申诉,流程很混乱。 后来加上“已完成待确认”和“已确认”两个状态,让学生在客户端确认后,工单才正式完结。这个改动看起来微不足道,但直接改变了整个系统的可信度。
javascript复制// 工单状态机
const ORDER_STATUS = {
PENDING: 0, // 待接单
ASSIGNED: 1, // 已派单待维修
IN_PROGRESS: 2, // 维修中
COMPLETED: 3, // 已完成,待学生确认
CONFIRMED: 4, // 学生已确认,闭环
CANCELLED: 5 // 已取消
};
维修记录表主要用于记录师傅的操作轨迹:
sql复制CREATE TABLE `repair_log` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`order_id` INT NOT NULL,
`operator_id` INT NOT NULL,
`operator_name` VARCHAR(50) NOT NULL,
`action` VARCHAR(50) NOT NULL COMMENT '接单/开始维修/完成/备注',
`content` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这张表不是可选项。没有操作日志的工单系统,一旦两个人同时改了状态,或者师傅说“我修了”学生说“没人来”,连最基本的对账能力都没有。
4.2 状态机流转设计,为什么必须放在后端校验
状态流转是这里的核心逻辑,我不建议把状态更新直接交给前端传值。正确的做法是:后端接口按业务动作定义,每个动作在后端校验前置状态是否符合预期。比如“派单”接口只允许“待接单”状态的工单流转为“已派单”,“完成维修”只允许“维修中”的工单流转为“已完成待确认”。前端传一个目标状态值进来直接update,这是安全隐患,也是逻辑混乱的开始。
我在后端的工单操作接口里统一做了状态校验:
javascript复制// 派单接口伪代码
async function assignOrder(orderId, workerId) {
const order = await getOrderById(orderId);
if (order.status !== ORDER_STATUS.PENDING) {
throw new ApiError(400, '当前状态不可派单');
}
await updateOrder(orderId, {
status: ORDER_STATUS.ASSIGNED,
assignee_id: workerId,
assigned_at: new Date()
});
await insertRepairLog(orderId, workerId, '派单', `分配给维修工 #${workerId}`);
}
这套状态机在前后端联调阶段省了无数口舌。学生端按钮“学生确认完成”只在状态为3时可见;宿管端“派单”按钮只在状态为0时可见。前端的按钮显示由后端返回的工单状态推导,后端接口同样做校验,双保险。
5. 后端接口:Express 路由拆分的实践细节
5.1 接口模块怎么划分,才不臃肿
后端我用 Express,项目结构按模块划分,不是按文件类型堆。具体来说:
text复制server/
├── app.js # 入口,注册中间件和路由
├── config/
│ ├── db.js # 数据库连接池
│ ├── jwt.js # JWT 签发/校验配置
│ └── index.js # 全局配置
├── routes/
│ ├── auth.routes.js # 登录/注册/刷新token
│ ├── user.routes.js # 用户管理
│ ├── dormitory.routes.js # 宿舍管理
│ ├── repair.routes.js # 报修工单
│ ├── stats.routes.js # 统计报表
│ └── notice.routes.js # 公告
├── controllers/
│ ├── authController.js
│ ├── repairController.js
│ └── ...
├── middlewares/
│ ├── auth.js # 登录鉴权中间件
│ ├── role.js # 角色权限中间件
│ └── upload.js # 文件上传中间件
├── services/
├── utils/
└── sql/
这种按路由模块拆分的结构,会让整个项目就像一份目录清晰的说明书。新人接手时,看到报修相关的需求,直接进 repair.routes.js 和 repairController.js,不会迷路。
5.2 登录鉴权用 JWT,还是 Session
这个我直接推荐 JWT,理由和大部分现代 Node 项目一样:后端不需要维护会话状态,负载均衡的时候不用考虑 session 共享问题。JWT 签发用 jsonwebtoken 库,过期时间设置 2 小时,前端每次请求把 token 放在请求头 Authorization: Bearer <token> 里,后端写一个鉴权中间件统一处理:
javascript复制// middlewares/auth.js
const jwt = require('jsonwebtoken');
module.exports = function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization || '';
const token = authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ code: 401, msg: '未登录' });
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.userId = payload.userId;
req.userRole = payload.role;
next();
} catch (e) {
return res.status(401).json({ code: 401, msg: '登录状态失效,请重新登录' });
}
};
然后是角色权限中间件,配合路由使用。宿管端的大部分接口,都要求 role === 1 才能操作。例如“派单”这个动作只有宿管能做,我在路由上挂载:
javascript复制router.post('/assign', authMiddleware, roleMiddleware('admin', 'manager'), repairController.assign);
5.3 文件上传:报修图片的接收与存储
报修单里学生上传的现场照片,是高频功能。这里我建议图片走本地静态目录存储,而不是直接往数据库塞 base64 字符串。用 multer 做文件上传中间件,限制单张大小不超过 5MB,只允许 jpg/png/webp 格式:
javascript复制// middlewares/upload.js
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: function(req, file, cb) {
cb(null, path.join(__dirname, '../uploads/'));
},
filename: function(req, file, cb) {
const ext = path.extname(file.originalname);
const uniqueName = Date.now() + '-' + Math.round(Math.random() * 1e9) + ext;
cb(null, uniqueName);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(file.originalname).toLowerCase())) {
cb(null, true);
} else {
cb(new Error('图片格式不支持'));
}
}
});
图片存储路径写入工单表的 image_urls 字段(JSON 数组),前端通过静态路径直接访问。部署时 Nginx 配置路由把 /uploads 映射到目录,就能正常展示图片。
5.4 关键接口示例:报修单的分页查询
学生端“我的报修记录”和宿管端“全部工单列表”都要用到分页查询。这个接口我写详细一点,是整个系统的出镜率之王:
javascript复制// controllers/repairController.js
async function listOrders(req, res, next) {
try {
const { page = 1, pageSize = 10, status, faultType, building } = req.query;
const offset = (page - 1) * pageSize;
const conditions = [];
const params = [];
// 学生只能看自己的工单
if (req.userRole === 2) {
conditions.push('student_id = ?');
params.push(req.userId);
}
if (status !== undefined && status !== '') {
conditions.push('status = ?');
params.push(Number(status));
}
if (faultType) {
conditions.push('fault_type = ?');
params.push(faultType);
}
if (building) {
conditions.push('d.building = ?');
params.push(building);
}
const whereSql = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const [rows] = await db.query(
`SELECT r.*, d.building, d.room_number, u.real_name AS student_name
FROM repair_order r
LEFT JOIN dormitory d ON r.dormitory_id = d.id
LEFT JOIN user u ON r.student_id = u.id
${whereSql}
ORDER BY r.urgent_flag DESC, r.created_at DESC
LIMIT ? OFFSET ?`,
[...params, Number(pageSize), offset]
);
const [countRows] = await db.query(
`SELECT COUNT(*) AS total FROM repair_order r ${whereSql}`,
params
);
res.json({
code: 200,
data: {
list: rows,
total: countRows[0].total,
page: Number(page),
pageSize: Number(pageSize)
}
});
} catch (e) {
next(e);
}
}
注意排序逻辑:ORDER BY r.urgent_flag DESC, r.created_at DESC。加急工单永远排前面,同优先级按创建时间倒序,新报修的工单选在前面。这个排序规则是宿管阿姨给我的反馈——你按登记时间正序排,早上的旧的还没修,下午的新工单永远压箱底。
6. 前端页面:Vue 项目里我踩过的坑与实现思路
6.1 前端项目结构,从创建那天就规划好
Vue 前端我按典型的后台管理结构搭:
text复制src/
├── api/ # axios 封装 + 各模块请求函数
│ ├── request.js
│ ├── auth.js
│ ├── repair.js
│ └── ...
├── router/ # 路由 + 路由守卫
├── store/ # Pinia 状态管理
├── views/
│ ├── student/ # 学生端页面
│ │ ├── CreateRepair.vue
│ │ └── MyOrders.vue
│ ├── manager/ # 宿管端页面
│ │ ├── OrderDispatch.vue
│ │ ├── OrderList.vue
│ │ └── StatsBoard.vue
│ └── login/Login.vue
├── components/ # 通用组件
│ ├── StatusTag.vue
│ └── UploadImages.vue
└── utils/
路由这里,我用路由守卫做登录校验。这个逻辑很简单:本地没有 token 就直接踢到登录页,有 token 但角色去不了某个页面,也拦截掉。代码实现:
javascript复制// router/index.js
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next('/login');
return;
}
const role = Number(localStorage.getItem('role'));
if (to.meta.roles && !to.meta.roles.includes(role)) {
next('/403');
return;
}
next();
});
6.2 报修表单页面:图片预览和字段校验
学生端最核心的页面是创建报修单。这个页面我用 Element UI 的 el-form 做表单校验,字段包含:故障类型(下拉选择)、故障描述(多行文本)、联系电话、现场图片(多图上传,支持预览)、紧急程度勾选。
这里的细节是“紧急程度”这个字段的设计。直接让学生勾“是否加急”,一定会有学生不管三七二十一全选加急,导致加急失去意义。我的做法是后端做逻辑判断:故障类型为“水管爆裂”“漏电”等安全类问题自动置为加急,其他类型即使学生选择加急,也需要宿管在进行审核时二次确认。把紧急判断权从学生收归到管理人员,比做一个谁都勾的按钮理性得多。
图片上传组件这里有个交互细节容易踩坑:图片上传要区分“已经上传拿到URL的”和“正在上传还没拿到URL的”两种状态。如果没区分,用户快速点击提交时,可能图片还没传完,表单就把空的 URL 提交上去。我的处理方式是,在提交前检查是否有上传中的图片,有的话提示用户等待,或者用 Promise 等待上传完成再提交。
6.3 宿管端工单派发页面:状态筛选与批量操作
宿管端的工作台是我花时间最多的地方。宿管阿姨的使用场景是:上班打开电脑,登录系统,要一眼看到所有还没处理的报修工单,然后逐个安排维修工。
所以我给宿管端做了顶部统计卡片:待接单 N 条、维修中 N 条、已完成待确认 N 条、本月总报修 N 条。下面是带筛选的工单表格,筛选条件包括状态、楼栋、故障类型、日期范围。表格行内操作按状态展示:
- 待接单:显示“派单”按钮,点击弹出维修工选择对话框
- 维修中:显示“催办”按钮,点击会给维修工发送提醒(这里我们做成站内消息)
- 已完成待确认:如果长时间未确认,显示“催确认”按钮
派单对话框的核心是一个维修工候选列表,这里的下拉选项数据来自“当前状态下还没有活或者活不多的维修工”。为了让宿管不用看维修工忙闲表,我用一个接口返回“可用维修工”列表,按当前未完成工单数量升序排:
javascript复制// services/workerService.js
async function getAvailableWorkers() {
const [rows] = await db.query(
`SELECT u.id, u.real_name,
(SELECT COUNT(*) FROM repair_order r
WHERE r.assignee_id = u.id AND r.status IN (1,2)) AS active_orders
FROM user u
WHERE u.role = 3
ORDER BY active_orders ASC, u.id ASC`
);
return rows;
}
这样宿管在派单时,优先看到的是当前空闲的维修工,而不是盲选一个可能手上压了一堆单的师傅。这个小设计很受宿管欢迎,因为它改变了派单的打开方式——从“随便找个师傅”到“按负载分配”。
6.4 学生确认环节:让流程闭环回到学生手上
学生端“我的报修”页面里,工单卡片按状态展示对应的操作按钮。最关键的按钮是“确认完成”。维修工点“完成维修”后,工单状态变成 3(已完成待确认),学生前端会看到弹窗提示“维修已完成,请确认是否解决?”,点击确认后状态变为 4,工单真正闭环;点击“仍有问题”则重新把工单置为 1(待维修),同时生成一条记录给宿管端标记“返修工单”。
这个返修逻辑是后来根据真实反馈加的。修了两次还没修好的情况不是少数,如果没有返修机制,维修工永远觉得自己修的没毛病,学生的怨气又无从发泄。
7. 关键业务流程的完整串联:从学生报修到数据沉淀
前面讲了很多零散模块,这里把一条完整业务线串起来。我拿一个典型场景走一遍,你就能理解这些表和接口是怎么协同的。
7.1 场景:学生发现宿舍灯管闪烁
学生小林在 3 号楼 503 室,晚上回宿舍发现灯管一直闪,于是打开手机端(H5 页面)登录系统,点击“我要报修”。系统通过登录会话拿到他的学生 ID 和宿舍 ID,自动填充楼栋“3号楼”、房间“503”,不用手动输入。小林选择故障类型“家具及电器”,描述“灯管一直闪,怀疑要坏了”,上传一张照片,填写电话,提交。
后端 createRepairOrder 接口接受到请求后,做了三件事:
- 生成唯一工单号
BX + 日期 + 随机数 - 因为故障类型不是水电安全类,紧急级别先按默认值走
- 往
repair_order表插入记录,status = 0
同时宿管端的页面上,待接单的统计数字马上从 2 变成了 3,新工单出现在列表最顶端(按创建时间倒序排列),并且因为 urgent_flag=0,它排在所有加急工单后面。
7.2 场景:宿管派单
次日早上,宿管阿姨打开管理端,看到这个工单。先点开详情,看了学生描述和图片,判断大概是灯管的镇流器坏了,属于师傅老王的擅长范围。点“派单”,弹出的对话框里显示当前负载最低的师傅,老王正好有一单在进行中但快要收尾了,排在列表第二位。宿管选了老王,确认。
后端 assignOrder 做了状态校验(status 必须为 0),然后更新工单为“待维修”,记录 assignee_id=老王ID,并写入一条 repair_log:“宿管阿姨 派单给 老王”。老王的手机端同步刷出来一张新任务单。
7.3 场景:维修工处理与完成
老王接到任务,先去仓库领了一个镇流器,到 3 号楼 503 敲门,修好之后点“开始维修”(状态变 2)再点“完成维修”(状态变 3)。维修内容填了“更换镇流器一个,测试正常”。
这时工单状态是“已完成待确认”。小林的手机端收到一条推送或者打开页面看到状态变化,他确认灯管正常了,点“确认完成”,状态变 4。整条链路闭环。这时系统在统计后台把这条工单的数据“入账”:处理时长为从 created_at 到 confirmed_at 的时长,维修工单量 +1。
7.4 场景:超时工单的预警处理
还有一类场景经常出现:宿管派单后,维修工一直没接单,或者接了单一直不点“开始维修”。这个项目我在后端加了一个定时任务,每小时扫描一次 repair_order 表:
- 状态为 1(待维修)且
assigned_at超过 24 小时未更新:自动在宿管端生成一条“催办提醒” - 状态为 2(维修中)且
assigned_at超过 48 小时未更新:自动在管理端标红,通知维修组长
定时任务用 Node 的 node-cron 库实现,每整点跑一次。这个能力看似不起眼,但它解决了系统里最大的管理痛点:不是没有人干活,而是没有人对“不干活”负责。有预警之后,宿管可以拿着系统页面去找领导反应情况,让“推诿拖延”有数据支撑。
7.5 统计报表:让数据反哺管理
最后是报表板块。宿管端和管理员端都有一个统计页面,我做成三个维度:
- 按楼栋统计:每个楼栋的报修数量、完成率、平均处理时长
- 按故障类型统计:水电类占多少、门窗类占多少、网络类占多少,配一个饼图
- 按时间趋势统计:本月的每日报修量曲线,能看出设备损坏的高峰期
这部分用 ECharts 画图,后端直接出聚合数据。SQL 大概是:
sql复制SELECT
d.building,
COUNT(*) AS total,
SUM(CASE WHEN r.status IN (3,4) THEN 1 ELSE 0 END) AS completed,
AVG(CASE WHEN r.status IN (3,4)
THEN TIMESTAMPDIFF(HOUR, r.created_at, r.confirmed_at)
ELSE NULL END) AS avg_hours
FROM repair_order r
LEFT JOIN dormitory d ON r.dormitory_id = d.id
WHERE r.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY d.building;
报表数据的意义不在于好看,而在于它直接指导了后勤采购预算的分配。如果说某栋楼的报修率远超其他楼,那这栋楼的硬件条件大概率已经老化了,该申请翻修,而不是继续打补丁。带着这份报表去开会,跟凭感觉说话完全不是一个分量。
8. 前后端联调与部署实战:那些文档里不会告诉你的问题
8.1 开发环境跨域问题:Vite 代理配置
前后端分离的项目,最烦的一件事就是开发环境下的跨域。Vite 的 devServer 默认跑在 5173 端口,后端 API 跑在 3000 端口,前端直接 fetch 后端接口一定会被 CORS 拦下来。
有两种解决办法。后端开启 CORS 中间件:
javascript复制const cors = require('cors');
app.use(cors({ origin: 'http://localhost:5173', credentials: true }));
或者前端用代理,把请求转发到后端。我推荐第二种,因为生产环境下 Nginx 本来就要做一层反向代理,开发时用 Vite 代理模拟生产环境,以后再切线上毫无压力。
Vite 的配置:
javascript复制// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
}
我开发的时候统一让前端请求前缀带 /api,代理去掉前缀再转发给后端。这样前后端接口路径天然解耦,后端换路径前端不用改一堆代码。
8.2 部署时前端路由和历史模式(history mode)的问题
Vue Router 默认是 hash 模式,地址栏长这样:http://xxx/#/order/list。这种模式部署最简单,服务器不需要额外配置,但丑且不利于分享链接。做管理系统我一般切到 history 模式,地址变成 http://xxx/order/list。
但 history 模式有个必踩的坑:用户直接访问 http://xxx/order/list 时,如果 Nginx 没做 try_files 配置,服务器会返回 404。 因为 Nginx 拿到的是 /order/list,在服务器文件系统里找不到这个路径对应的资源。
解决办法是在 Nginx 配置里加:
nginx复制location / {
root /var/www/html;
index index.html;
try_files $uri $uri/ /index.html;
}
try_files 的意思是:如果当前路径找不到对应文件,就回退到 /index.html,然后由 Vue Router 接管路由匹配。这一行是 Vue history 模式部署的核心。
8.3 上传图片目录的 Nginx 映射与权限
前端上传的图片存储在后端项目的 uploads/ 目录,但在生产环境,后端一般跑在 Node 进程里,前端静态文件由 Nginx 提供。要让图片 URL 能被访问,Nginx 需要单独配一个 location:
nginx复制location /uploads/ {
alias /var/www/dormitory-server/uploads/;
}
这里有个权限细节很隐蔽:Node 进程写上传目录的权限可能和 Nginx 读目录的权限不一致。我在一次部署时遇到的情况是,图片上传成功但访问 403,排查了半天发现是 uploads 目录的所有者是 root,Nginx 的 worker 进程用 www-data 用户访问不了。解决办法:
bash复制chown -R www-data:www-data /var/www/dormitory-server/uploads/
这类问题其实不算技术难度,但它极其消耗时间。我自己的原则是:部署阶段把用户权限的事想在前头,先统一设置好目录归属,再启动服务。
8.4 环境变量与秘钥管理
这个项目用到的敏感信息包括:JWT 加密密钥、MySQL 数据库密码、(如果可以的话)邮件服务密钥。我的建议是统一放在项目根目录的 .env 文件里,后端启动时用 dotenv 加载:
text复制PORT=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=yourpassword
DB_NAME=dormitory
JWT_SECRET=your-random-secret-string
必须把 .env 加进 .gitignore,绝对不允许提交到代码仓库。 另外建议每次开发环境、测试环境、生产环境的 JWT_SECRET 分开设置,避免一套密钥走天下。一旦泄露,别人可以伪造任意身份的 token,后果不堪设想。
9. 性能优化与安全加固:一个能交给学校的系统,不止是能跑
9.1 数据库异步查询的并发控制
Node.js 数据库操作如果用连接池,并发控制是个需要考虑的问题。默认的 mysql2 连接池配置是 10 个连接,如果一次上来 50 个请求,后 40 个请求会排队等待。我实际项目的配置:
javascript复制const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 20,
queueLimit: 0,
charset: 'utf8mb4'
});
同时每个查询都应该用参数化查询(? 占位符),而不是字符串拼接 SQL。这不仅是为了防注入,更是在长 SQL 里避免转义问题的省心做法。在这个项目里我从来没有用过任何 ORM,直接写 SQL 反而让我能精确控制每个查询的索引走向。
9.2 基础安全设置:密码哈希、登录限流、敏感信息脱敏
再强调一次密码问题。bcryptjs 的哈希计算比较慢,天生对暴力破解有抵抗性。登录接口我加了一个简单的限流器,同一个 IP 10 分钟内最多失败 5 次,超出就锁定一小时。实现不复杂,用一个内存 Map 记失败次数:
javascript复制const loginAttempts = new Map();
function checkLoginAttempt(ip) {
const records = loginAttempts.get(ip) || { count: 0, lockedUntil: 0 };
if (records.lockedUntil > Date.now()) {
return { locked: true, remaining: records.lockedUntil - Date.now() };
}
return { locked: false, count: records.count };
}
内存 Map 方案在单机部署下够用了。如果想做得更精细,可以把计数放到 Redis 里,但宿舍管理系统的访问量根本不需要这个复杂度。
另外接口返回用户信息时,我习惯性脱敏。密码字段永远不返回;学生电话在宿管端显示完整,在维修工端只显示后四位,防止学生隐私被无关人员看到。这类细节在校园系统里特别重要——你面对的是学生的真实隐私,合规意识不能松。
9.3 前端构建优化:首屏加载和时间开销
前端我用 Vite 构建后,发现 Element UI 全量引入导致 vendor chunk 非常大,首屏加载要好几秒。解决办法是引入 Element Plus 的按需自动导入插件:
javascript复制// vite.config.js
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default {
plugins: [
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] })
]
}
开启按需导入后,build 产物体积明显下降。另外我还给路由做了懒加载:
javascript复制const OrderList = () => import('../views/manager/OrderList.vue');
这样用户进入的是登录页还是宿管首页,只加载对应的路由模块,不用一上来就把整个系统页面全部下载。首屏体验从“白屏等三秒”变成了“瞬间出现”。
10. 我给这套系统的横向扩展建议
这个报修系统的第一版跑通之后,能演化的方向还有很多。这里列几个我认为真正有价值的扩展方向,不是那种“啊我们可以上微服务”的空话。
第一个是对接学校的统一身份认证。很多高校已经有 CAS 或者 OAuth 的统一登录体系,学生和教职工不需要单独注册账号。对接之后,用户体验从“再注册一个账号”变成“直接用学号登录”,管理员的账号维护成本也降为零。
第二个是把工单数据接入学校的数据大屏。学校的后勤管理部门喜欢在墙上挂一个大屏,显示实时报修数量、已完成率、维修平均响应时间。这个系统里的统计接口稍加改造,就能以约定的 JSON 格式向大屏推送数据。如果你能把这个做了,这套系统的能见度和价值感会完全不同。
第三个是做一个简单的移动端独立入口。目前很多学校会倾向于用企业微信或钉钉作为内部应用入口。这个 Vue 项目可以做成 H5 页面嵌套进企业微信。只要处理好鉴权对接,学生不用额外装 App,在微信里直接点一个链接就能报修,使用率会明显提升。
第四个是维修备件库存管理。宿舍维修中很多消耗品(灯泡、水龙头垫、门锁芯)有库存概念。当前系统里如果维修工填了“更换镇流器”,系统后台可以把这件物品从备件表里扣掉,仓库管理员看到库存快低于阈值就自动采购。这个功能如果做了,系统的价值会远超一套简单的报修单流转工具,而是真的参与到后勤的日常运营闭环里。
最后:这套系统做到什么程度才算合格
我自己做完这个项目之后,最深的一个感受是:技术只是表面,真正复杂的是让不同角色的人都愿意用起来。
学生觉得有用,是因为他们不用再跑值班室、不用再等口头答复——系统里能看到进度;宿管觉得有用,是因为她不用再拿本子记、不用再整月补 Excel——系统自动统计和催办;维修工觉得有用,是因为他手机上能看到任务列表、能标注状态、操作记录自动留存,干没干活一目了然。
一个业务系统的成功,从来不取决于用了多新的技术,而取决于它是否真的改变了人们的工作方式。Node.js 和 Vue 只是实现手段,真正的产品是围绕着工单状态流转而展开的那套逻辑:让每一条报修都有记录、有人负责、有结果、可以被追溯。
如果你准备自己动手做一个类似的项目,我的建议是:不要急着先写代码。先把角色、流程、状态、权限画清楚,哪怕就是一张白纸上的几个框和箭头也行。想清楚这些,后面写代码是水到渠成的事。反过来,如果上来就开写,写到一半你一定会遇到“这个工单到底应该谁取消”之类的问题,然后回头改表结构,重复劳动不堪其扰。
项目做完之后,你自己验收的时候可以走一遍这个清单:学生能不能顺利用三分钟提交一条报修、宿管能不能在十秒内给一条工单派到人、维修工能不能在手机上不落一件事地完成闭环、统计数据能不能回答“这个月哪个楼栋报修最多、修得最慢”。这四个问题如果都能给出肯定的答案,这套系统就已经比市面上很多空有外表的内部系统强了。
我自己在写这套系统时最大的收获,是学会了把自己代入角色去思考界面和数据。宿管阿姨不会用复杂的筛选器,就把她要的信息直接摆在工作台最上方;维修工懒得看长描述,就把故障类型和楼栋房间号做成大字号标签;学生最烦填表格,就把能自动带出的数据全部自动带出。做系统从来不是做给评委看的,是做给每天打开它的人用的。
