diff --git a/README.md b/README.md index c3636d9..8516da5 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 -- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分、老师查看提交情况。 +- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码、运行学生的程序、老师评分项打分、评语)、全班成绩表与 CSV 导出。 尚未完成: diff --git a/docs/设计方案.md b/docs/设计方案.md index df66f19..882f290 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -234,7 +234,7 @@ IOT/ flowchart TB P0["阶段 0 · 技术验证(已完成 2026-10-22)教材项目 2 任务 1 原样运行;实测编译、延迟、内存"] P1["阶段 1 · MVP(进行中,约 3 个月)虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔部署(沙箱、Docker Compose、GitHub Actions)✔"] - P2["阶段 2 · 教学闭环(进行中,约 2 个月)账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔老师看板(看代码、运行、老师评分)、成绩导出;一个班试用"] + P2["阶段 2 · 教学闭环(进行中,约 2 个月)账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"] P3["阶段 3 · 扩展(下学期)教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"] P4["阶段 4 · 进阶(之后)校园街景场景、故障注入题库、导出 VS 工程"] P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1 @@ -273,6 +273,7 @@ flowchart TB - 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。 - 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。 - 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。 +- 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。 ## 技术风险与待确认事项 diff --git a/server/src/app.ts b/server/src/app.ts index 2050f0b..d8e8cd1 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { updateAssignment, } from './assignments' import { parseChecks } from './grading/checks' +import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews' import type { GradingQueue } from './grading/queue' import { listSamples, loadChecks, loadSample } from './samples' import { serveStatic } from './static' @@ -114,7 +115,7 @@ export function createApp(db: Database, gradingQueue: GradingQueue) { return c.json( listAssignments(db, user.classId).map((a) => { const latest = latestSubmission(db, a.id, user.id) - return { ...a, latest: latest && { ...latest, result: null } } + return { ...a, latest: latest && { ...latest, result: null }, manualScore: manualScore(getReview(db, a.id, user.id)) } }), ) }) @@ -122,7 +123,8 @@ export function createApp(db: Database, gradingQueue: GradingQueue) { 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) }) + const userId = c.get('user').id + return c.json({ assignment, template: assignmentTemplate(db, assignment.id), latest: latestSubmission(db, assignment.id, userId), review: getReview(db, assignment.id, userId) }) }) app.post('/api/assignments/:id/submit', async (c) => { @@ -236,8 +238,37 @@ export function createApp(db: Database, gradingQueue: GradingQueue) { return c.json({}) }) - // 全班每个学生最近一次提交的情况 - app.get('/api/teacher/assignments/:id/submissions', (c) => c.json(classSubmissions(db, id(c)))) + // 全班每个学生最近一次提交的情况和老师评分 + app.get('/api/teacher/assignments/:id/submissions', (c) => + c.json( + classSubmissions(db, id(c)).map((s) => { + const review = getReview(db, id(c), s.userId) + return { ...s, manualScore: manualScore(review), review } + }), + ), + ) + + // 评阅一个学生:最近一次提交(含代码和评分结果)、老师评阅 + app.get('/api/teacher/assignments/:id/students/:userId', (c) => { + const assignment = getAssignment(db, id(c)) + const detail = assignment && studentDetail(db, assignment.id, Number(c.req.param('userId'))) + return detail ? c.json({ assignment, ...detail }) : c.json({ message: '找不到这个学生的作业' }, 404) + }) + + app.put('/api/teacher/assignments/:id/students/:userId/review', async (c) => { + const { scores, comment } = await body<{ scores: unknown; comment: unknown }>(c) + const userId = Number(c.req.param('userId')) + const result = saveReview(db, id(c), userId, scores, comment) + return result.error ? c.json({ message: result.error }, 400) : c.json(getReview(db, id(c), userId)) + }) + + // 一份提交的代码(老师在实验页里运行学生的程序) + app.get('/api/teacher/submissions/:id', (c) => { + const submission = submissionFiles(db, id(c)) + return submission ? c.json(submission) : c.json({ message: '提交不存在' }, 404) + }) + + app.get('/api/teacher/classes/:id/gradebook', (c) => c.json(gradebook(db, id(c)))) app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404)) diff --git a/server/src/db.ts b/server/src/db.ts index 9fa2724..abc7c7b 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -1,4 +1,4 @@ -// SQLite 数据库:账号、班级、学生作品、作业与提交、平台设置(如 JWT 签名密钥)。 +// SQLite 数据库:账号、班级、学生作品、作业、提交与老师评阅、平台设置(如 JWT 签名密钥)。 // 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。 import { Database } from 'bun:sqlite' import { mkdirSync } from 'node:fs' @@ -85,6 +85,17 @@ const MIGRATIONS = [ CREATE INDEX submissions_assignment_user ON submissions (assignment_id, user_id, id); CREATE INDEX submissions_status ON submissions (status); `, + // 老师评阅:每个学生在每个作业上一条,scores 是 JSON 数组,和作业 scoring.manual 一一对应 + ` + CREATE TABLE reviews ( + assignment_id INTEGER NOT NULL REFERENCES assignments (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + scores TEXT NOT NULL, + comment TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + PRIMARY KEY (assignment_id, user_id) + ); + `, ] /** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */ diff --git a/server/src/reviews.ts b/server/src/reviews.ts new file mode 100644 index 0000000..c81b8e5 --- /dev/null +++ b/server/src/reviews.ts @@ -0,0 +1,104 @@ +// 老师评阅和成绩:给老师评分项打分、写评语;全班成绩表。 +// 一个学生的总分 = 最近一次提交的自动评分 + 老师评分(没提交时没有总分)。 +import type { Database } from 'bun:sqlite' +import type { Review, Scoring, SourceFile } from '@iot/shared' +import { getAssignment, latestSubmission } from './assignments' + +/** + * 把保存的得分对齐到作业现在的老师评分项:老师后来增删了评分项时,多的截掉、少的补"未打分", + * 超过满分的按满分算 + */ +function normalize(scores: (number | null)[], scoring: Scoring): (number | null)[] { + return scoring.manual.map((m, i) => { + const s = scores[i] + return typeof s === 'number' ? Math.min(Math.max(s, 0), m.points) : null + }) +} + +export function getReview(db: Database, assignmentId: number, userId: number): Review | null { + const assignment = getAssignment(db, assignmentId) + const row = db + .query<{ scores: string; comment: string; updated_at: number }, [number, number]>( + 'SELECT scores, comment, updated_at FROM reviews WHERE assignment_id = ? AND user_id = ?', + ) + .get(assignmentId, userId) + if (!row || !assignment) return null + return { scores: normalize(JSON.parse(row.scores), assignment.scoring), comment: row.comment, updatedAt: row.updated_at } +} + +/** 老师评分之和(未打分的项不算) */ +export const manualScore = (review: Review | null) => review?.scores.reduce((sum, s) => sum + (s ?? 0), 0) ?? null + +export function saveReview(db: Database, assignmentId: number, userId: number, scores: unknown, comment: unknown): { error?: string } { + const assignment = getAssignment(db, assignmentId) + if (!assignment) return { error: '作业不存在' } + const student = db.query('SELECT 1 FROM users WHERE id = ? AND class_id = ?').get(userId, assignment.classId) + if (!student) return { error: '学生不在这个班' } + const manual = assignment.scoring.manual + if (!Array.isArray(scores) || scores.length !== manual.length) return { error: `老师评分项应有 ${manual.length} 项` } + for (const [i, s] of scores.entries()) { + if (s === null) continue + if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points) return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` } + } + db.query( + `INSERT INTO reviews (assignment_id, user_id, scores, comment, updated_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (assignment_id, user_id) DO UPDATE SET scores = excluded.scores, comment = excluded.comment, updated_at = excluded.updated_at`, + ).run(assignmentId, userId, JSON.stringify(scores), String(comment ?? ''), Date.now()) + return {} +} + +/** 老师评阅一个学生:最近一次提交(含代码)、评阅、提交次数 */ +export function studentDetail(db: Database, assignmentId: number, userId: number) { + const student = db.query<{ id: number; username: string; name: string }, [number]>('SELECT id, username, name FROM users WHERE id = ?').get(userId) + if (!student) return null + const latest = latestSubmission(db, assignmentId, userId) + const files = latest + ? (JSON.parse(db.query<{ files: string }, [number]>('SELECT files FROM submissions WHERE id = ?').get(latest.id)!.files) as SourceFile[]) + : null + const count = db.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?').get(assignmentId, userId)!.n + return { student, submission: latest, files, review: getReview(db, assignmentId, userId), submissionCount: count } +} + +/** 一份提交的代码和它属于谁(老师在实验页里运行学生的程序时用) */ +export function submissionFiles(db: Database, submissionId: number) { + const row = db + .query<{ files: string; assignment_id: number; title: string; name: string; username: string }, [number]>( + `SELECT s.files, s.assignment_id, a.title, u.name, u.username + FROM submissions s JOIN assignments a ON a.id = s.assignment_id JOIN users u ON u.id = s.user_id WHERE s.id = ?`, + ) + .get(submissionId) + if (!row) return null + return { files: JSON.parse(row.files) as SourceFile[], assignmentId: row.assignment_id, assignmentTitle: row.title, studentName: row.name, username: row.username } +} + +/** 全班成绩表:每个学生在每个作业上的自动评分、老师评分和总分 */ +export function gradebook(db: Database, classId: number) { + const assignments = db + .query<{ id: number; title: string; scoring: string }, [number]>('SELECT id, title, scoring FROM assignments WHERE class_id = ? ORDER BY created_at') + .all(classId) + .map((a) => { + const scoring = JSON.parse(a.scoring) as Scoring + const sum = (xs: number[]) => xs.reduce((x, y) => x + y, 0) + return { id: a.id, title: a.title, scoring, total: sum(scoring.auto) + sum(scoring.manual.map((m) => m.points)) } + }) + const students = db + .query<{ id: number; username: string; name: string }, [number]>(`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`) + .all(classId) + return { + assignments: assignments.map(({ id, title, total }) => ({ id, title, total })), + students: students.map((s) => ({ + userId: s.id, + username: s.username, + name: s.name, + scores: Object.fromEntries( + assignments.map((a) => { + const latest = latestSubmission(db, a.id, s.id) + const review = getReview(db, a.id, s.id) + const auto = latest?.status === 'done' ? latest.autoScore : null + const manual = manualScore(review) + return [a.id, { submitted: latest !== null, auto, manual, total: latest ? (auto ?? 0) + (manual ?? 0) : null }] + }), + ), + })), + } +} diff --git a/server/test/api.test.ts b/server/test/api.test.ts index a8a0c85..3c4d15e 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -339,3 +339,69 @@ describe('作业', () => { expect((await student.get('/api/assignments')).body).toEqual([]) }) }) + +describe('评阅与成绩', () => { + const SAMPLE = 'p4-t3-报警灯' + const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释', points: 5 }] } + + async function submitted() { + const t = await teacher() + const classId = await classWithStudents(t, [ + { username: 's1', name: '甲', password: 'p1' }, + { username: 's2', name: '乙', password: 'p2' }, + ]) + const files = (await loadSample(SAMPLE))! + const { body } = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '报警灯', startMode: 'ui', template: files, scoring, deadline: null }) + const student = new Browser() + await student.login('s1', 'p1') + await student.post(`/api/assignments/${body.id}/submit`, { files }) + await queue.idle() + const userId = (await student.get('/api/me')).body.user.id as number + return { t, student, classId, assignmentId: body.id as number, userId } + } + + test('老师看到学生的代码和评分结果,打分、写评语;学生能看到老师的评分和评语', async () => { + const { t, student, assignmentId, userId } = await submitted() + const detail = (await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body + expect(detail).toMatchObject({ student: { name: '甲' }, submission: { status: 'done', autoScore: 20 }, review: null, submissionCount: 1 }) + expect(detail.files.some((f: any) => f.path === 'Form1.cs')).toBe(true) + + // 超出满分拒绝 + const tooHigh = await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [11, 5], comment: '' }) + expect(tooHigh.status).toBe(400) + expect(tooHigh.body.message).toBe('"窗体界面"的得分要在 0 到 10 之间') + + const saved = await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, null], comment: '界面整齐,注释还要补' }) + expect(saved.body).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' }) + + expect((await student.get(`/api/assignments/${assignmentId}`)).body.review).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' }) + expect((await student.get('/api/assignments')).body[0].manualScore).toBe(8) + expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({ username: 's1', manualScore: 8, review: { comment: '界面整齐,注释还要补' } }) + }, 60000) + + test('成绩表:总分 = 自动评分 + 老师评分;没提交的没有总分', async () => { + const { t, classId, assignmentId, userId } = await submitted() + await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, 4], comment: '' }) + const book = (await t.get(`/api/teacher/classes/${classId}/gradebook`)).body + expect(book.assignments).toEqual([{ id: assignmentId, title: '报警灯', total: 35 }]) + expect(book.students.map((s: any) => [s.name, s.scores[assignmentId].total])).toEqual([ + ['甲', 32], + ['乙', null], + ]) + }, 60000) + + test('老师减少评分项后,已打的分按新的评分项对齐', async () => { + const { t, assignmentId, userId } = await submitted() + await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, 4], comment: '' }) + await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '报警灯', deadline: null, scoring: { auto: [10, 10], manual: [{ title: '窗体界面', points: 5 }] } }) + expect((await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body.review.scores).toEqual([5]) + }, 60000) + + test('老师能取到提交的代码(在实验页里运行);学生不能', async () => { + const { t, student, assignmentId, userId } = await submitted() + const { submission } = (await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body + const files = await t.get(`/api/teacher/submissions/${submission.id}`) + expect(files.body).toMatchObject({ assignmentTitle: '报警灯', studentName: '甲' }) + expect((await student.get(`/api/teacher/submissions/${submission.id}`)).status).toBe(403) + }, 60000) +}) diff --git a/shared/grading.ts b/shared/grading.ts index cf92799..cd8267c 100644 --- a/shared/grading.ts +++ b/shared/grading.ts @@ -52,3 +52,10 @@ export interface Submission { /** 排队评分时前面还有几份 */ queueAhead?: number } + +/** 老师评阅:老师评分项各自的得分(null 表示还没打分,和 scoring.manual 一一对应)和评语 */ +export interface Review { + scores: (number | null)[] + comment: string + updatedAt: number +} diff --git a/web/src/editor/CodeEditor.vue b/web/src/editor/CodeEditor.vue index f512350..403ea75 100644 --- a/web/src/editor/CodeEditor.vue +++ b/web/src/editor/CodeEditor.vue @@ -9,6 +9,8 @@ const props = defineProps<{ files: SourceFile[] activeFile?: string diagnostics: Diagnostic[] + /** 只读(老师查看学生提交的代码) */ + readOnly?: boolean }>() const emit = defineEmits<{ @@ -96,6 +98,7 @@ onMounted(() => { scrollBeyondLastLine: false, renderWhitespace: 'selection', unicodeHighlight: { ambiguousCharacters: false }, + readOnly: props.readOnly, }) syncModels() showActive() diff --git a/web/src/grading/GradeResultView.vue b/web/src/grading/GradeResultView.vue index 6840b83..48add47 100644 --- a/web/src/grading/GradeResultView.vue +++ b/web/src/grading/GradeResultView.vue @@ -1,13 +1,19 @@ @@ -34,9 +40,22 @@ const manual = computed(() => props.assignment.scoring.manual) - - 由老师评分:{{ m.title }}({{ m.points }} 分) - + + + + 老师评分 {{ manualScore }} / {{ manualTotal }} 分 + + + + {{ m.title }}{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分 + + + 老师评语:{{ review.comment }} + + + 由老师评分:{{ m.title }}({{ m.points }} 分) + + @@ -99,6 +118,21 @@ const manual = computed(() => props.assignment.scoring.manual) color: #b91c1c; } +.reviewed { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; + border-top: 1px solid #f0f0f0; +} + +.comment { + padding: 8px 10px; + background: #f9fafb; + border-radius: 4px; + white-space: pre-wrap; +} + .manual { color: #6b7280; } diff --git a/web/src/pages/AssignmentsPage.vue b/web/src/pages/AssignmentsPage.vue index cfc28aa..1f9d8e8 100644 --- a/web/src/pages/AssignmentsPage.vue +++ b/web/src/pages/AssignmentsPage.vue @@ -7,7 +7,7 @@ import type { Assignment, Submission } from '@iot/shared' import { api } from '../api' import { currentUser, logout } from '../auth' -type Row = Assignment & { latest: Submission | null } +type Row = Assignment & { latest: Submission | null; manualScore: number | null } const router = useRouter() const rows = ref([]) @@ -27,6 +27,10 @@ function status(row: Row) { if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交') if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中') if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错') + if (row.manualScore !== null) { + const all = total(row) + row.scoring.manual.reduce((a, m) => a + m.points, 0) + return h(NTag, { type: 'success', size: 'small' }, () => `老师已评阅 · 总分 ${(s.autoScore ?? 0) + row.manualScore!} / ${all}`) + } const full = s.autoScore === total(row) return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${s.autoScore} / ${total(row)}`) } diff --git a/web/src/pages/ReviewPage.vue b/web/src/pages/ReviewPage.vue new file mode 100644 index 0000000..6214b98 --- /dev/null +++ b/web/src/pages/ReviewPage.vue @@ -0,0 +1,440 @@ + + + + + + ← 班级管理 + {{ assignment?.title }} + {{ assignment?.className }} + 已提交 {{ rows.filter((r) => r.status).length }} / {{ rows.length }} 人 + 导出成绩 + + {{ currentUser?.name }} + 退出 + + + + + + + + {{ detail.student.name }} + {{ detail.student.username }} + 提交 {{ detail.submissionCount }} 次 · 最后提交 {{ formatTime(detail.submission.createdAt) }} + + 上一个 + 下一个 + 运行他的程序 + + + + + + (activeFile = v)"> + {{ f.path }} + + + + + + + 自动评分 + + + + + 规范提示 + + {{ h.text }} + + + + + 老师评分 + + {{ m.title }} + + / {{ m.points }} + + + + + 总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }} + + + 保存 + 保存并看下一个 + + + + + + + + + + + + + diff --git a/web/src/pages/TeacherPage.vue b/web/src/pages/TeacherPage.vue index e8d0428..b088574 100644 --- a/web/src/pages/TeacherPage.vue +++ b/web/src/pages/TeacherPage.vue @@ -1,11 +1,13 @@ + + + + + 总分 = 最近一次提交的自动评分 + 老师评分;"待评"表示老师还没评阅,只含自动评分。 + 导出成绩表(CSV) + + + + + + + diff --git a/web/src/teacher/SubmissionsDialog.vue b/web/src/teacher/SubmissionsDialog.vue deleted file mode 100644 index d4f397d..0000000 --- a/web/src/teacher/SubmissionsDialog.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - 已提交 {{ submitted }} / {{ rows.length }} 人 - - - - - diff --git a/web/src/teacher/conventions.test.ts b/web/src/teacher/conventions.test.ts new file mode 100644 index 0000000..cf8bc11 --- /dev/null +++ b/web/src/teacher/conventions.test.ts @@ -0,0 +1,32 @@ +/// +import { expect, test } from 'bun:test' +import { conventionHints } from './conventions' + +const designer = (fields: string) => ({ path: 'Form1.Designer.cs', content: `partial class Form1\n{\n${fields}\n}` }) + +test('代码里用到的控件还是默认名字时提示;界面上的标签、教材的设备控件不提示', () => { + const files = [ + designer( + [ + 'private System.Windows.Forms.Button button1;', + 'private System.Windows.Forms.Label label1;', + 'private System.Windows.Forms.TextBox txt_SendText;', + 'private WinFormsDeviceControlLibrary.Lamp lamp1;', + 'this.button1.Click += new System.EventHandler(this.button1_Click);', + ].join('\n'), + ), + { path: 'Form1.cs', content: 'void button1_Click()\n{\n // 打开灯\n lamp1.OnOff(true);\n txt_SendText.Text = "";\n}' }, + ] + expect(conventionHints(files)).toEqual([ + { level: 'warn', text: '代码里用到的控件还是默认名字:button1(书上要求"控件缩写 + 有意义的英文",如 btn_Open)' }, + { level: 'info', text: '注释 1 行,代码 3 行' }, + ]) +}) + +test('没有注释时提示;Designer.cs 和 Program.cs 不算学生写的代码', () => { + const files = [ + { path: 'Program.cs', content: '// 程序入口\nstatic void Main() { }' }, + { path: 'Form1.cs', content: 'public Form1()\n{\n InitializeComponent();\n}' }, + ] + expect(conventionHints(files)).toEqual([{ level: 'warn', text: '没有写注释(代码 2 行)' }]) +}) diff --git a/web/src/teacher/conventions.ts b/web/src/teacher/conventions.ts new file mode 100644 index 0000000..4763f9b --- /dev/null +++ b/web/src/teacher/conventions.ts @@ -0,0 +1,46 @@ +// 规范提示:给老师评"注释、命名"时参考,不计分。 +// - 控件命名:在代码里用到、却还是默认名字(button1、textBox1……)的控件。书上要求"控件缩写 + 有意义的英文", +// 如 btn_Open;只放在界面上、代码里不用的标签、分组框可以保留默认名字(书上也是这样), +// 教材自带的设备控件(lamp1、alarm1)书上就这样命名,也不提示。 +// - 注释:学生自己写的代码文件(不含 Designer.cs、Program.cs)里有多少行注释。 +import type { SourceFile } from '@iot/shared' + +export interface ConventionHint { + level: 'warn' | 'info' + text: string +} + +const DEFAULT_NAME = /^(button|label|textBox|comboBox|checkBox|radioButton|listBox)\d+$/ + +const studentCode = (files: SourceFile[]) => files.filter((f) => f.path.endsWith('.cs') && !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs') + +export function conventionHints(files: SourceFile[]): ConventionHint[] { + const hints: ConventionHint[] = [] + const code = studentCode(files) + const codeText = code.map((f) => f.content).join('\n') + + const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).map((f) => f.content) + const controls = designers.flatMap((text) => [...text.matchAll(/^\s*private\s+[\w.]+\s+(\w+);/gm)].map((m) => m[1])) + // 绑定了事件(this.button1.Click += ...)或在代码里用到,就算"用到" + const withEvents = new Set(designers.flatMap((text) => [...text.matchAll(/this\.(\w+)\.\w+\s*\+=/g)].map((m) => m[1]))) + const usedInCode = (name: string) => new RegExp(`(? DEFAULT_NAME.test(name) && (withEvents.has(name) || usedInCode(name))) + if (defaults.length) { + hints.push({ level: 'warn', text: `代码里用到的控件还是默认名字:${defaults.join('、')}(书上要求"控件缩写 + 有意义的英文",如 btn_Open)` }) + } + + let comments = 0 + let lines = 0 + for (const line of codeText.split('\n').map((l) => l.trim())) { + if (!line || line === '{' || line === '}' || line.startsWith('using ')) continue + if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) comments++ + else { + lines++ + if (line.includes('//')) comments++ + } + } + if (lines > 0) { + hints.push(comments === 0 ? { level: 'warn', text: `没有写注释(代码 ${lines} 行)` } : { level: 'info', text: `注释 ${comments} 行,代码 ${lines} 行` }) + } + return hints +} diff --git a/web/src/teacher/csv.ts b/web/src/teacher/csv.ts new file mode 100644 index 0000000..c65a54d --- /dev/null +++ b/web/src/teacher/csv.ts @@ -0,0 +1,14 @@ +// 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。 +// 学号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。 +export function downloadCsv(filename: string, rows: (string | number | null)[][]) { + const quote = (value: string | number | null) => { + const cell = value === null ? '' : String(value) + return /[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell + } + const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n' + const link = document.createElement('a') + link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' })) + link.download = filename + link.click() + URL.revokeObjectURL(link.href) +}
已提交 {{ submitted }} / {{ rows.length }} 人