From f256b313f7fd5afee372921ce056998c74dd7293 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sun, 25 Oct 2026 18:35:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E9=A1=B5"=E8=AF=84=E4=BB=B7?= =?UTF-8?q?=E9=A1=B9"=E5=92=8C=E8=87=AA=E6=B5=8B=EF=BC=9A=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=89=8D=E8=87=AA=E5=B7=B1=E6=A3=80=E6=9F=A5=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=AE=A1=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工具栏"评价项"打开侧栏,列出自动检查的功能项(作业显示布置时的规则和各项分值) - "自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和原因;不计分、不留记录, 自由练习和作业都能用;每人同时一次,结果保留到下一次,之后改过代码提示结果可能过时; 和提交评分共用全站并发上限 - 实验的评价项接口学生也能用(老师布置作业的对话框改用它) - 修正:运行区的窗体和"放大"按钮会盖住网页的抽屉和对话框 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/设计方案.md | 1 + server/src/app.ts | 44 ++++++- server/src/grading/queue.ts | 18 ++- server/src/grading/selfcheck.ts | 41 ++++++ server/test/api.test.ts | 28 ++++- shared/grading.ts | 9 ++ web/src/grading/SelfCheckPanel.vue | 179 +++++++++++++++++++++++++++ web/src/pages/WorkbenchPage.vue | 34 +++++ web/src/run/RunArea.vue | 2 + web/src/teacher/AssignmentDialog.vue | 2 +- 11 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 server/src/grading/selfcheck.ts create mode 100644 web/src/grading/SelfCheckPanel.vue diff --git a/README.md b/README.md index e298e09..74d49f3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 -- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 +- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 尚未完成: diff --git a/docs/设计方案.md b/docs/设计方案.md index 983fbde..dc15d5f 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -315,6 +315,7 @@ flowchart TB - 2026-10-22:代码编辑器的语言功能:代码补全(输入 `btn_Open.` 列出按钮的属性、方法、事件,只列出平台仿真了的成员)、悬停提示、参数提示(`MessageBox.Show(` 列出重载,标出正在填的参数)、停止输入 1 秒后自动检查编译错误(提示和点"运行"时一样)。语言服务是一个常驻进程,每个浏览器标签页一份工程,只更新有变化的文件。实测(限制 2 核):50 个标签页同时检查 1.2 秒内全部完成,同时补全 0.7 秒;内存约 260 MB。 - 2026-10-22:AI 评阅:学生提交时浏览器给每个窗体截图;自动评分后由 DeepSeek Flash(支持图片输入和 JSON 输出)按书上评价表给老师评分项打建议分、写评语,发出的内容不含姓名学号,学生代码里像指令的文字一律当作品内容。提示词按教材的命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名都不扣分):教材参考答案得 28/30,默认控件名、没有注释的作品得 15/30。评阅页显示截图和 AI 建议,可逐个采纳或"全部采纳"(不覆盖老师已评的分);学生在老师评阅后又提交时提示老师重新看。一份作业约 0.02 元。 +- 2026-10-25:评价项和自测(为一个班试用做准备):实验页工具栏"评价项"打开侧栏,列出这个实验(作业则是布置时的评分规则和各项分值)自动检查的功能项;"自己检查"在服务器上按评分规则运行学生现在的程序,逐项显示通过与否和不通过的原因。不计分、不留记录,自由练习和作业都能用;每人同时一次,结果在内存里保留到下一次,检查之后改过代码会提示结果可能过时;和提交评分共用全站的并发上限。评分时操作控件前最多等 3 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。 ## 技术风险与待确认事项 diff --git a/server/src/app.ts b/server/src/app.ts index 2021007..850bca1 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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( diff --git a/server/src/grading/queue.ts b/server/src/grading/queue.ts index e5bd28f..ae7b21c 100644 --- a/server/src/grading/queue.ts +++ b/server/src/grading/queue.ts @@ -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) { diff --git a/server/src/grading/selfcheck.ts b/server/src/grading/selfcheck.ts new file mode 100644 index 0000000..0c94320 --- /dev/null +++ b/server/src/grading/selfcheck.ts @@ -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() + + 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 + }, + } +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 7359c70..c1cd5b4 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -279,7 +279,7 @@ describe('作业', () => { }) expect(result.status).toBe(400) expect(result.body.message).toBe('自动评分项应有 2 项') - expect((await t.get(`/api/teacher/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯']) + expect((await t.get(`/api/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯']) }) test('学生能看到自己班的作业和起点工程,别的班看不到', async () => { @@ -332,6 +332,32 @@ describe('作业', () => { expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30) }, 60000) + test('自测:提交前自己检查,告诉哪项不通过;不计分、不留记录,同时只能一次', async () => { + const { student, assignmentId, files } = await setup() + const finished = async () => { + for (let i = 0; i < 300; i++) { + const { body } = await student.get('/api/selfcheck') + if (body.status !== 'running') return body + await Bun.sleep(200) + } + } + const broken = files.map((f) => (f.path === 'Form1.cs' ? { ...f, content: f.content.replace('AlarmOnOff("Alarm", false);', '') } : f)) + const started = await student.post(`/api/assignments/${assignmentId}/selfcheck`, { files: broken }) + expect(started.body).toMatchObject({ status: 'running', target: `assignment:${assignmentId}` }) + expect((await student.post(`/api/assignments/${assignmentId}/selfcheck`, { files })).status).toBe(409) + const result = await finished() + expect(result.result.results.map((r: any) => r.passed)).toEqual([true, false]) + expect(result.result.results[1].message).toBeString() + // 自测不算提交 + expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body).toBeNull() + + // 自由练习:按实验的评分规则检查 + const sample = await student.post(`/api/samples/${encodeURIComponent(SAMPLE)}/selfcheck`, { files }) + expect(sample.body.target).toBe(`sample:${SAMPLE}`) + expect((await finished()).result.results.map((r: any) => r.passed)).toEqual([true, true]) + expect((await student.post('/api/samples/no-such/selfcheck', { files })).status).toBe(404) + }, 60000) + test('过了截止时间不能提交', async () => { const { student, assignmentId, files } = await setup(Date.now() - 1000) const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files }) diff --git a/shared/grading.ts b/shared/grading.ts index 261904f..42cb269 100644 --- a/shared/grading.ts +++ b/shared/grading.ts @@ -16,6 +16,15 @@ export interface GradeResult { ms: number } +/** 自测(提交前自己跑一遍评分,不计分、不留记录):每人同时一次,结果保留到下一次自测 */ +export interface SelfCheck { + status: 'running' | 'done' | 'error' + /** 检查的是哪个实验或作业(结果只对同一个显示) */ + target: string + result: GradeResult | null + startedAt: number +} + // ---------- 作业 ---------- /** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */ diff --git a/web/src/grading/SelfCheckPanel.vue b/web/src/grading/SelfCheckPanel.vue new file mode 100644 index 0000000..8e89b9c --- /dev/null +++ b/web/src/grading/SelfCheckPanel.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/web/src/pages/WorkbenchPage.vue b/web/src/pages/WorkbenchPage.vue index 66413cd..354ebd9 100644 --- a/web/src/pages/WorkbenchPage.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -18,6 +18,7 @@ import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works' import RunArea from '../run/RunArea.vue' import { createRunSession } from '../run/session' import GradeResultView from '../grading/GradeResultView.vue' +import SelfCheckPanel from '../grading/SelfCheckPanel.vue' import { captureForms } from '../grading/screenshots' const props = defineProps<{ assignmentId?: string; submissionId?: string }>() @@ -134,9 +135,28 @@ async function openProject(key: string, original: () => Promise, d async function loadSample(id: string, discardWork = false) { sampleId.value = id void router.replace({ query: { sample: id } }) + sampleChecks.value = [] + void api('GET', `/api/samples/${encodeURIComponent(id)}/checks`) + .then((titles) => { + if (sampleId.value === id) sampleChecks.value = titles + }) + .catch(() => {}) await openProject(sampleWorkKey(id), () => api('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork) } +// ---------- 评价项和自测 ---------- +const sampleChecks = ref([]) +const selfCheckOpen = ref(false) +/** 作业用布置时的评分规则,自由练习用实验的评分规则 */ +const selfCheck = computed(() => { + if (props.assignmentId) { + const a = assignment.value + return a ? { titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null + } + const id = sampleId.value + return id && sampleChecks.value.length ? { titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` } : null +}) + // ---------- 作业 ---------- const assignment = ref(null) const submission = ref(null) @@ -456,6 +476,9 @@ function run() { {{ scoreText }} + + 评价项 +