Files
IOT_SIM/server/src/assignments.ts
T
xuyueandClaude Opus 5.5 de277d3e9d
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s
用 bun fmt 格式化全部代码和文档
- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:02:33 +08:00

250 lines
9.8 KiB
TypeScript

// 作业:老师把一个实验布置给一个班,学生提交后自动评分。
// 布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。
import type { Database } from 'bun:sqlite'
import type { Assignment, GradeResult, Scoring, Screenshot, 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
task: string | null
}
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, a.task
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,
task: row.task,
}
}
export function listAssignments(db: Database, classId: number): Assignment[] {
return db.query<AssignmentRow, [number]>(`${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<AssignmentRow, [number]>(`${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
/** 任务说明(Markdown),null 表示用实验的说明 */
task: string | null
}
/** 任务说明最长多少字 */
export const MAX_TASK_LENGTH = 20_000
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) ?? taskError(a.task)
if (error) return { error }
const result = db
.query(
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at, task)
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(), a.task)
return { id: Number(result.lastInsertRowid) }
}
function taskError(task: string | null) {
return task !== null && task.length > MAX_TASK_LENGTH ? `任务说明不能超过 ${MAX_TASK_LENGTH} 字` : null
}
/** 修改名称、截止时间、分值、任务说明(起点工程和评分规则布置后不能改) */
export function updateAssignment(
db: Database,
id: number,
changes: { title: string; deadline: number | null; scoring: Scoring; task: string | null },
): { error?: string } {
const checks = assignmentChecks(db, id)
if (!checks) return { error: '作业不存在' }
if (!changes.title.trim()) return { error: '作业名称不能为空' }
const error = scoringError(changes.scoring, checks.length) ?? taskError(changes.task)
if (error) return { error }
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ?, task = ? WHERE id = ?').run(
changes.title.trim(),
changes.deadline,
JSON.stringify(changes.scoring),
changes.task,
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<SubmissionRow, [number, number]>(
'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[], screenshots: Screenshot[] = []): number | null {
if (latestSubmission(db, assignmentId, userId)?.status === 'pending') return null
const result = db
.query(`INSERT INTO submissions (assignment_id, user_id, files, screenshots, status, created_at) VALUES (?, ?, ?, ?, 'pending', ?)`)
.run(assignmentId, userId, JSON.stringify(files), JSON.stringify(screenshots), 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; sample_id: string }, [number]>(
'SELECT s.assignment_id, s.files, a.sample_id FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?',
)
.get(id)
return row ? { assignmentId: row.assignment_id, sampleId: row.sample_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
submission_id: number | null
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.id AS submission_id, 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,
submissionId: r.submission_id,
status: r.status,
autoScore: r.auto_score,
submittedAt: r.created_at,
count: r.count,
}))
}