资讯详情

局域网考试系统终版:断网机房部署与交卷评分实践

📅 2026/10/27 10:36:12 | 华诺云谱 👁 阅读
局域网考试系统终版:断网机房部署与交卷评分实践
简介《局域网考试系统终版》面向学校教师、教务管理者及培训机构用于在局域网内组织在线考试与课堂测验重点解决出卷耗时、阅卷繁琐和考试环境封闭性不足等问题。系统围绕试题库管理、自定义试卷、客观题自动阅卷、成绩统计与答题数据分析展开并支持考试时间、难度、题型及防作弊策略等个性化设置便于教师根据教学进度灵活安排考核。资源包约7.58MB文件总数与类型明细暂未提供下载后可通过安装程序与使用说明完成局域网部署适合需要快速搭建内部考试环境的用户参考。已有207人学习可借此了解局域网考试系统的功能构成、部署方式与教学应用思路为校园考试或课堂测验提供一套较完整的工具方案。1. 局域网考场的第一脚断网机房里也能开考的系统机房临时断外网监考老师抱着 U 盘到处找离线题库学生浏览器却打不开在线考试页这种场景我在项目现场见过不止一次。这套局域网考试系统终版解决的就是这个问题一台普通笔记本或小主机启动服务学生机在同一个局域网里用浏览器登录、抽题、答题、交卷教师端负责组卷、发布考试、看在线状态和导出成绩。它适合课程测验、培训考核、校内机房练习不适合直接搬到公网做高并发 SaaS。整套系统围绕局域网考试的真实约束设计没有外网依赖、答案落库要稳、交卷不能重复、断线要能重连现场部署时少一点玄学多一点可排查的日志。2. 技术栈与目录结构从启动脚本到题库文件的整体地图2.1 一套常见但够用的技术选型这套终版没有把技术栈堆得很重常见做法是 Node.js Express Socket.IO better-sqlite3前端用原生 HTML/CSS/JavaScript 或轻量框架。理由很直接局域网考试系统的核心不是炫技而是把“登录、抽题、保存答案、交卷、评分”这条链路做稳。Express 负责 HTTP 接口和静态页面Socket.IO 负责在线状态和倒计时同步better-sqlite3 用同步方式操作本地数据库省掉连接池和额外服务机房一台机器就能跑。题库导入用 CSV教师把 Excel 另存为 CSV 后走脚本入库减少现场手工录题。身份认证用 JWT密码用 bcryptjs 哈希学生和教师用同一套登录入口但角色不同。这里的选型偏向“好部署、好排查”不是追求框架热度局域网考试现场最怕的是服务起不来、数据库锁死、学生交卷后页面空白所以依赖越少越好。2.2 目录结构与文件职责先看目录。下面这棵结构是我在类似项目里反复用过的版本新增模块时按职责放不要把所有逻辑塞进server.jslan-exam/ ├─ package.json ├─ server.js # 进程入口加载路由、静态目录、Socket.IO ├─ config/ │ └─ db.js # 打开 SQLite、设置 WAL、导出 db 实例 ├─ routes/ │ ├─ auth.js # 登录、当前用户信息 │ ├─ exam.js # 学生抽题、保存答案、交卷 │ └─ admin.js # 教师组卷、发布考试、成绩导出 ├─ services/ │ ├─ paper.js # 试卷生成、题目乱序、选项乱序 │ ├─ grade.js # 客观题自动评分、主观题待批 │ └─ session.js # 考试会话、倒计时、防重复交卷 ├─ data/ │ ├─ exam.db # SQLite 数据库文件 │ └─ questions.csv # 题库导入源文件 ├─ public/ │ ├─ login.html # 登录页 │ ├─ exam.html # 学生答题页 │ ├─ admin.html # 教师管理页 │ └─ assets/ │ ├─ exam.css │ └─ exam.js # 前端倒计时、答案暂存、交卷请求 └─ scripts/ ├─ init-db.js # 建表与初始管理员 └─ import-questions.js # CSV 题库导入这个结构的关键是把“路由”和“服务”分开。routes/exam.js只处理请求参数和响应真正的抽题逻辑放在services/paper.js评分放在services/grade.js。现场改需求时比如加“随机选项顺序”只动paper.js不用在路由里翻半天。config/db.js里建议打开 WAL 模式SQLite 在多人同时写入时表现更稳// config/db.js const Database require(better-sqlite3); const path require(path); const dbPath path.join(__dirname, .., data, exam.db); const db new Database(dbPath); // WAL 模式让读写并发更顺局域网考试多人交卷时少一点锁等待 db.pragma(journal_mode WAL); // 外键约束要显式打开否则试卷题目关联可能变成孤儿数据 db.pragma(foreign_keys ON); module.exports db;逻辑说明dbPath固定在data/exam.db部署时整个目录拷走就能迁移。journal_mode WAL是常见做法能减少读 blockingforeign_keys ON防止删试卷后题目关联残留。参数上db.pragma的第二个参数是值SQLite 的 PRAGMA 有的返回结果有的只设置状态这里只做设置。注意 WAL 模式会多出-wal和-shm文件备份时要一起带走不然数据库可能不完整。2.3 数据库表设计考试会话和答案分开存表设计不要贪多。局域网考试系统终版围绕“人、题、卷、考、答、分”六类数据建表够用且好查-- data/schema.sql 的核心表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN (student,teacher)), class_name TEXT ); CREATE TABLE questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL CHECK(type IN (single,multiple,judge,blank,short)), stem TEXT NOT NULL, options_json TEXT, answer_json TEXT NOT NULL, score REAL NOT NULL DEFAULT 1 ); CREATE TABLE papers ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, duration_minutes INTEGER NOT NULL, status TEXT NOT NULL DEFAULT draft ); CREATE TABLE paper_questions ( paper_id INTEGER NOT NULL, question_id INTEGER NOT NULL, sort_order INTEGER NOT NULL, PRIMARY KEY (paper_id, question_id), FOREIGN KEY (paper_id) REFERENCES papers(id) ON DELETE CASCADE, FOREIGN KEY (question_id) REFERENCES questions(id) ); CREATE TABLE exam_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, paper_id INTEGER NOT NULL, student_id INTEGER NOT NULL, status TEXT NOT NULL DEFAULT ongoing, started_at TEXT NOT NULL, submitted_at TEXT, score REAL, UNIQUE(paper_id, student_id) ); CREATE TABLE answers ( session_id INTEGER NOT NULL, question_id INTEGER NOT NULL, answer_json TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (session_id, question_id), FOREIGN KEY (session_id) REFERENCES exam_sessions(id) ON DELETE CASCADE );逻辑说明exam_sessions用UNIQUE(paper_id, student_id)卡住一个学生同一张卷只能有一条会话这是防重复交卷的第一道门。answers以(session_id, question_id)为主键学生每次保存答案用 UPSERT不会越存越多。参数上duration_minutes存分钟倒计时由服务端算剩余秒数前端只负责显示type用固定枚举评分函数按类型分支。常见坑是把答案直接拼成一个大 JSON 塞进会话表后面按题查错、人工批改都难受拆成answers行更稳。3. 核心链路拆解学生登录、抽题、交卷与自动评分的四段实现3.1 登录与考试会话先确认身份再确认能不能考登录接口不要只发 token 就结束还要把角色和班级带回去前端据此跳转学生页或教师页。下面是一个简化版// routes/auth.js const express require(express); const bcrypt require(bcryptjs); const jwt require(jsonwebtoken); const db require(../config/db); const router express.Router(); const JWT_SECRET process.env.JWT_SECRET || change-me-in-real-deploy; router.post(/login, (req, res) { const { username, password } req.body; const user db.prepare( SELECT id, username, password_hash, role, class_name FROM users WHERE username ? ).get(username); if (!user || !bcrypt.compareSync(password, user.password_hash)) { return res.status(401).json({ message: 用户名或密码错误 }); } const token jwt.sign( { id: user.id, role: user.role, username: user.username }, JWT_SECRET, { expiresIn: 8h } ); res.json({ token, user: { id: user.id, username: user.username, role: user.role, className: user.class_name } }); }); module.exports router;逻辑说明先用username查用户再用 bcrypt 比对哈希避免明文密码落库。JWT 里放id、role、username有效期 8 小时覆盖一场考试足够。参数上JWT_SECRET必须从环境变量注入不要写死在代码里expiresIn不要设太长机房共用机器时减少 token 被复用的风险。登录成功后前端把 token 放sessionStorage答题页每次请求带Authorization: Bearer。考试会话创建放在学生点击“开始考试”时// services/session.js const db require(../config/db); function startSession(paperId, studentId) { const paper db.prepare( SELECT id, duration_minutes FROM papers WHERE id ? AND status published ).get(paperId); if (!paper) throw new Error(试卷未发布); const existing db.prepare( SELECT * FROM exam_sessions WHERE paper_id ? AND student_id ? ).get(paperId, studentId); if (existing) return existing; const now new Date().toISOString(); const info db.prepare( INSERT INTO exam_sessions (paper_id, student_id, status, started_at) VALUES (?, ?, ongoing, ?) ).run(paperId, studentId, now); return db.prepare(SELECT * FROM exam_sessions WHERE id ?).get(info.lastInsertRowid); } module.exports { startSession };逻辑说明先校验试卷状态必须是published防止学生拿到草稿卷。已有会话直接返回保证刷新页面不重复开考。参数上started_at用 ISO 字符串存跨时区显示时前端再格式化duration_minutes在抽题接口里参与计算截止时间。注意这里没有把剩余时间写死服务端每次请求重新算避免学生改系统时间。3.2 抽题与试卷下发乱序可以但答案不能下发抽题接口承担两个责任按试卷取题、按学生会话记录已答答案。题目可以乱序选项也可以乱序但答案字段必须从响应里剔除// routes/exam.js const express require(express); const db require(../config/db); const { startSession } require(../services/session); const { requireAuth } require(../middleware/auth); const router express.Router(); router.post(/start, requireAuth, (req, res) { const { paperId } req.body; const session startSession(paperId, req.user.id); const paper db.prepare( SELECT id, title, duration_minutes FROM papers WHERE id ? ).get(paperId); const rows db.prepare( SELECT q.id, q.type, q.stem, q.options_json, q.score FROM paper_questions pq JOIN questions q ON q.id pq.question_id WHERE pq.paper_id ? ORDER BY pq.sort_order ).all(paperId); const questions rows.map((q) ({ id: q.id, type: q.type, stem: q.stem, options: q.options_json ? JSON.parse(q.options_json) : [], score: q.score })); const answers db.prepare( SELECT question_id, answer_json FROM answers WHERE session_id ? ).all(session.id); res.json({ sessionId: session.id, paper: { id: paper.id, title: paper.title, durationMinutes: paper.duration_minutes }, startedAt: session.started_at, questions, savedAnswers: answers }); }); module.exports router;逻辑说明startSession负责幂等创建会话paper_questions按sort_order取题保证教师组卷顺序。返回给前端的questions不包含answer_json这是硬规则答案只留在服务端。参数上options_json对判断题和填空题可能为空前端按type渲染不同控件savedAnswers用于断线重连后回填。常见做法是把题目顺序和选项顺序在组卷时固化考试中不再变避免学生刷新后题号跳变引发争议。3.3 交卷与评分用事务把“交卷”和“写分”绑在一起交卷是最容易出问题的地方。下面这段把状态更新、答案写入、客观题评分放在一个事务里并用status ongoing做乐观锁// services/grade.js const db require(../config/db); function gradeObjective(question, answer) { if (answer null || answer undefined) return 0; const correct JSON.parse(question.answer_json); if (question.type single || question.type judge) { return String(answer) String(correct) ? question.score : 0; } if (question.type multiple) { const a Array.isArray(answer) ? [...answer].sort() : []; const c Array.isArray(correct) ? [...correct].sort() : []; return JSON.stringify(a) JSON.stringify(c) ? question.score : 0; } if (question.type blank) { const norm (v) String(v).trim().toLowerCase(); return norm(answer) norm(correct) ? question.score : 0; } return null; // 简答题待人工批改 } function submitSession(sessionId, studentId) { const tx db.transaction(() { const session db.prepare( SELECT * FROM exam_sessions WHERE id ? AND student_id ? AND status ongoing ).get(sessionId, studentId); if (!session) throw new Error(会话不存在或已交卷); const rows db.prepare( SELECT q.id, q.type, q.answer_json, q.score, a.answer_json AS student_answer FROM paper_questions pq JOIN questions q ON q.id pq.question_id LEFT JOIN answers a ON a.question_id q.id AND a.session_id ? WHERE pq.paper_id ? ).all(sessionId, session.paper_id); let total 0; let hasShort false; for (const row of rows) { const answer row.student_answer ? JSON.parse(row.student_answer) : null; const score gradeObjective(row, answer); if (score null) { hasShort true; continue; } total score; } db.prepare( UPDATE exam_sessions SET status submitted, submitted_at ?, score ? WHERE id ? AND status ongoing ).run(new Date().toISOString(), hasShort ? null : total, sessionId); return { total, hasShort }; }); return tx(); } module.exports { submitSession };逻辑说明db.transaction保证交卷状态和分数一起提交中途报错会回滚。status ongoing条件让重复交卷第二次查不到会话直接报错避免分数被覆盖。gradeObjective对单选、判断做字符串比较多选先排序再比 JSON填空做trim和大小写归一。参数上hasShort为真时score先写null表示待人工批改如果全是客观题直接写总分。注意多选答案必须统一成数组前端传字符串会翻车接口层最好做一次类型校验。3.4 Socket.IO 实时状态在线人数和倒计时同步局域网考试不需要复杂消息队列Socket.IO 足够。教师端订阅exam:status学生端定时上报心跳// server.js 片段 const http require(http); const express require(express); const { Server } require(socket.io); const app express(); const server http.createServer(app); const io new Server(server, { cors: { origin: * } }); const online new Map(); // sessionId - lastSeen io.on(connection, (socket) { socket.on(student:heartbeat, ({ sessionId }) { online.set(sessionId, Date.now()); socket.join(session:${sessionId}); }); socket.on(teacher:watch, ({ paperId }) { socket.join(paper:${paperId}); }); }); setInterval(() { const now Date.now(); for (const [sessionId, lastSeen] of online.entries()) { if (now - lastSeen 30000) { online.delete(sessionId); io.emit(student:offline, { sessionId }); } } }, 10000); server.listen(3000, 0.0.0.0);逻辑说明onlineMap 记录会话最后一次心跳30 秒没上报就判定离线10 秒扫一次。socket.join把学生和教师分到不同房间后续发消息按房间推。参数上server.listen的0.0.0.0是局域网访问的关键绑127.0.0.1会导致同网段其他机器打不开。心跳间隔建议 10 到 15 秒太频繁增加压力太慢离线判定迟钝。现场翻车最多的是服务端绑错地址学生端一直转圈。4. 部署与运行从零把服务端、数据库和前端跑起来4.1 环境准备与依赖安装准备一台能装 Node.js 的机器Windows 或 Linux 都行建议 Node.js 18 以上。先建目录再初始化依赖mkdir lan-exam cd lan-exam npm init -y npm install express better-sqlite3 socket.io bcryptjs jsonwebtoken csv-parse参数说明better-sqlite3是原生模块Windows 上如果安装失败优先检查 Node 版本和构建工具机房离线环境可以提前在有网机器上npm pack或整包拷贝node_modules。csv-parse只用于题库导入运行期不需要。安装完成后package.json里补一个启动脚本{ scripts: { init: node scripts/init-db.js, import: node scripts/import-questions.js, start: node server.js } }逻辑说明把初始化、导入、启动拆成三个命令现场按顺序执行出错时容易定位是哪一步。参数上npm start默认读server.js如果改了入口文件名要同步改这里。4.2 初始化数据库与导入题库初始化脚本负责建表并插入一个教师账号。密码不要用明文脚本里用 bcrypt 生成// scripts/init-db.js const fs require(fs); const path require(path); const bcrypt require(bcryptjs); const db require(../config/db); const schema fs.readFileSync(path.join(__dirname, .., data, schema.sql), utf8); db.exec(schema); const exists db.prepare(SELECT id FROM users WHERE username teacher).get(); if (!exists) { db.prepare( INSERT INTO users (username, password_hash, role) VALUES (?, ?, teacher) ).run(teacher, bcrypt.hashSync(ChangeMe123, 10)); } console.log(数据库初始化完成);逻辑说明db.exec(schema)执行建表 SQL重复执行会报错所以初始化只跑一次。bcrypt.hashSync第二个参数是 salt 轮数10 是常见值。参数上默认教师账号teacher和初始密码只用于第一次登录部署后必须改。题库 CSV 建议列头固定为type,stem,options_json,answer_json,score导入脚本逐行解析并插入questions表。常见坑是 CSV 里有逗号和换行必须用标准 CSV 解析器不要手写split(,)。4.3 启动服务与多端联调启动顺序初始化数据库、导入题库、启动服务。服务启动后先在本机开三个浏览器标签分别登录教师和学生走一遍“组卷、发布、开考、答题、交卷”npm run init npm run import npm start本机访问http://127.0.0.1:3000/login.html。教师登录后创建试卷选择题目、设置时长、发布学生登录后开始考试。联调时重点看三件事题目是否按顺序出现、答案暂存后刷新是否回填、交卷后成绩是否落库。参数上duration_minutes先设 5 分钟做短测倒计时归零后前端自动交卷服务端也要再校验一次截止时间不能只信前端。4.4 局域网访问与防火墙学生机访问要用服务端机器的局域网 IP不是127.0.0.1。在服务端执行ipconfig或ip addr查到地址比如192.168.1.50学生浏览器打开http://192.168.1.50:3000/login.html。如果打不开按这个顺序排查服务是否监听0.0.0.0、防火墙是否放行 3000 端口、学生机和服务端是否同一网段、路由器是否开了 AP 隔离。参数上端口可以改成 80 或 8080但改完要同步改前端请求地址。局域网考试部署最怕“老师机自己能开、学生机全白屏”提前一天用两台机器互访一次比现场救火强。5. 现场排查清单局域网考试里最容易翻车的五类问题5.1 学生端页面打不开先查监听地址再查防火墙现象教师机访问正常学生机浏览器一直转圈或提示无法连接。原因通常不是代码而是服务只监听了127.0.0.1或者系统防火墙没有放行端口。解决把server.listen(3000, 0.0.0.0)写死Windows 防火墙新建入站规则放行 TCP 3000再用ping和telnet验证网络层。注意有些机房交换机开了端口隔离学生机之间不能互访但学生到教师机需要通。5.2 交卷时数据重复UNIQUE 约束和状态锁要一起用现象学生点两次交卷成绩表出现两条记录或者分数被覆盖成 0。原因前端按钮没禁用只是表象服务端没有幂等控制才是根因。解决exam_sessions加UNIQUE(paper_id, student_id)交卷 SQL 带WHERE status ongoing检查changes是否为 1前端交卷后立即禁用按钮并跳转结果页。血泪经验是只在后端做查询再插入两个请求并发时仍可能穿透唯一约束和事务缺一不可。5.3 自动评分结果不一致多选答案类型要统一现象同一道多选题有的学生判对有的判错人工看答案明明一样。原因前端有时传数组[A,C]有时传字符串A,C评分函数按 JSON 比较就翻车。解决接口层统一把多选答案转成数组并去重排序评分前再做一次类型校验题库里的answer_json也统一存数组。参数上判断题统一存true/false字符串避免布尔和字符串混用。5.4 断电或断网恢复答案暂存比自动交卷更救命现象考场突然断电恢复后学生答题页空白之前答案全丢。原因答案只存在前端内存或者只在交卷时一次性提交。解决每答一题调用保存接口answers表用 UPSERT前端用localStorage做一层草稿缓存恢复后先拉服务端答案再和服务端比对。注意localStorage不能存正确答案只能存学生作答。倒计时以服务端started_at为准学生改本机时间不影响。5.5 并发交卷时数据库锁等待WAL 不是万能药现象结束前最后两分钟学生集中交卷接口偶发SQLITE_BUSY。原因SQLite 写入串行长事务或频繁小写入会放大锁等待。解决交卷事务里只做必要的查询和更新不要把导出成绩、发通知塞进同一个事务打开 WAL设置busy_timeout例如db.pragma(busy_timeout 5000)。如果规模到几百人同时交卷提前分批或换 PostgreSQL别硬扛。这个坑我在小规模测验里踩过日志里只看到超时查半天才发现是事务里夹了慢查询。6. 二次开发随机组卷、防作弊与多班级管理的改造落点这套局域网考试系统终版的二次开发优先动三个地方投入产出比最高。第一随机组卷。现在paper_questions是固定题目改成按题型和知识点抽题给questions加knowledge_point和difficulty字段组卷时按规则SELECT ... ORDER BY RANDOM() LIMIT n把抽中的题写入paper_questions。注意同一张卷内去重抽完再落库别在渲染时临时抽否则学生刷新题号会变。第二防作弊。局域网考试不追求绝对防住追求“有记录、可追溯”。常见做法是前端监听visibilitychange和blur切屏超过 3 次写一条exam_events表服务端记录登录 IP、交卷时间、答案修改次数。教师端加一个事件列表考试结束后按会话查看。不要在前端做“禁止右键”这种容易被绕过的拦截记录比拦截更有价值。第三多班级管理。给users.class_name建索引试卷发布时增加class_scope字段学生开始考试时校验班级是否在范围内。成绩导出按班级聚合SQL 用GROUP BY class_name。如果班级多教师端加一个班级下拉筛选别把所有学生塞进一张大表。改造后的验证方法很简单准备 10 个学生账号、2 张试卷、1 个教师账号跑一遍“随机组卷发布、学生切屏、断线重连、交卷、导出成绩”。看三张表exam_sessions状态是否只变一次、answers是否有重复行、exam_events是否记录到切屏。代码上每次改评分逻辑先用固定题库跑单元测试别直接上现场。// 随机组卷的核心片段按题型抽题并去重 function buildRandomPaper(paperId, rules) { const picked new Set(); const insert db.prepare( INSERT INTO paper_questions (paper_id, question_id, sort_order) VALUES (?, ?, ?) ); let order 0; for (const rule of rules) { const rows db.prepare( SELECT id FROM questions WHERE type ? ORDER BY RANDOM() LIMIT ? ).all(rule.type, rule.count); for (const row of rows) { if (picked.has(row.id)) continue; picked.add(row.id); insert.run(paperId, row.id, order); } } }逻辑说明rules是[{ type: single, count: 20 }]这样的数组按题型抽题。picked防止同一题重复进入试卷sort_order决定展示顺序。参数上ORDER BY RANDOM()在题量几千以内够用题库大了要换成按随机种子抽方便复现。从那以后我每次改组卷逻辑都强制先跑一遍“同一学生重复开考”和“两个学生同时交卷”的用例再让学生碰系统。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑