实验页"评价项"和自测:提交前自己检查,不计分

- 工具栏"评价项"打开侧栏,列出自动检查的功能项(作业显示布置时的规则和各项分值)
- "自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和原因;不计分、不留记录,
  自由练习和作业都能用;每人同时一次,结果保留到下一次,之后改过代码提示结果可能过时;
  和提交评分共用全站并发上限
- 实验的评价项接口学生也能用(老师布置作业的对话框改用它)
- 修正:运行区的窗体和"放大"按钮会盖住网页的抽屉和对话框

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-25 18:35:25 +08:00
1 parent aadb2c9e93
commit f256b313f7
11 files changed
+345 -15

No files matched your search

+38 -6
View File
@@ -6,6 +6,7 @@ import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type T
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
import type { Scoring, StartMode } from '@iot/shared'
import {
assignmentChecks,
assignmentTemplate,
classSubmissions,
createAssignment,
@@ -18,6 +19,7 @@ import {
} from './assignments'
import { aiEnabled, getAiReview, type AiQueue } from './ai-review'
import { parseChecks } from './grading/checks'
import { createSelfCheck } from './grading/selfcheck'
import { languageService } from './language-service'
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
import type { GradingQueue } from './grading/queue'
@@ -98,6 +100,30 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return files ? c.json(files) : c.notFound()
})
// ---- 自测:提交前自己跑一遍评分,不计分、不留记录 ----
const selfCheck = createSelfCheck()
/** 实验的评价项(自动检查的功能项名称):实验页的评价项面板、老师布置作业时用 */
app.get('/api/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.post('/api/samples/:id/selfcheck', async (c) => {
const sampleId = c.req.param('id')
const yaml = (await listSamples()).some((s) => s.id === sampleId) ? await loadChecks(sampleId) : null
if (!yaml) return c.json({ message: '这个实验没有评价项' }, 404)
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
if (!selfCheck.start(c.get('user').id, `sample:${sampleId}`, files, parseChecks(yaml), sampleId)) {
return c.json({ message: '上一次检查还没完成' }, 409)
}
return c.json(selfCheck.get(c.get('user').id))
})
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => {
@@ -197,6 +223,18 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
return c.json(latestSubmission(db, assignment.id, user.id))
})
app.post('/api/assignments/:id/selfcheck', async (c) => {
const assignment = visibleAssignment(c)
const checks = assignment && assignmentChecks(db, assignment.id)
if (!assignment || !checks) return c.json({ message: '作业不存在' }, 404)
const { files } = await body<{ files: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
if (!selfCheck.start(c.get('user').id, `assignment:${assignment.id}`, files, checks, assignment.sampleId)) {
return c.json({ message: '上一次检查还没完成' }, 409)
}
return c.json(selfCheck.get(c.get('user').id))
})
app.get('/api/assignments/:id/submission', (c) => {
const assignment = visibleAssignment(c)
if (!assignment) return c.json({ message: '作业不存在' }, 404)
@@ -247,12 +285,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
})
// 示例的自动评分项名称(布置作业时设置分值用)
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(
+12 -6
View File
@@ -1,6 +1,7 @@
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
// 服务器重启前没评完的提交,启动时继续评。评完调用 onGraded(接着做 AI 评阅)。
import type { Database } from 'bun:sqlite'
import type { SourceFile } from '@iot/shared'
import { mkdir, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments'
@@ -10,6 +11,16 @@ import { grade } from './grader'
const MAX_ACTIVE = 3
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
await rm(dir, { recursive: true, force: true })
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
}
export function createGradingQueue(db: Database, onGraded: (submissionId: number) => void = () => {}) {
const queue: number[] = []
let active = 0
@@ -21,12 +32,7 @@ export function createGradingQueue(db: Database, onGraded: (submissionId: number
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)
}
await writeProject(dir, submission.files)
saveGrade(db, id, await grade(dir, checks, submission.sampleId))
onGraded(id)
} catch (error) {
+41
View File
@@ -0,0 +1,41 @@
// 自测:学生提交前自己跑一遍评分,告诉他哪几项不通过、为什么;不计分、不留记录。
// 每人同时只能有一次,结果放在内存里,保留到下一次自测(服务器重启后就没有了)。
// 检查项和提交评分一样受全站并发上限(grader.ts 的 GRADE_CONCURRENCY)约束,机器忙时排队。
import type { SelfCheck, SourceFile } from '@iot/shared'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { config } from '../config'
import type { Check } from './checks'
import { grade } from './grader'
import { writeProject } from './queue'
export function createSelfCheck() {
const states = new Map<number, SelfCheck>()
return {
/** 开始自测;上一次还没完成时返回 false */
start(userId: number, target: string, files: SourceFile[], checks: Check[], sampleId: string): boolean {
if (states.get(userId)?.status === 'running') return false
const state: SelfCheck = { status: 'running', target, result: null, startedAt: Date.now() }
states.set(userId, state)
const dir = join(config.runsDir, `selfcheck-${userId}`)
void (async () => {
try {
await writeProject(dir, files)
state.result = await grade(dir, checks, sampleId)
state.status = 'done'
} catch (error) {
console.error(`自测出错(用户 ${userId}):`, error)
state.status = 'error'
} finally {
await rm(dir, { recursive: true, force: true })
}
})()
return true
},
get(userId: number): SelfCheck | null {
return states.get(userId) ?? null
},
}
}