布置作业、提交与自动评分

- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项);
  起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照
- 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交
- 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示
- 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分
- 串口列表为空时提示在 Load 或 Click 事件里填充
- 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:02:22 +08:00
1 parent 37fc747b49
commit 4e6b0d5cb2
23 files changed
+1439 -50

No files matched your search

+116 -4
View File
@@ -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 = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
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 提供