From 4e6b0d5cb27fb3d09a01a2fd654f0c6fe7aa29cf Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 19:02:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=83=E7=BD=AE=E4=BD=9C=E4=B8=9A=E3=80=81?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E4=B8=8E=E8=87=AA=E5=8A=A8=E8=AF=84=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项); 起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照 - 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交 - 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示 - 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分 - 串口列表为空时提示在 Load 或 Click 事件里填充 - 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/设计方案.md | 4 +- server/src/app.ts | 120 ++++++++++++++- server/src/assignments.ts | 203 +++++++++++++++++++++++++ server/src/db.ts | 33 +++- server/src/grading/grader.ts | 8 +- server/src/grading/queue.ts | 70 +++++++++ server/src/index.ts | 3 +- server/src/run-session.ts | 2 +- server/src/samples.ts | 8 + server/test/api.test.ts | 103 ++++++++++++- shared/grading.ts | 37 +++++ web/src/Root.vue | 3 +- web/src/designer/templates.test.ts | 35 ++++- web/src/designer/templates.ts | 29 +++- web/src/grading/GradeResultView.vue | 109 +++++++++++++ web/src/pages/AssignmentsPage.vue | 111 ++++++++++++++ web/src/pages/TeacherPage.vue | 37 +++-- web/src/pages/WorkbenchPage.vue | 167 +++++++++++++++++--- web/src/router.ts | 4 +- web/src/teacher/AssignmentDialog.vue | 211 ++++++++++++++++++++++++++ web/src/teacher/AssignmentsPanel.vue | 111 ++++++++++++++ web/src/teacher/SubmissionsDialog.vue | 79 ++++++++++ 23 files changed, 1439 insertions(+), 50 deletions(-) create mode 100644 server/src/assignments.ts create mode 100644 server/src/grading/queue.ts create mode 100644 web/src/grading/GradeResultView.vue create mode 100644 web/src/pages/AssignmentsPage.vue create mode 100644 web/src/teacher/AssignmentDialog.vue create mode 100644 web/src/teacher/AssignmentsPanel.vue create mode 100644 web/src/teacher/SubmissionsDialog.vue diff --git a/README.md b/README.md index 995ac19..c3636d9 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 -- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上。 +- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分、老师查看提交情况。 尚未完成: diff --git a/docs/设计方案.md b/docs/设计方案.md index 68af370..df66f19 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -234,7 +234,7 @@ IOT/ flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)
教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P1["阶段 1 · MVP(进行中,约 3 个月)
虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔
教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔
部署(沙箱、Docker Compose、GitHub Actions)✔"] - P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔
布置与提交作业、自动评测、老师看板、成绩导出;一个班试用"] + P2["阶段 2 · 教学闭环(进行中,约 2 个月)
账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔
老师看板(看代码、运行、老师评分)、成绩导出;一个班试用"] P3["阶段 3 · 扩展(下学期)
教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)
校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 @@ -271,6 +271,8 @@ flowchart TB **第 2 阶段进展**: - 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。 +- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。 +- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。 ## 技术风险与待确认事项 diff --git a/server/src/app.ts b/server/src/app.ts index f198080..2050f0b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,7 +3,21 @@ import type { Database } from 'bun:sqlite' import { Hono, type Context } from 'hono' import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth' import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes' -import { listSamples, loadSample } from './samples' +import type { Scoring, StartMode } from '@iot/shared' +import { + assignmentTemplate, + classSubmissions, + createAssignment, + createSubmission, + deleteAssignment, + getAssignment, + latestSubmission, + listAssignments, + updateAssignment, +} from './assignments' +import { parseChecks } from './grading/checks' +import type { GradingQueue } from './grading/queue' +import { listSamples, loadChecks, loadSample } from './samples' import { serveStatic } from './static' import { deleteWork, getWork, saveWork, validFiles } from './works' @@ -22,7 +36,7 @@ function clientIp(c: AppContext) { /** 读取 JSON 请求体,格式不对时返回空对象 */ const body = (c: AppContext) => c.req.json>().catch(() => ({}) as Partial) -export function createApp(db: Database) { +export function createApp(db: Database, gradingQueue: GradingQueue) { const app = new Hono<{ Bindings: Env; Variables: { user: User } }>() app.post('/api/login', async (c) => { @@ -82,14 +96,62 @@ export function createApp(db: Database) { return c.json({}) }) + // ---- 作业(学生) ---- + /** 当前用户能不能看这个作业:老师都能看,学生只能看自己班的 */ + const id = (c: AppContext) => Number(c.req.param('id')) + const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result)) + + const visibleAssignment = (c: AppContext) => { + const assignment = getAssignment(db, id(c)) + const user = c.get('user') + return assignment && (user.role === 'teacher' || assignment.classId === user.classId) ? assignment : null + } + + // 自己班的作业,带上自己最近一次提交的状态 + app.get('/api/assignments', (c) => { + const user = c.get('user') + if (user.classId === null) return c.json([]) + return c.json( + listAssignments(db, user.classId).map((a) => { + const latest = latestSubmission(db, a.id, user.id) + return { ...a, latest: latest && { ...latest, result: null } } + }), + ) + }) + + app.get('/api/assignments/:id', (c) => { + const assignment = visibleAssignment(c) + if (!assignment) return c.json({ message: '作业不存在' }, 404) + return c.json({ assignment, template: assignmentTemplate(db, assignment.id), latest: latestSubmission(db, assignment.id, c.get('user').id) }) + }) + + app.post('/api/assignments/:id/submit', async (c) => { + const assignment = visibleAssignment(c) + if (!assignment) return c.json({ message: '作业不存在' }, 404) + const user = c.get('user') + if (user.role === 'student' && assignment.deadline !== null && Date.now() > assignment.deadline) { + return c.json({ message: '已经过了截止时间,不能再提交' }, 400) + } + const { files } = await body<{ files: unknown }>(c) + if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400) + const submissionId = createSubmission(db, assignment.id, user.id, files) + if (submissionId === null) return c.json({ message: '上一次提交还在评分,请等评分结束再提交' }, 409) + gradingQueue.enqueue(submissionId) + return c.json(latestSubmission(db, assignment.id, user.id)) + }) + + app.get('/api/assignments/:id/submission', (c) => { + const assignment = visibleAssignment(c) + if (!assignment) return c.json({ message: '作业不存在' }, 404) + return c.json(latestSubmission(db, assignment.id, c.get('user').id)) + }) + // ---- 老师 ---- app.use('/api/teacher/*', async (c, next) => { if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403) await next() }) - const id = (c: AppContext) => Number(c.req.param('id')) - const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result)) app.get('/api/teacher/classes', (c) => c.json(listClasses(db))) @@ -127,6 +189,56 @@ export function createApp(db: Database) { return c.json({}) }) + // 示例的自动评分项名称(布置作业时设置分值用) + app.get('/api/teacher/samples/:id/checks', async (c) => { + const checks = await loadChecks(c.req.param('id')) + if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 404) + return c.json(parseChecks(checks).map((check) => check.title)) + }) + + // 班级的作业,带上提交人数 + app.get('/api/teacher/classes/:id/assignments', (c) => + c.json( + listAssignments(db, id(c)).map((a) => { + const students = classSubmissions(db, a.id) + return { ...a, studentCount: students.length, submittedCount: students.filter((s) => s.status).length } + }), + ), + ) + + app.post('/api/teacher/assignments', async (c) => { + const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null }>(c) + if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404) + const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null + if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400) + if (a.startMode !== 'blank' && a.startMode !== 'ui') return c.json({ message: '起点只能是空白工程或界面已做好' }, 400) + if (!validFiles(a.template)) return c.json({ message: '起点工程格式不对' }, 400) + const result = createAssignment(db, { + classId: a.classId!, + sampleId: a.sampleId!, + title: String(a.title ?? ''), + startMode: a.startMode, + template: a.template, + checks, + scoring: a.scoring as Scoring, + deadline: typeof a.deadline === 'number' ? a.deadline : null, + }) + return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result) + }) + + app.patch('/api/teacher/assignments/:id', async (c) => { + const a = await body<{ title: string; deadline: number | null; scoring: Scoring }>(c) + return reply(c, updateAssignment(db, id(c), { title: String(a.title ?? ''), deadline: typeof a.deadline === 'number' ? a.deadline : null, scoring: a.scoring as Scoring })) + }) + + app.delete('/api/teacher/assignments/:id', (c) => { + deleteAssignment(db, id(c)) + return c.json({}) + }) + + // 全班每个学生最近一次提交的情况 + app.get('/api/teacher/assignments/:id/submissions', (c) => c.json(classSubmissions(db, id(c)))) + app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404)) // 打包好的网页(部署时);开发时网页由 Vite 提供 diff --git a/server/src/assignments.ts b/server/src/assignments.ts new file mode 100644 index 0000000..c9398d9 --- /dev/null +++ b/server/src/assignments.ts @@ -0,0 +1,203 @@ +// 作业:老师把一个实验布置给一个班,学生提交后自动评分。 +// 布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。 +import type { Database } from 'bun:sqlite' +import type { Assignment, GradeResult, Scoring, SourceFile, StartMode, Submission } from '@iot/shared' +import { parseChecks } from './grading/checks' + +interface AssignmentRow { + id: number + class_id: number + class_name: string + sample_id: string + title: string + start_mode: StartMode + checks: string + scoring: string + deadline: number | null + created_at: number +} + +const SELECT_ASSIGNMENT = ` + SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at + FROM assignments a JOIN classes c ON c.id = a.class_id` + +function toAssignment(row: AssignmentRow): Assignment { + return { + id: row.id, + classId: row.class_id, + className: row.class_name, + sampleId: row.sample_id, + title: row.title, + startMode: row.start_mode, + deadline: row.deadline, + createdAt: row.created_at, + checkTitles: parseChecks(row.checks).map((c) => c.title), + scoring: JSON.parse(row.scoring) as Scoring, + } +} + +export function listAssignments(db: Database, classId: number): Assignment[] { + return db.query(`${SELECT_ASSIGNMENT} WHERE a.class_id = ? ORDER BY a.created_at DESC`).all(classId).map(toAssignment) +} + +export function getAssignment(db: Database, id: number): Assignment | null { + const row = db.query(`${SELECT_ASSIGNMENT} WHERE a.id = ?`).get(id) + return row ? toAssignment(row) : null +} + +/** 评分规则的快照(评分时用) */ +export function assignmentChecks(db: Database, id: number) { + const row = db.query<{ checks: string }, [number]>('SELECT checks FROM assignments WHERE id = ?').get(id) + return row ? parseChecks(row.checks) : null +} + +export function assignmentTemplate(db: Database, id: number): SourceFile[] | null { + const row = db.query<{ template: string }, [number]>('SELECT template FROM assignments WHERE id = ?').get(id) + return row ? (JSON.parse(row.template) as SourceFile[]) : null +} + +/** 检查分值:自动评分项个数要和规则一致,分值是不小于 0 的数,老师评分项要有名称 */ +function scoringError(scoring: Scoring, checkCount: number): string | null { + if (!Array.isArray(scoring?.auto) || scoring.auto.length !== checkCount) return `自动评分项应有 ${checkCount} 项` + if (!Array.isArray(scoring.manual)) return '老师评分项格式不对' + const valid = (p: unknown) => typeof p === 'number' && Number.isFinite(p) && p >= 0 + if (!scoring.auto.every(valid) || !scoring.manual.every((m) => valid(m?.points))) return '分值要填不小于 0 的数' + if (scoring.manual.some((m) => typeof m.title !== 'string' || !m.title.trim())) return '老师评分项要填名称' + return null +} + +export interface NewAssignment { + classId: number + sampleId: string + title: string + startMode: StartMode + template: SourceFile[] + /** checks.yaml 的原文 */ + checks: string + scoring: Scoring + deadline: number | null +} + +export function createAssignment(db: Database, a: NewAssignment): { id: number } | { error: string } { + if (!a.title.trim()) return { error: '作业名称不能为空' } + const error = scoringError(a.scoring, parseChecks(a.checks).length) + if (error) return { error } + const result = db + .query( + `INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now()) + return { id: Number(result.lastInsertRowid) } +} + +/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */ +export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } { + const checks = assignmentChecks(db, id) + if (!checks) return { error: '作业不存在' } + if (!changes.title.trim()) return { error: '作业名称不能为空' } + const error = scoringError(changes.scoring, checks.length) + if (error) return { error } + db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run( + changes.title.trim(), + changes.deadline, + JSON.stringify(changes.scoring), + id, + ) + // 分值变了,已评分的提交重新算分 + for (const s of db.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`).all(id)) { + db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id) + } + return {} +} + +export function deleteAssignment(db: Database, id: number) { + db.query('DELETE FROM assignments WHERE id = ?').run(id) +} + +/** 通过的自动评分项的分值之和 */ +export function autoScore(result: GradeResult, scoring: Scoring) { + return result.results.reduce((sum, r, i) => sum + (r.passed ? (scoring.auto[i] ?? 0) : 0), 0) +} + +// ---------- 提交 ---------- + +interface SubmissionRow { + id: number + status: Submission['status'] + result: string | null + auto_score: number | null + created_at: number +} + +function toSubmission(db: Database, row: SubmissionRow): Submission { + const submission: Submission = { + id: row.id, + status: row.status, + result: row.result ? (JSON.parse(row.result) as GradeResult) : null, + autoScore: row.auto_score, + createdAt: row.created_at, + } + if (row.status === 'pending') { + submission.queueAhead = db.query<{ n: number }, [number]>(`SELECT COUNT(*) AS n FROM submissions WHERE status = 'pending' AND id < ?`).get(row.id)!.n + } + return submission +} + +/** 某个学生在某个作业上最近一次提交 */ +export function latestSubmission(db: Database, assignmentId: number, userId: number): Submission | null { + const row = db + .query( + 'SELECT id, status, result, auto_score, created_at FROM submissions WHERE assignment_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', + ) + .get(assignmentId, userId) + return row ? toSubmission(db, row) : null +} + +/** 新建一份等待评分的提交;同一个人上一份还在评分时返回 null */ +export function createSubmission(db: Database, assignmentId: number, userId: number, files: SourceFile[]): number | null { + if (latestSubmission(db, assignmentId, userId)?.status === 'pending') return null + const result = db + .query(`INSERT INTO submissions (assignment_id, user_id, files, status, created_at) VALUES (?, ?, ?, 'pending', ?)`) + .run(assignmentId, userId, JSON.stringify(files), Date.now()) + return Number(result.lastInsertRowid) +} + +export function pendingSubmissionIds(db: Database): number[] { + return db.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`).all().map((r) => r.id) +} + +export function submissionForGrading(db: Database, id: number) { + const row = db.query<{ assignment_id: number; files: string }, [number]>('SELECT assignment_id, files FROM submissions WHERE id = ?').get(id) + return row ? { assignmentId: row.assignment_id, files: JSON.parse(row.files) as SourceFile[] } : null +} + +export function saveGrade(db: Database, id: number, result: GradeResult | null) { + const row = db.query<{ scoring: string }, [number]>('SELECT a.scoring FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?').get(id) + if (!row) return + if (!result) { + db.query(`UPDATE submissions SET status = 'error', graded_at = ? WHERE id = ?`).run(Date.now(), id) + return + } + db.query(`UPDATE submissions SET status = 'done', result = ?, auto_score = ?, graded_at = ? WHERE id = ?`).run( + JSON.stringify(result), + autoScore(result, JSON.parse(row.scoring) as Scoring), + Date.now(), + id, + ) +} + +/** 每个学生最近一次提交的状态(老师看提交情况) */ +export function classSubmissions(db: Database, assignmentId: number) { + return db + .query<{ user_id: number; username: string; name: string; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number }, [number]>( + `SELECT u.id AS user_id, u.username, u.name, s.status, s.auto_score, s.created_at, + (SELECT COUNT(*) FROM submissions x WHERE x.assignment_id = ?1 AND x.user_id = u.id) AS count + FROM users u + JOIN assignments a ON a.id = ?1 AND u.class_id = a.class_id AND u.role = 'student' + LEFT JOIN submissions s ON s.id = (SELECT MAX(id) FROM submissions WHERE assignment_id = ?1 AND user_id = u.id) + ORDER BY u.username`, + ) + .all(assignmentId) + .map((r) => ({ userId: r.user_id, username: r.username, name: r.name, status: r.status, autoScore: r.auto_score, submittedAt: r.created_at, count: r.count })) +} diff --git a/server/src/db.ts b/server/src/db.ts index fc96cef..9fa2724 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -1,4 +1,4 @@ -// SQLite 数据库:账号、班级、学生作品、平台设置(如 JWT 签名密钥)。 +// SQLite 数据库:账号、班级、学生作品、作业与提交、平台设置(如 JWT 签名密钥)。 // 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。 import { Database } from 'bun:sqlite' import { mkdirSync } from 'node:fs' @@ -54,6 +54,37 @@ const MIGRATIONS = [ value TEXT NOT NULL ); `, + // 作业:布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。 + // scoring 是 JSON:{ auto: 每个自动评分项的分值(按规则顺序), manual: [{ title, points }] 老师评分项 } + ` + CREATE TABLE assignments ( + id INTEGER PRIMARY KEY, + class_id INTEGER NOT NULL REFERENCES classes (id) ON DELETE CASCADE, + sample_id TEXT NOT NULL, + title TEXT NOT NULL, + start_mode TEXT NOT NULL CHECK (start_mode IN ('blank', 'ui')), + template TEXT NOT NULL, + checks TEXT NOT NULL, + scoring TEXT NOT NULL, + deadline INTEGER, + created_at INTEGER NOT NULL + ); + CREATE INDEX assignments_class ON assignments (class_id); + -- 提交:status 为 pending(排队评分中)、done(已评分)或 error(评分出错);result 是自动评分结果 JSON + CREATE TABLE submissions ( + id INTEGER PRIMARY KEY, + assignment_id INTEGER NOT NULL REFERENCES assignments (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + files TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'done', 'error')), + result TEXT, + auto_score REAL, + created_at INTEGER NOT NULL, + graded_at INTEGER + ); + CREATE INDEX submissions_assignment_user ON submissions (assignment_id, user_id, id); + CREATE INDEX submissions_status ON submissions (status); + `, ] /** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */ diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 06b3e75..42d35b9 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -1,11 +1,11 @@ // 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。 -// 服务器只有 2 核,全站同时最多跑 GRADE_CONCURRENCY 个检查,其余排队。 +// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。 import type { CheckResult, ControlNode, GradeResult } from '@iot/shared' import { compileService } from '../compile-service' import type { Check, Expectation, Step } from './checks' import { ProgramDriver } from './driver' -const GRADE_CONCURRENCY = 2 +const GRADE_CONCURRENCY = 6 /** 程序启动后多久内应该显示出窗体 */ const START_TIMEOUT = 10_000 @@ -92,7 +92,9 @@ async function runStep(driver: ProgramDriver, step: Step) { const items = ((node.props.Items as unknown[] | undefined) ?? []).map(String) const index = items.indexOf(step.item) if (index < 0) { - throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})`) + // 串口列表是空的:最常见的原因是没有填充 + const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : '' + throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`) } driver.act(node, { action: 'select', index }) await driver.settle() diff --git a/server/src/grading/queue.ts b/server/src/grading/queue.ts new file mode 100644 index 0000000..add08a3 --- /dev/null +++ b/server/src/grading/queue.ts @@ -0,0 +1,70 @@ +// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。 +// 服务器重启前没评完的提交,启动时继续评。 +import type { Database } from 'bun:sqlite' +import { mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments' +import { config } from '../config' +import { safeJoin } from '../run-session' +import { grade } from './grader' + +const MAX_ACTIVE = 3 + +export function createGradingQueue(db: Database) { + const queue: number[] = [] + let active = 0 + let idleWaiters: (() => void)[] = [] + + async function gradeOne(id: number) { + const submission = submissionForGrading(db, id) + const checks = submission && assignmentChecks(db, submission.assignmentId) + if (!submission || !checks) return + const dir = join(config.runsDir, `grade-${id}`) + try { + await rm(dir, { recursive: true, force: true }) + for (const file of submission.files) { + const target = safeJoin(dir, file.path) + await mkdir(dirname(target), { recursive: true }) + await Bun.write(target, file.content) + } + saveGrade(db, id, await grade(dir, checks)) + } catch (error) { + console.error(`评分出错(提交 ${id}):`, error) + saveGrade(db, id, null) + } finally { + await rm(dir, { recursive: true, force: true }) + } + } + + function pump() { + while (active < MAX_ACTIVE && queue.length > 0) { + const id = queue.shift()! + active++ + void gradeOne(id).finally(() => { + active-- + pump() + }) + } + if (active === 0 && queue.length === 0) { + for (const resolve of idleWaiters) resolve() + idleWaiters = [] + } + } + + for (const id of pendingSubmissionIds(db)) queue.push(id) + pump() + + return { + enqueue(id: number) { + queue.push(id) + pump() + }, + /** 等队列全部评完(测试用) */ + idle(): Promise { + if (active === 0 && queue.length === 0) return Promise.resolve() + return new Promise((resolve) => idleWaiters.push(resolve)) + }, + } +} + +export type GradingQueue = ReturnType diff --git a/server/src/index.ts b/server/src/index.ts index 7d9fbb5..c69b0c7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,7 @@ import { ensureTeacher, tokenUser } from './auth' import { compileService } from './compile-service' import { config } from './config' import { openDatabase } from './db' +import { createGradingQueue } from './grading/queue' import { cleanupStaleRuns, RunSession } from './run-session' import { checkSandbox, sandboxError } from './sandbox' @@ -13,7 +14,7 @@ if (sandboxError) console.error(sandboxError) const db = openDatabase() ensureTeacher(db) -const app = createApp(db) +const app = createApp(db, createGradingQueue(db)) interface SocketData { session?: RunSession diff --git a/server/src/run-session.ts b/server/src/run-session.ts index 425c5cf..30658cb 100644 --- a/server/src/run-session.ts +++ b/server/src/run-session.ts @@ -170,7 +170,7 @@ export class RunSession { } /** 拼接路径并拒绝跳出工程目录的文件名 */ -function safeJoin(base: string, path: string) { +export function safeJoin(base: string, path: string) { const target = resolve(base, normalize(path)) if (!target.startsWith(`${base}/`)) throw new Error(`非法文件路径:${path}`) return target diff --git a/server/src/samples.ts b/server/src/samples.ts index 61bb24e..2853777 100644 --- a/server/src/samples.ts +++ b/server/src/samples.ts @@ -32,3 +32,11 @@ export async function loadSample(id: string): Promise { } return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null } + +/** 示例的评分规则(checks.yaml 原文);没有规则返回 null */ +export async function loadChecks(id: string): Promise { + const path = resolve(SAMPLES_DIR, id, 'checks.yaml') + if (!path.startsWith(`${SAMPLES_DIR}/`)) return null + const file = Bun.file(path) + return (await file.exists()) ? file.text() : null +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 05a93cb..a8a0c85 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -4,13 +4,17 @@ import { createApp } from '../src/app' import { ensureTeacher } from '../src/auth' import { config } from '../src/config' import { openDatabase } from '../src/db' +import { createGradingQueue, type GradingQueue } from '../src/grading/queue' +import { loadSample } from '../src/samples' let app: ReturnType +let queue: GradingQueue beforeEach(() => { const db = openDatabase(':memory:') ensureTeacher(db) - app = createApp(db) + queue = createGradingQueue(db) + app = createApp(db, queue) }) /** 一个浏览器:记住访问令牌和刷新令牌 */ @@ -238,3 +242,100 @@ describe('令牌', () => { expect((await t.get('/api/me')).status).toBe(401) }) }) + +describe('作业', () => { + const SAMPLE = 'p4-t3-报警灯' + const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }] } + + /** 老师建班、导入一个学生、布置报警灯作业;返回老师、学生和作业 id */ + async function setup(deadline: number | null = null) { + const t = await teacher() + const classId = await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }]) + const template = (await loadSample(SAMPLE))! + const created = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '控制报警灯', startMode: 'ui', template, scoring, deadline }) + expect(created.status).toBe(200) + const student = new Browser() + await student.login('s1', 'p1') + return { t, student, classId, assignmentId: created.body.id as number, files: template } + } + + test('分值项数要和评分规则一致', async () => { + const t = await teacher() + const classId = await classWithStudents(t, []) + const result = await t.post('/api/teacher/assignments', { + classId, + sampleId: SAMPLE, + title: '控制报警灯', + startMode: 'ui', + template: [], + scoring: { auto: [10], manual: [] }, + deadline: null, + }) + expect(result.status).toBe(400) + expect(result.body.message).toBe('自动评分项应有 2 项') + expect((await t.get(`/api/teacher/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯']) + }) + + test('学生能看到自己班的作业和起点工程,别的班看不到', async () => { + const { t, assignmentId } = await setup() + const s1 = new Browser() + await s1.login('s1', 'p1') + const list = (await s1.get('/api/assignments')).body + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ title: '控制报警灯', checkTitles: ['能正确打开报警灯', '能正确关闭报警灯'], latest: null }) + expect((await s1.get(`/api/assignments/${assignmentId}`)).body.template.length).toBeGreaterThan(0) + + const { body: other } = await t.post('/api/teacher/classes', { name: '另一个班' }) + await t.post(`/api/teacher/classes/${other.id}/import`, { rows: [{ username: 's2', name: '乙', password: 'p2' }] }) + const s2 = new Browser() + await s2.login('s2', 'p2') + expect((await s2.get('/api/assignments')).body).toEqual([]) + expect((await s2.get(`/api/assignments/${assignmentId}`)).status).toBe(404) + }) + + test('提交后排队评分,评完得到每项结果和分数;老师能看到提交情况', async () => { + const { t, student, assignmentId, files } = await setup() + const submitted = await student.post(`/api/assignments/${assignmentId}/submit`, { files }) + expect(submitted.body.status).toBe('pending') + // 上一份还没评完时不能再交 + expect((await student.post(`/api/assignments/${assignmentId}/submit`, { files })).status).toBe(409) + + await queue.idle() + const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body + expect(graded).toMatchObject({ status: 'done', autoScore: 20 }) + expect(graded.result.results.map((r: any) => r.passed)).toEqual([true, true]) + + const submissions = (await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body + expect(submissions).toEqual([expect.objectContaining({ username: 's1', status: 'done', autoScore: 20, count: 1 })]) + expect((await t.get(`/api/teacher/classes/${(await t.get('/api/teacher/classes')).body[0].id}/assignments`)).body[0]).toMatchObject({ + studentCount: 1, + submittedCount: 1, + }) + }, 60000) + + test('只做对一部分时按通过的项得分;老师改分值后重新算分', async () => { + const { t, student, assignmentId, files } = await setup() + const broken = files.map((f) => (f.path === 'Form1.cs' ? { ...f, content: f.content.replace('AlarmOnOff("Alarm", false);', '') } : f)) + await student.post(`/api/assignments/${assignmentId}/submit`, { files: broken }) + await queue.idle() + const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body + expect(graded.autoScore).toBe(10) + expect(graded.result.results[1]).toMatchObject({ title: '能正确关闭报警灯', passed: false }) + + await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring: { ...scoring, auto: [30, 20] } }) + expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30) + }, 60000) + + test('过了截止时间不能提交', async () => { + const { student, assignmentId, files } = await setup(Date.now() - 1000) + const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files }) + expect(result.status).toBe(400) + expect(result.body.message).toBe('已经过了截止时间,不能再提交') + }) + + test('删除作业后学生看不到', async () => { + const { t, student, assignmentId } = await setup() + await t.delete(`/api/teacher/assignments/${assignmentId}`) + expect((await student.get('/api/assignments')).body).toEqual([]) + }) +}) diff --git a/shared/grading.ts b/shared/grading.ts index aafea2c..cf92799 100644 --- a/shared/grading.ts +++ b/shared/grading.ts @@ -15,3 +15,40 @@ export interface GradeResult { /** 评分用时(毫秒) */ ms: number } + +// ---------- 作业 ---------- + +/** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */ +export type StartMode = 'blank' | 'ui' + +/** 分值:自动评分项按规则顺序各自的分值;老师评分项(界面、注释、命名等)由老师打分 */ +export interface Scoring { + auto: number[] + manual: { title: string; points: number }[] +} + +export interface Assignment { + id: number + classId: number + className: string + sampleId: string + title: string + startMode: StartMode + /** 截止时间(毫秒时间戳),null 表示不限 */ + deadline: number | null + createdAt: number + /** 自动评分项的名称,和 scoring.auto 一一对应 */ + checkTitles: string[] + scoring: Scoring +} + +export interface Submission { + id: number + status: 'pending' | 'done' | 'error' + result: GradeResult | null + /** 自动评分得分(通过的项的分值之和) */ + autoScore: number | null + createdAt: number + /** 排队评分时前面还有几份 */ + queueAhead?: number +} diff --git a/web/src/Root.vue b/web/src/Root.vue index 8c66752..1a1c189 100644 --- a/web/src/Root.vue +++ b/web/src/Root.vue @@ -6,7 +6,8 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive- - + + diff --git a/web/src/designer/templates.test.ts b/web/src/designer/templates.test.ts index 42a8a88..eb41e4a 100644 --- a/web/src/designer/templates.test.ts +++ b/web/src/designer/templates.test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'bun:test' import { generateDesigner, parseDesigner } from './document' import { addControl } from './edit' import { controlDef } from './catalog' -import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates' +import { assignmentTemplate, formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates' const files = [ { path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' }, @@ -27,3 +27,36 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正 expect(text).toContain(' this.Controls.Add(this.button1);\n this.Name = "AddUser";') expect(text).toContain(' #endregion\n\n private System.Windows.Forms.Button button1;\n }') }) + +/** 读取 samples/ 下的示例工程 */ +async function sample(id: string) { + const dir = `${import.meta.dir}/../../../samples/${id}` + const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir })) + return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() }))) +} + +test('作业起点"界面已做好":保留控件和属性,去掉事件绑定和事件处理方法', async () => { + const files = assignmentTemplate(await sample('p2-t1-LED显示屏'), 'ui') + expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs']) + const designer = files.find((f) => f.path === 'Form1.Designer.cs')!.content + expect(designer).toContain('this.btn_Open = new System.Windows.Forms.Button();') + expect(designer).toContain('this.btn_Open.Text = "打 开";') + expect(designer).not.toContain('+=') + const code = files.find((f) => f.path === 'Form1.cs')!.content + expect(code).toContain('namespace LED显示屏') + expect(code).not.toContain('btn_Open_Click') +}) + +test('作业起点"界面已做好":多个窗体都保留,其他类(PublicData)由学生自己写', async () => { + const files = assignmentTemplate(await sample('p1-t1-登录'), 'ui') + expect(files.map((f) => f.path).sort()).toEqual(['AddUser.Designer.cs', 'AddUser.cs', 'Login.Designer.cs', 'Login.cs', 'Program.cs']) + expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form') +}) + +test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => { + const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank') + expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs']) + const program = files.find((f) => f.path === 'Program.cs')!.content + expect(program).toContain('Application.Run(new Form1())') + expect(files.find((f) => f.path === 'Form1.Designer.cs')!.content).not.toContain('com_port') +}) diff --git a/web/src/designer/templates.ts b/web/src/designer/templates.ts index 2785418..b2102d0 100644 --- a/web/src/designer/templates.ts +++ b/web/src/designer/templates.ts @@ -1,5 +1,6 @@ -// 新建窗体:生成与 VS(中文版、.NET Framework 4.5)新建窗体并保存一次后相同的两个文件。 +// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。 import type { SourceFile } from '@iot/shared' +import { generateDesigner, parseDesigner } from './document' /** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */ export function projectNamespace(files: SourceFile[]): string { @@ -104,3 +105,29 @@ ${blockHeader(name)} { path: `${name}.Designer.cs`, content: designer }, ] } + +export type StartMode = 'blank' | 'ui' + +/** + * 布置作业时学生的起点工程(由参考答案生成): + * - blank:空白工程,只有 Program.cs 和一个空的 Form1,学生从搭界面做起; + * - ui:界面已做好,保留每个窗体的控件和属性,去掉事件绑定,窗体代码只剩构造函数, + * 学生在设计器里双击控件生成事件处理方法、写代码。其他类(如 PublicData)也要学生自己写。 + */ +export function assignmentTemplate(files: SourceFile[], mode: StartMode): SourceFile[] { + const namespace = projectNamespace(files) + const program = files.find((f) => f.path === 'Program.cs') + if (mode === 'blank') { + const startup = startupForm(files) + const programText = program && startup ? program.content.replace(new RegExp(`new\\s+${startup}\\s*\\(`, 'u'), 'new Form1(') : undefined + return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')] + } + const result: SourceFile[] = program ? [{ ...program }] : [] + for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) { + const doc = parseDesigner(designer.content) + for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event') + const [code] = newFormFiles(namespace, doc.className) + result.push({ path: designer.path.replace(/\.Designer\.cs$/, '.cs'), content: code.content }, { path: designer.path, content: generateDesigner(doc) }) + } + return result +} diff --git a/web/src/grading/GradeResultView.vue b/web/src/grading/GradeResultView.vue new file mode 100644 index 0000000..6840b83 --- /dev/null +++ b/web/src/grading/GradeResultView.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/web/src/pages/AssignmentsPage.vue b/web/src/pages/AssignmentsPage.vue new file mode 100644 index 0000000..cfc28aa --- /dev/null +++ b/web/src/pages/AssignmentsPage.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/pages/TeacherPage.vue b/web/src/pages/TeacherPage.vue index 8efc67c..e8d0428 100644 --- a/web/src/pages/TeacherPage.vue +++ b/web/src/pages/TeacherPage.vue @@ -1,10 +1,11 @@ + + + + diff --git a/web/src/teacher/AssignmentsPanel.vue b/web/src/teacher/AssignmentsPanel.vue new file mode 100644 index 0000000..92c6629 --- /dev/null +++ b/web/src/teacher/AssignmentsPanel.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/web/src/teacher/SubmissionsDialog.vue b/web/src/teacher/SubmissionsDialog.vue new file mode 100644 index 0000000..d4f397d --- /dev/null +++ b/web/src/teacher/SubmissionsDialog.vue @@ -0,0 +1,79 @@ + + + + +