简介这是一套面向计算机相关专业学生与Web开发初学者的宿舍预订系统完整源码基于Node.js与Express.js搭建后端配合MySQL与Sequelize完成数据持久化前端采用HTML5、CSS3、Bootstrap4与jQuery构建响应式页面并通过JWT实现登录鉴权与路由保护。项目区分学生与管理员两类角色学生可在线查看房间、自助预订管理员可新增房间、查看住宿分配与预订记录从而减少线下排队和人员接触。资源包共22个文件、约34KB以JavaScript为主含app.js、路由、认证与数据库连接等后端逻辑另有4个HTML页面、CSS样式、package.json依赖清单及README说明还附带了本地HTTPS调试用的证书与密钥文件结构紧凑、便于导入运行与二次开发。目前已有117人学习。读者可从中获取JWT鉴权、Sequelize模型设计、Express路由分层与前端表单交互的完整实现范例适合作为课程设计、毕业设计参考或全栈入门练手项目。1. 从开学季那条长队说起Hostel-Allocation 到底解决了什么每年新生报到那两天宿舍楼下的队伍能拐两个弯辅导员抱着一摞纸质表格挨个问“你叫什么、哪个专业、想住几楼”写完再翻另一本册子找空床位。这套流程最大的问题不是慢是信息不对称——学生不知道自己能选哪间管理员不知道谁已经住进去了。Hostel-Allocation 就是把这个柜台搬到浏览器里学生登录后远程预订房间管理员登录后看到一张“谁住在哪个房间”的实时表。技术栈是 Node.js Express MySQL Sequelize鉴权用 JWT前端 jQuery Bootstrap4 直接由 Express 托管静态文件。它适合两类人一是想找一个“有完整业务闭环、又不是玩具”的 Node 全栈练手项目的人二是真要在小规模场景里落地一套住宿登记系统、需要拿去改的人。2. 技术选型与目录结构Express Sequelize 撑起的三层骨架2.1 为什么这套栈适合宿舍分配场景宿舍分配这类系统的业务复杂度其实很低三张表、两种角色、一个“占位”动作。真正难的地方在两处——一是并发两个学生同时点同一间房的“预订”占用量不能变成 5/4二是权限学生只能看自己的分配记录管理员才能看全量。这两个诉求直接决定了技术选型的方向。选 Express 而不是更重的框架理由很实在这个项目 90% 的接口都是“收一个 JSON、查一次库、返回一个 JSON”路由层薄得几乎没有存在感。Express 的中间件模型刚好能把 JWT 校验、角色判断、错误兜底串成一条链一个app.use就把所有/api/admin/*挡在门外不用在每个控制器里重复写 if。选 Sequelize 而不是手写 SQL理由在关联查询上。管理员看板要的不是allocations表本身而是“学生姓名 学号 楼栋 房间号 入住时间”拼起来的一行。手写的话就是三表 JOIN 加字段别名改一次字段就要同步改三处Sequelize 的include把这个拼装过程声明化了模型里写一次关联查询处直接include: [{ model: Student }, { model: Room }]。选 JWT 而不是 session是因为这套系统天然是前后端分离的形态前端是纯静态的 HTML jQuery由 Express 的express.static直接吐出去没有任何模板渲染。session 需要服务端存状态、需要 cookie 跨请求带上、部署多实例还要考虑共享存储JWT 把{ id, role }塞进 token 里服务端验签即可横向扩容时不用动任何配置。代价是 token 签发后无法主动吊销只能靠短过期时间兜底——这一点在后面避坑章节会细说。前端用 jQuery 而不是 Vue/React看起来“落后”但在这个项目里是合理的页面只有登录、房间列表、管理员看板三个交互就是“拉列表、点按钮、弹提示”引入一套构建工具链的收益远小于成本。Bootstrap4 负责栅格和表格样式jQuery 负责$.ajax和事件委托整个前端零构建改完文件刷新浏览器就生效。MySQL 这边有一个容易被忽略的细节选房要用行锁而行锁只有 InnoDB 支持。MyISAM 只支持表锁两个学生抢不同房间也会互相阻塞。所以建库时必须确认ENGINEInnoDBMySQL 5.7 之后默认就是但如果是接手别人的老库这一点要专门查一遍。2.2 目录怎么分层路由和控制器别混着写项目目录按职责切分不按文件类型切分。很多新手会把所有路由写进一个routes.js业务逻辑直接写在路由回调里写到第三个接口就开始互相引用。这个项目的做法是路由只做“路径到函数”的映射控制器只做“参数校验 调模型 返回”模型只做“表结构和关联声明”。hostel-allocation/ ├── app.js # 入口中间件装配、静态目录、错误兜底 ├── config/ │ └── database.js # 数据库连接配置按环境导出 ├── models/ │ ├── index.js # 实例化 sequelize统一声明关联 │ ├── student.js │ ├── room.js │ └── allocation.js ├── controllers/ │ ├── authController.js # 注册、登录、签发 token │ ├── roomController.js # 房间列表、详情 │ └── allocationController.js # 选房、退宿 ├── routes/ │ ├── auth.js │ ├── rooms.js │ └── admin.js ├── middleware/ │ ├── auth.js # verifyToken / requireRole │ └── errorHandler.js ├── public/ # 前端静态资源Express 直接托管 │ ├── index.html # 登录 注册 │ ├── rooms.html # 学生选房页 │ ├── admin.html # 管理员看板 │ ├── css/ │ └── js/ │ ├── login.js │ ├── rooms.js │ └── admin.js ├── migrations/ ├── seeders/ # 初始房间数据 ├── .env.example └── package.jsonapp.js的装配顺序有讲究中间件是洋葱模型谁先注册谁先执行。静态目录要放在路由之前否则访问/rooms.html会被后面的路由通配符截走错误处理中间件必须放在所有路由之后而且签名必须是四个参数(err, req, res, next)少一个 Express 就不会把它当错误处理器。// app.js const express require(express); const path require(path); const authRoutes require(./routes/auth); const roomRoutes require(./routes/rooms); const adminRoutes require(./routes/admin); const errorHandler require(./middleware/errorHandler); const app express(); app.use(express.json()); // 解析 application/json app.use(express.urlencoded({ extended: false })); // 解析表单提交 app.use(express.static(path.join(__dirname, public))); // 静态资源优先命中 app.use(/api/auth, authRoutes); // 放行注册、登录 app.use(/api/rooms, roomRoutes); // 内部各自挂 verifyToken app.use(/api/admin, adminRoutes); // 内部挂 verifyToken requireRole(admin) app.use(errorHandler); // 四参数签名必须最后注册 const port process.env.PORT || 3000; app.listen(port, () console.log(server on ${port}));这里express.json()和express.urlencoded()两个都要挂因为前端 jQuery 的$.ajax有时候用contentType: application/json发 JSON有时候用默认的表单编码少挂一个就会出现req.body是undefined的情况。express.static放在最前面是为了让rooms.html、css/、js/这类请求直接被文件系统处理掉不进入后面的路由匹配省一次判断。2.3 从零把项目跑起来环境变量与建表脚本落地第一步不是写代码是把数据库连通。很多人卡在这里报错信息往往是ER_ACCESS_DENIED_ERROR或者ECONNREFUSED前者是账号密码不对后者是 MySQL 服务没起或者端口不对。# 初始化并安装依赖 npm init -y npm i express sequelize mysql2 jsonwebtoken bcryptjs dotenv cors npm i -D sequelize-cli nodemon # 建库字符集必须 utf8mb4否则学生姓名里的生僻字会变成问号 mysql -u root -p -e CREATE DATABASE hostel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;mysql2是驱动sequelize是 ORM两个是配套的bcryptjs是纯 JS 实现的密码哈希比原生的bcrypt少一次编译在 Windows 上装起来省事dotenv用来读.env避免把数据库密码硬编码进仓库。.env里至少要放这几项注意JWT_SECRET不要用示例值上线随便一个 32 位随机串都行# .env PORT3000 DB_HOST127.0.0.1 DB_PORT3306 DB_NAMEhostel DB_USERroot DB_PASSyour_password JWT_SECRETchange_this_to_a_random_32_chars JWT_EXPIRES2h连接配置单独抽一个文件因为sequelize-cli做迁移时也读同一份配置两处写死容易出现“应用连 A 库、迁移跑 B 库”的经典翻车// config/database.js require(dotenv).config(); module.exports { development: { username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, host: process.env.DB_HOST || 127.0.0.1, port: Number(process.env.DB_PORT) || 3306, dialect: mysql, timezone: 08:00, // 关键不写会按 UTC 存页面上的入住时间差 8 小时 logging: false, // 关掉 SQL 打印开发时想看得清楚可以改成 console.log define: { underscored: true, // JS 里写 camelCase数据库自动落成 snake_case timestamps: true // 自动维护 created_at / updated_at }, pool: { max: 10, min: 0, acquire: 30000, idle: 10000 } } };timezone: 08:00这一行是血泪经验。Sequelize 默认按 UTC 处理DATETIME写入的new Date()会被转换一次读出来再转换一次如果 MySQL 服务端本身又是 UTC就是双重偏移。表现出来就是“学生明明今天入住看板上显示昨天”。模型实例化和关联声明统一收在models/index.js这是整个项目里最容易被忽略又最容易出错的文件// models/index.js const { Sequelize, DataTypes } require(sequelize); const config require(../config/database)[process.env.NODE_ENV || development]; const sequelize new Sequelize( config.database, config.username, config.password, config ); const Student require(./student)(sequelize, DataTypes); const Room require(./room)(sequelize, DataTypes); const Allocation require(./allocation)(sequelize, DataTypes); // 关联必须在 sync/migration 之前声明否则外键列不会建出来 Student.hasOne(Allocation, { foreignKey: studentId, as: allocation }); Allocation.belongsTo(Student, { foreignKey: studentId, as: student }); Room.hasMany(Allocation, { foreignKey: roomId, as: allocations }); Allocation.belongsTo(Room, { foreignKey: roomId, as: room }); module.exports { sequelize, Sequelize, Student, Room, Allocation };as别名不是可有可无的装饰。include的时候用的就是这个名字include: [{ model: Student, as: student }]别名对不上会报student is not associated to allocation这个报错信息很误导人看着像没定义关联其实是名字写错了。第一次跑用sync快速把表建出来加个初始房间数据// scripts/init-db.js const { sequelize, Room } require(../models); (async () { await sequelize.sync({ force: true }); // 注意force 会删表重建只能本地用 const rows []; for (let floor 1; floor 6; floor) { for (let n 1; n 10; n) { rows.push({ roomNo: ${floor}${String(n).padStart(2, 0)}, building: A, floor, capacity: 4, occupied: 0, genderType: floor 3 ? male : female }); } } await Room.bulkCreate(rows); console.log(已初始化 ${rows.length} 间房); await sequelize.close(); })();bulkCreate一次插入 60 条比循环create快一个数量级因为它会拼成单条INSERT ... VALUES (...), (...)。force: true会先DROP TABLE再建开发阶段表结构改来改去很方便但一旦有真实数据就是灾难所以这个脚本我只放在scripts/目录绝不进npm start的链路。跑起来就是三步node scripts/init-db.js建表 →npm run dev配置成nodemon app.js→ 浏览器打开http://localhost:3000/index.html。3. 数据模型与鉴权三张表撑起学生和管理员两套视图3.1 学生表、房间表、分配记录表的建法三张表的关系很清晰一个学生同时只能有一条有效分配一间房可以被多个学生占用直到occupied追上capacity。这里的关键设计决策是——把“谁住哪”单独抽成allocations表而不是在students表上挂一个room_id字段。原因在于退宿。学生退宿后这条记录不应该消失管理员要能查到“上学期谁住过 302”这是审计需求。如果room_id直接挂在学生表上退宿就是置空历史信息就丢了。抽成独立表之后退宿只是把status从active改成released记录还在房间占用数减一即可。// models/room.js module.exports (sequelize, DataTypes) { const Room sequelize.define(Room, { roomNo: { type: DataTypes.STRING(16), allowNull: false, unique: true, field: room_no }, building: { type: DataTypes.STRING(32), allowNull: false, defaultValue: A }, floor: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1, max: 30 } }, capacity: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 4, validate: { min: 1, max: 8 } }, occupied: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, genderType: { type: DataTypes.ENUM(male, female), allowNull: false, field: gender_type } }, { tableName: rooms, underscored: true, indexes: [ { fields: [building, floor] } // 看板按楼栋楼层筛选时走这个索引 ] }); return Room; };三个字段值得单独说。roomNo加unique这是房间的唯一标识重复插入会直接被数据库拒掉比在应用层查一遍再插更可靠。capacity限定 1 到 8超出的值一律拒绝防止管理员手滑填个 40 导致后面超卖判断失效。genderType用ENUM而不是字符串一是省空间二是非法值在写入时就会报错不用在业务层再写一遍白名单校验。students表的重点是密码字段的命名——叫passwordHash而不是password让每一个读代码的人都知道这里存的不是明文// models/student.js module.exports (sequelize, DataTypes) { const Student sequelize.define(Student, { studentNo: { type: DataTypes.STRING(20), allowNull: false, unique: true, field: student_no }, name: { type: DataTypes.STRING(32), allowNull: false }, email: { type: DataTypes.STRING(64), validate: { isEmail: true } }, passwordHash: { type: DataTypes.STRING(72), // bcrypt 输出固定 60 位留点余量 allowNull: false, field: password_hash }, gender: { type: DataTypes.ENUM(male, female), allowNull: false }, role: { type: DataTypes.ENUM(student, admin), allowNull: false, defaultValue: student } }, { tableName: students, underscored: true, defaultScope: { // 默认查询不带出密码字段避免不小心序列化进响应 attributes: { exclude: [passwordHash] } }, scopes: { // 登录校验时显式带上 withPassword: { attributes: { include: [passwordHash] } } } }); return Student; };defaultScope这一招很实用。不加的话早晚会有一次res.json(student)把密码哈希吐到前端虽然是哈希但也没必要给。加上默认排除之后登录接口用Student.scope(withPassword).findOne(...)显式拿一次其他地方想拿都拿不到。字段设计汇总一下表关键字段约束作用studentsstudent_noUNIQUE, NOT NULL学号登录名天然去重studentspassword_hashNOT NULL, VARCHAR(72)bcrypt 哈希不存明文studentsroleENUM(student,admin)单表双角色省一张权限表roomsroom_noUNIQUE, NOT NULL房间唯一标识roomscapacity / occupiedNOT NULL, 默认 4 / 0满房判断的两个输入roomsgender_typeENUM男女混住校验的基准allocationsstudent_id / room_id外键关联两张主表allocationsstatusENUM(active,released)退宿用软状态不删记录allocations表还有一个隐藏的坑单纯加student_id的唯一索引不行因为学生退宿后再入住会产生第二条active记录吗不会但历史记录会有多条released唯一索引会冲突。常见的解法是加一个可空的active_flag列入住时置 1、退宿时置 NULL唯一索引建在(student_id, active_flag)上——MySQL 的唯一索引允许多个 NULL所以历史记录怎么堆都不冲突而同一个学生的两条active记录active_flag 1必然被卡住。这层数据库兜底比在应用层写findOne判断要可靠得多。3.2 JWT 双角色中间件学生和管理员走不同的门鉴权这一层我一般拆成两个函数而不是一个。verifyToken只负责“这个 token 是不是我们签的、有没有过期”requireRole才负责“这个角色能不能进”。混在一起写的话某天要给管理员加一个“可以访问但只读”的端点就得把这个函数拆开重写。// middleware/auth.js const jwt require(jsonwebtoken); function verifyToken(req, res, next) { const header req.headers.authorization || ; const [scheme, token] header.split( ); if (scheme ! Bearer || !token) { return res.status(401).json({ message: 缺少访问令牌 }); } try { // payload 结构固定为 { id, role }role 只有 student / admin req.user jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { const expired err.name TokenExpiredError; return res.status(401).json({ message: expired ? 登录已过期请重新登录 : 令牌无效 }); } } function requireRole(...roles) { return (req, res, next) { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: 无权限访问该资源 }); } next(); }; } module.exports { verifyToken, requireRole };requireRole用剩余参数...roles而不是单个字符串是为了支持后续加第三种角色比如宿管到时候requireRole(admin, warden)一行就够不用改函数签名。401 和 403 的区分也很关键401 是“你没登录”前端应该跳登录页403 是“你登录了但不够格”前端应该弹提示而不是把用户踢出去。很多项目一律返 401结果学生误点管理员链接就被登出了。签发端在authController里登录成功后把id和role塞进 payload// controllers/authController.js登录部分 const jwt require(jsonwebtoken); const bcrypt require(bcryptjs); const { Student } require(../models); async function login(req, res, next) { try { const { studentNo, password } req.body; if (!studentNo || !password) { return res.status(400).json({ message: 学号和密码不能为空 }); } // scope 把默认排除的 passwordHash 显式取回来 const stu await Student.scope(withPassword) .findOne({ where: { studentNo } }); // 用户不存在和密码错误返回同一句话不给撞库的人提供枚举信息 if (!stu || !(await bcrypt.compare(password, stu.passwordHash))) { return res.status(401).json({ message: 学号或密码错误 }); } const token jwt.sign( { id: stu.id, role: stu.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES || 2h } ); res.json({ token, user: { id: stu.id, name: stu.name, role: stu.role } }); } catch (err) { next(err); } } module.exports { login };jwt.sign的第三个参数expiresIn直接决定 token 生命周期。设太短学生选房选到一半要重登设太长token 泄露的风险窗口就大。住宿分配这种场景 2 小时是合理的折中——够走完整个流程又不是常年有效。payload里只放id和role不放姓名、学号这些因为 JWT 是 base64 编码不是加密任何人都能解开看内容别把它当保险箱。3.3 Sequelize 关联查询管理员看板一条 SQL 拿到全部信息管理员最核心的诉求就一句话“现在谁住在哪儿”。这句话翻译成数据库操作就是以allocations为主表、active为过滤条件、关联出学生和房间信息。findAndCountAll一次返回数据和总数省掉单独发一条count查询// controllers/adminController.js const { Allocation, Student, Room } require(../models); async function listOccupancy(req, res, next) { try { const page Math.max(parseInt(req.query.page, 10) || 1, 1); const size Math.min(parseInt(req.query.size, 10) || 20, 100); const { count, rows } await Allocation.findAndCountAll({ where: { status: active }, include: [ { model: Student, as: student, attributes: [studentNo, name, gender] }, { model: Room, as: room, attributes: [roomNo, building, floor] } ], order: [[allocatedAt, DESC]], limit: size, offset: (page - 1) * size }); res.json({ total: count, page, size, data: rows }); } catch (err) { next(err); } }size上限卡在 100不是为了性能炫技是防止有人手改 URL 传个size999999把内存打满。order按入住时间倒序管理员打开页面看到的是最新的记录符合直觉。attributes里显式列出需要的字段而不是让 Sequelize 全查回来。学生表里排除了密码哈希但email、created_at这些在看板上根本不用多查一列就多一次网络传输尤其findAndCountAll在分页时还要为count再发一条查询。如果看板要加一个“各楼栋当前入住率”的小卡片用sequelize.fn做聚合// 各楼栋入住率统计 const stats await Room.findAll({ attributes: [ building, [sequelize.fn(SUM, sequelize.col(capacity)), totalBeds], [sequelize.fn(SUM, sequelize.col(occupied)), usedBeds] ], group: [building], raw: true // 不要模型包装直接拿普通对象 });raw: true在这里是必须的不加的话返回的是带dataValues的实例数组聚合字段还得.get(totalBeds)才能取到前端处理起来别扭。group只按building分attributes里的其他字段也必须是聚合函数或被group的列这是 SQL 的ONLY_FULL_GROUP_BY规则MySQL 5.7 之后默认开启写错了会直接报错而不是静默给个随机值。4. 核心业务实现一次选房请求从进入到落库的完整链路4.1 选房接口事务加行锁把超卖挡在数据库层整个项目只有这一个地方需要认真写并发。朴素的做法是先查房间、看occupied capacity、再occupied 1、再插入分配记录。问题在于“查”和“改”之间有一个时间窗口两个请求同时进来都读到3/4都判断“还有空位”都执行1结果就是5/4。开学季几百人同时点这个窗口几乎必然被撞上。解法是把“读”和“写”圈进一个事务并在读的时候加行锁// controllers/allocationController.js const { sequelize, Room, Allocation, Student } require(../models); async function allocate(req, res, next) { const studentId req.user.id; // 来自 JWT不接受前端传 const { roomId } req.body; const t await sequelize.transaction(); try { // 1. 加排他锁读房间别的请求在这一行上会等不会读到中间的占用数 const room await Room.findByPk(roomId, { lock: t.LOCK.UPDATE, transaction: t }); if (!room) throw Object.assign(new Error(房间不存在), { status: 404 }); if (room.occupied room.capacity) { throw Object.assign(new Error(该房间已满请换一间), { status: 409 }); } // 2. 性别校验房间是 male 就只能男生选 const stu await Student.findByPk(studentId, { transaction: t }); if (!stu || stu.gender ! room.genderType) { throw Object.assign(new Error(该房间与你的性别信息不匹配), { status: 400 }); } // 3. 写入分配记录 占用数自增两步必须同生共死 await Allocation.create( { studentId, roomId, status: active, allocatedAt: new Date() }, { transaction: t } ); await room.increment(occupied, { by: 1, transaction: t }); await t.commit(); res.status(201).json({ ok: true, roomId }); } catch (err) { await t.rollback(); // 任何一步失败都整体回滚不会留下半条记录 next(err); } }lock: t.LOCK.UPDATE生成的是SELECT ... FOR UPDATEMySQL 会在这一行上加排他锁第二个请求过来会阻塞在findByPk上等第一个事务提交后才继续此时读到的occupied已经是 1 了。这是最直接的串行化手段代价是同一间房的请求排队但不同房间互不影响实际并发度足够。studentId必须从req.user.id取绝不能从前端 body 里读。否则改个请求体就能替别人选房这是设计上最容易犯的低级错误。increment而不是先读再save是因为save会把整个模型所有字段写回如果两个字段同时被改会互相覆盖increment生成的是UPDATE rooms SET occupied occupied 1是原子操作。除了事务前面提到的唯一索引兜底也值得加上。万一哪天有人绕过这个接口直接往库里写或者事务隔离级别被人改动过数据库层还能再挡一次// migrations/xxx-create-allocations.js节选 await queryInterface.createTable(allocations, { id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, student_id: { type: Sequelize.INTEGER, allowNull: false, references: { model: students, key: id } }, room_id: { type: Sequelize.INTEGER, allowNull: false, references: { model: rooms, key: id } }, status: { type: Sequelize.ENUM(active, released), defaultValue: active }, // 入住置 1退宿置 NULL多个 NULL 不冲突一个人只能有一条 active active_flag: { type: Sequelize.TINYINT, allowNull: true, defaultValue: 1 }, allocated_at: { type: Sequelize.DATE, allowNull: false } }); await queryInterface.addIndex(allocations, [student_id, active_flag], { unique: true, name: uk_student_active });这一层索引的价值在于即使业务代码写漏了判断第二个active插入时会直接抛ER_DUP_ENTRY错误被catch住回滚数据依然是干净的。数据库的约束比代码里的 if 更可信因为它不会因为某个新接口忘了写就失效。4.2 管理员看板分页查询与实时占用统计管理员的第二个诉求是“哪栋楼还有空房”。这个查询可以直接落在rooms表上用where条件筛出未满的房间// controllers/roomController.js async function listAvailable(req, res, next) { try { const { building, gender } req.query; const where { // 关键列与列比较要用 sequelize.where sequelize.col [Op.and]: [ sequelize.where( sequelize.col(occupied), { [Op.lt]: sequelize.col(capacity) } ) ] }; if (building) where.building building; if (gender) where.genderType gender; const rooms await Room.findAll({ where, order: [[building, ASC], [floor, ASC], [roomNo, ASC]] }); res.json({ total: rooms.length, data: rooms }); } catch (err) { next(err); } }这里有个 Sequelize 的语法陷阱不能写where: { occupied: { [Op.lt]: sequelize.col(capacity) } }那样会被当成“和字符串capacity比较”或者直接报错。列与列比较必须用sequelize.where(sequelize.col(a), op, sequelize.col(b))包一层。这个写法不熟的话会卡很久报错信息也不直观。排序按楼栋、楼层、房间号三级是因为管理员看列表时的心理模型就是“先找楼、再找层、再看房号”顺序对上了扫一眼就能定位。学生端看到的列表还要额外做一件事——把已经分到房的学生挡在选房页之外。这个判断放在接口里而不是前端// 学生进入选房页前先查一次自己的状态 async function myAllocation(req, res, next) { try { const record await Allocation.findOne({ where: { studentId: req.user.id, status: active }, include: [{ model: Room, as: room, attributes: [roomNo, building, floor] }] }); res.json({ hasRoom: !!record, data: record }); } catch (err) { next(err); } }前端拿到hasRoom: true就不渲染房间列表直接显示“你已分配到 A 栋 302”。这只是体验优化真正的拦截在allocate里的唯一索引和事务判断上——前端可以绕过数据库不能。4.3 jQuery 前端把后端错误原样展示给学生看前端这一层最容易写歪的地方是错误处理。很多人写成$.ajax({...}).done(...)失败分支直接console.log学生点完按钮页面毫无反应以为卡了又点了一次结果触发两次请求。正确的做法是统一在fail里读xhr.responseJSON.message这是后端res.status(4xx).json({ message })约定好的结构// public/js/rooms.js $(function () { const token localStorage.getItem(token); if (!token) { location.href /index.html; return; } const authHeader { Authorization: Bearer token }; function renderRooms(list) { const $tbody $(#roomTable tbody).empty(); if (!list.length) { $tbody.append(trtd colspan5 classtext-center暂无可用房间/td/tr); return; } list.forEach(room { const full room.occupied room.capacity; $tbody.append( tr td${room.roomNo}/td td${room.building}/td td${room.occupied}/${room.capacity}/td td${full ? span classbadge badge-secondary已满/span : span classbadge badge-success可预订/span}/td tdbutton classbtn btn-sm btn-primary js-book style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
企业数字化 ERP 产品动态
相关推荐
Java Web英语词汇量测试系统:Spring Boot+Vue轻量级实现 简介:这是一套基于Java开发的在线英语词汇量测试系统源码,面向Java初学者与毕业设计学生,解决英语词汇能力动态评估与可视化反馈问题,适用于课程设计、Web应用实训及MVC模式实践场景。资源共191个文件,包含23个核心Jav… · 2026/11/5 6:57:33
ST7789V屏幕驱动全解析:SPI三线/四线与GPIO模拟点屏排错 简介:面向嵌入式图形显示开发的 ST7789V 驱动例程,基于 STM32F429 平台,覆盖 SPI 三线、四线与 GPIO 模拟三种通信方式,适合已具备单片机基础、希望在小尺寸 TFT 屏上完成界面开发的工程师与学生。代码围绕初始化与显示操作两条主… · 2026/11/5 6:57:33
Unieap Java简单项目:设备借用登记的事务、并发扣减与验收 简介:Unieap简单小项目是一份面向Java初学者和业务流程开发入门者的示例工程,基于Unieap 3.4框架展示企业级应用的构建思路,适合用来理解工作流引擎、表单设计、数据模型与权限控制等核心组件如何协同。压缩包约82.8MB,文件总数与… · 2026/11/5 6:57:33
ADS122U04与STM32F410RB工业温度采集方案:UART接口ADC实战详解 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 7:35:44
ADS122U04IPWR + PIC18F66K40:PT100/NTC高精度温度采集方案解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 7:35:44
Figma设计稿转LVGL嵌入式UI:自动化链路与工程实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 7:35:44
ADS122U04IPWR+PIC18F56K42实现0.1℃工业温度测量 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 7:35:44
工业温度测量信号链设计:ADS122U04与PIC18F57Q43实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/5 7:35:44
1km人口分布栅格数据处理:口径、裁剪与时间序列分析 去年帮一位做城市扩张研究的朋友处理人口栅格,他拿到一份 2000—2023 年的 1km 精度人口分布栅格数据,第一件事就是把研究区内的像元值全部加总,想直接得到总人口曲线。结果算出来的数比公开统计口径少了将近三成,他一度以为是数据… · 2026/11/5 7:34:44
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38