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

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

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

+1 -1
View File
@@ -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)。
尚未完成:
+1
View File
@@ -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 秒让它出现(上一步刚打开的窗体在机器忙时晚一点显示)。
## 技术风险与待确认事项
+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
},
}
}
+27 -1
View File
@@ -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 })
+9
View File
@@ -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 界面已做好(只写代码) */
+179
View File
@@ -0,0 +1,179 @@
<script setup lang="ts">
// 评价项面板:列出这个实验(或作业)自动检查的功能项;"自己检查"在服务器上运行学生现在的程序逐项检查,
// 告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { SelfCheck, SourceFile } from '@iot/shared'
import { api } from '../api'
const props = defineProps<{
show: boolean
/** 检查项名称 */
titles: string[]
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
target: string
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
url: string
/** 学生现在的代码 */
files: SourceFile[]
/** 开始检查前调用(先把没保存的改动存好) */
beforeStart?: () => Promise<void>
/** 作业的各项分值(自由练习没有) */
points?: number[]
}>()
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
const state = ref<SelfCheck | null>(null)
const error = ref('')
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
const checkedFiles = ref<string>()
const current = computed(() => (state.value?.target === props.target ? state.value : null))
const running = computed(() => current.value?.status === 'running')
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
clearTimeout(timer)
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
}
onBeforeUnmount(() => clearTimeout(timer))
watch(
() => props.show,
(show) => {
if (show) void refresh()
},
{ immediate: true },
)
async function start() {
error.value = ''
try {
await props.beforeStart?.()
checkedFiles.value = JSON.stringify(props.files)
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
timer = setTimeout(refresh, 2000)
} catch (e) {
error.value = (e as Error).message
}
}
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
</script>
<template>
<NDrawer :show="show" :width="420" placement="right" @update:show="emit('update:show', $event)">
<NDrawerContent title="评价项" closable>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
<span v-else-if="current?.status === 'done' && current.result" class="status">
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
</span>
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
</div>
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
{{ current.result.results[i].message }}
</div>
</div>
</li>
</ul>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.note {
margin: 0 0 12px;
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #374151;
}
.error {
color: #b91c1c;
font-size: 13px;
}
.outdated {
margin: 4px 0;
color: #a16207;
font-size: 13px;
}
.items {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.items li {
display: flex;
gap: 8px;
}
.mark {
width: 1em;
flex: none;
font-weight: bold;
color: #9ca3af;
}
.passed .mark {
color: #16a34a;
}
.failed .mark {
color: #dc2626;
}
.points {
margin-left: 8px;
color: #6b7280;
}
.message {
margin-top: 2px;
color: #b91c1c;
}
</style>
+34
View File
@@ -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<SourceFile[]>, d
async function loadSample(id: string, discardWork = false) {
sampleId.value = id
void router.replace({ query: { sample: id } })
sampleChecks.value = []
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
.then((titles) => {
if (sampleId.value === id) sampleChecks.value = titles
})
.catch(() => {})
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
}
// ---------- 评价项和自测 ----------
const sampleChecks = ref<string[]>([])
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<Assignment | null>(null)
const submission = ref<Submission | null>(null)
@@ -456,6 +476,9 @@ function run() {
{{ scoreText }}
</NButton>
</template>
<NButton v-if="selfCheck && !viewing" size="small" title="这个任务自动检查哪些功能;可以自己先检查一遍(不计分)" @click="selfCheckOpen = true">
评价项
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -488,6 +511,17 @@ function run() {
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
</NModal>
<SelfCheckPanel
v-if="selfCheck"
v-model:show="selfCheckOpen"
:titles="selfCheck.titles"
:target="selfCheck.target"
:url="selfCheck.url"
:points="selfCheck.points"
:files="files"
:before-start="flushSave"
/>
<main class="workspace" :class="{ designing: activeForm }">
<section class="editor">
<div class="tab-row">
+2
View File
@@ -142,6 +142,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
border-radius: 4px;
/* 窗体被拖到桌面外面时可以滚动查看 */
overflow: auto;
/* 窗体、提示框的层级只在桌面里比较,不会盖住网页的抽屉和对话框 */
isolation: isolate;
}
.desktop-canvas {
+1 -1
View File
@@ -63,7 +63,7 @@ async function chooseSample(id: string) {
sampleId.value = id
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
try {
checkTitles.value = await api<string[]>('GET', `/api/teacher/samples/${encodeURIComponent(id)}/checks`)
checkTitles.value = await api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
auto.value = checkTitles.value.map(() => 10)
} catch (e) {
checkTitles.value = []