简介:《局域网考试系统终版》面向学校教师、教务管理者及培训机构,用于在局域网内组织在线考试与课堂测验,重点解决出卷耗时、阅卷繁琐和考试环境封闭性不足等问题。系统围绕试题库管理、自定义试卷、客观题自动阅卷、成绩统计与答题数据分析展开,并支持考试时间、难度、题型及防作弊策略等个性化设置,便于教师根据教学进度灵活安排考核。资源包约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.js:
lan-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是常见做法,能减少读 blocking;foreign_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()在题量几千以内够用,题库大了要换成按随机种子抽,方便复现。从那以后我每次改组卷逻辑,都强制先跑一遍“同一学生重复开考”和“两个学生同时交卷”的用例,再让学生碰系统。希望帮到你。
本文还有配套的精品资源,点击获取