评阅与成绩:老师看代码、运行学生程序、打分、写评语;成绩表与导出
- 评阅页:全班学生列表(提交状态、得分、是否已评阅);选中学生的代码(只读)、自动评分结果、 规范提示(代码里用到的默认名字控件、注释行数)、老师评分项打分和评语,"保存并看下一个" - "运行他的程序":新标签页里打开学生的提交,可以操作实训台,改动不保存 - 学生能看到老师的各项得分和评语;作业列表显示总分 - 班级管理页"成绩"标签页:学生 × 作业的总分,点分数进入评阅;单个作业和全班成绩导出 CSV - 老师增删评分项后,已打的分按新的评分项对齐 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e6b0d5cb2
commit
89e9956a22
20 files changed
+999
-127
No files matched your search
+35
-4
@@ -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))
|
||||
|
||||
|
||||
+12
-1
@@ -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:" 时用内存数据库(测试用) */
|
||||
|
||||
@@ -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<number>((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 }]
|
||||
}),
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user