任务说明:实验页"任务"面板、布置作业时可改写;先写项目 3 任务 2、项目 6 任务 2 两份
- 每个实验一份 task.md(任务目标、控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试) - 工具栏"评价项"改成"任务":上面是任务说明,下面是评价项和自己检查;不遮挡页面,第一次打开实验时自动打开 - 自己写的 Markdown 子集渲染,先转义全部 HTML(老师可以改写说明) - 作业保存任务说明(新列 task),以前布置的作业用实验现在的说明 - 测试:任务说明表格里的控件名都在示例的 Designer.cs 里 - 修改作业时实验下拉框显示标题而不是编号 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b9f635c35
commit
90b739a2c7
17 files changed
+759
-219
No files matched your search
+25
-6
@@ -25,7 +25,7 @@ import { getReview, gradebook, manualScore, saveReview, studentDetail, submissio
|
||||
import type { GradingQueue } from './grading/queue'
|
||||
import { config } from './config'
|
||||
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { listSamples, loadChecks, loadSample } from './samples'
|
||||
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
|
||||
import { SqlError, sqlService } from './sql-service'
|
||||
import { serveStatic } from './static'
|
||||
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
|
||||
@@ -112,6 +112,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
return c.json(parseChecks(checks).map((check) => check.title))
|
||||
})
|
||||
|
||||
/** 实验的任务说明(Markdown);没有时 task 为 null */
|
||||
app.get('/api/samples/:id/task', async (c) => c.json({ task: await loadTask(c.req.param('id')) }))
|
||||
|
||||
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
|
||||
@@ -202,11 +205,18 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/api/assignments/:id', (c) => {
|
||||
app.get('/api/assignments/:id', async (c) => {
|
||||
const assignment = visibleAssignment(c)
|
||||
if (!assignment) return c.json({ message: '作业不存在' }, 404)
|
||||
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) })
|
||||
// 以前布置的作业没有保存任务说明,用实验现在的说明
|
||||
const task = assignment.task ?? (await loadTask(assignment.sampleId))
|
||||
return c.json({
|
||||
assignment: { ...assignment, task },
|
||||
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) => {
|
||||
@@ -298,7 +308,7 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
)
|
||||
|
||||
app.post('/api/teacher/assignments', async (c) => {
|
||||
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null }>(c)
|
||||
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c)
|
||||
if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404)
|
||||
const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null
|
||||
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
|
||||
@@ -313,13 +323,22 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
checks,
|
||||
scoring: a.scoring as Scoring,
|
||||
deadline: typeof a.deadline === 'number' ? a.deadline : null,
|
||||
task: typeof a.task === 'string' ? a.task : null,
|
||||
})
|
||||
return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result)
|
||||
})
|
||||
|
||||
app.patch('/api/teacher/assignments/:id', async (c) => {
|
||||
const a = await body<{ title: string; deadline: number | null; scoring: Scoring }>(c)
|
||||
return reply(c, updateAssignment(db, id(c), { title: String(a.title ?? ''), deadline: typeof a.deadline === 'number' ? a.deadline : null, scoring: a.scoring as Scoring }))
|
||||
const a = await body<{ title: string; deadline: number | null; scoring: Scoring; task: string | null }>(c)
|
||||
return reply(
|
||||
c,
|
||||
updateAssignment(db, id(c), {
|
||||
title: String(a.title ?? ''),
|
||||
deadline: typeof a.deadline === 'number' ? a.deadline : null,
|
||||
scoring: a.scoring as Scoring,
|
||||
task: typeof a.task === 'string' ? a.task : null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
app.delete('/api/teacher/assignments/:id', (c) => {
|
||||
|
||||
@@ -15,10 +15,11 @@ interface AssignmentRow {
|
||||
scoring: string
|
||||
deadline: number | null
|
||||
created_at: number
|
||||
task: string | null
|
||||
}
|
||||
|
||||
const SELECT_ASSIGNMENT = `
|
||||
SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at
|
||||
SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at, a.task
|
||||
FROM assignments a JOIN classes c ON c.id = a.class_id`
|
||||
|
||||
function toAssignment(row: AssignmentRow): Assignment {
|
||||
@@ -33,6 +34,7 @@ function toAssignment(row: AssignmentRow): Assignment {
|
||||
createdAt: row.created_at,
|
||||
checkTitles: parseChecks(row.checks).map((c) => c.title),
|
||||
scoring: JSON.parse(row.scoring) as Scoring,
|
||||
task: row.task,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,32 +78,46 @@ export interface NewAssignment {
|
||||
checks: string
|
||||
scoring: Scoring
|
||||
deadline: number | null
|
||||
/** 任务说明(Markdown),null 表示用实验的说明 */
|
||||
task: string | null
|
||||
}
|
||||
|
||||
/** 任务说明最长多少字 */
|
||||
export const MAX_TASK_LENGTH = 20_000
|
||||
|
||||
export function createAssignment(db: Database, a: NewAssignment): { id: number } | { error: string } {
|
||||
if (!a.title.trim()) return { error: '作业名称不能为空' }
|
||||
const error = scoringError(a.scoring, parseChecks(a.checks).length)
|
||||
const error = scoringError(a.scoring, parseChecks(a.checks).length) ?? taskError(a.task)
|
||||
if (error) return { error }
|
||||
const result = db
|
||||
.query(
|
||||
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at, task)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now())
|
||||
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now(), a.task)
|
||||
return { id: Number(result.lastInsertRowid) }
|
||||
}
|
||||
|
||||
/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */
|
||||
export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } {
|
||||
function taskError(task: string | null) {
|
||||
return task !== null && task.length > MAX_TASK_LENGTH ? `任务说明不能超过 ${MAX_TASK_LENGTH} 字` : null
|
||||
}
|
||||
|
||||
/** 修改名称、截止时间、分值、任务说明(起点工程和评分规则布置后不能改) */
|
||||
export function updateAssignment(
|
||||
db: Database,
|
||||
id: number,
|
||||
changes: { title: string; deadline: number | null; scoring: Scoring; task: string | null },
|
||||
): { error?: string } {
|
||||
const checks = assignmentChecks(db, id)
|
||||
if (!checks) return { error: '作业不存在' }
|
||||
if (!changes.title.trim()) return { error: '作业名称不能为空' }
|
||||
const error = scoringError(changes.scoring, checks.length)
|
||||
const error = scoringError(changes.scoring, checks.length) ?? taskError(changes.task)
|
||||
if (error) return { error }
|
||||
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run(
|
||||
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ?, task = ? WHERE id = ?').run(
|
||||
changes.title.trim(),
|
||||
changes.deadline,
|
||||
JSON.stringify(changes.scoring),
|
||||
changes.task,
|
||||
id,
|
||||
)
|
||||
// 分值变了,已评分的提交重新算分
|
||||
|
||||
@@ -101,6 +101,10 @@ const MIGRATIONS = [
|
||||
ALTER TABLE submissions ADD COLUMN screenshots TEXT;
|
||||
ALTER TABLE submissions ADD COLUMN ai_review TEXT;
|
||||
`,
|
||||
// 作业的任务说明(Markdown);以前布置的作业为 NULL,用实验现在的说明
|
||||
`
|
||||
ALTER TABLE assignments ADD COLUMN task TEXT;
|
||||
`,
|
||||
]
|
||||
|
||||
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
|
||||
|
||||
@@ -33,6 +33,14 @@ export async function loadSample(id: string): Promise<SourceFile[] | null> {
|
||||
return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null
|
||||
}
|
||||
|
||||
/** 示例的任务说明(task.md 原文,Markdown);没有说明返回 null */
|
||||
export async function loadTask(id: string): Promise<string | null> {
|
||||
const path = resolve(SAMPLES_DIR, id, 'task.md')
|
||||
if (!path.startsWith(`${SAMPLES_DIR}/`)) return null
|
||||
const file = Bun.file(path)
|
||||
return (await file.exists()) ? file.text() : null
|
||||
}
|
||||
|
||||
/** 示例的评分规则(checks.yaml 原文);没有规则返回 null */
|
||||
export async function loadChecks(id: string): Promise<string | null> {
|
||||
const path = resolve(SAMPLES_DIR, id, 'checks.yaml')
|
||||
|
||||
+23
-1
@@ -9,7 +9,7 @@ import { openDatabase } from '../src/db'
|
||||
import { createGradingQueue, type GradingQueue } from '../src/grading/queue'
|
||||
import { languageService } from '../src/language-service'
|
||||
import { sqlService } from '../src/sql-service'
|
||||
import { loadSample } from '../src/samples'
|
||||
import { loadSample, loadTask } from '../src/samples'
|
||||
|
||||
let app: ReturnType<typeof createApp>
|
||||
let queue: GradingQueue
|
||||
@@ -287,6 +287,28 @@ describe('作业', () => {
|
||||
return { t, student, classId, assignmentId: created.body.id as number, files: template }
|
||||
}
|
||||
|
||||
test('任务说明:布置时保存,老师可以改;没有保存的作业用实验现在的说明', async () => {
|
||||
const { t, student, classId, assignmentId } = await setup()
|
||||
// setup 没传任务说明(像加这个功能之前布置的作业)
|
||||
expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe(await loadTask(SAMPLE))
|
||||
|
||||
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring, task: '## 老师要求\n只做前 3 步' })
|
||||
expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe('## 老师要求\n只做前 3 步')
|
||||
|
||||
const tooLong = await t.post('/api/teacher/assignments', {
|
||||
classId,
|
||||
sampleId: SAMPLE,
|
||||
title: '太长',
|
||||
startMode: 'ui',
|
||||
template: [],
|
||||
scoring,
|
||||
deadline: null,
|
||||
task: 'x'.repeat(20_001),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
expect(tooLong.body.message).toContain('任务说明')
|
||||
})
|
||||
|
||||
test('分值项数要和评分规则一致', async () => {
|
||||
const t = await teacher()
|
||||
const classId = await classWithStudents(t, [])
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// 示例的任务说明(task.md):表格里写的控件名都要在示例的 Designer.cs 里有,学生照着改名不会被带错。
|
||||
import { expect, test } from 'bun:test'
|
||||
import { listSamples, loadSample, loadTask } from '../src/samples'
|
||||
|
||||
/** 控件表格(表头有"名称")里那一列用 `反引号` 写的名字 */
|
||||
function tableNames(task: string): string[] {
|
||||
const names: string[] = []
|
||||
let column = -1
|
||||
for (const line of task.split('\n')) {
|
||||
if (!line.trim().startsWith('|')) {
|
||||
column = -1
|
||||
continue
|
||||
}
|
||||
const cells = line.trim().replace(/^\||\|$/g, '').split('|')
|
||||
const header = cells.findIndex((c) => c.includes('名称'))
|
||||
if (header >= 0) column = header
|
||||
else if (column >= 0) names.push(...[...(cells[column] ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1]))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
test('任务说明里的控件名都在示例的界面里', async () => {
|
||||
let checked = 0
|
||||
for (const { id } of await listSamples()) {
|
||||
const task = await loadTask(id)
|
||||
if (!task) continue
|
||||
const designer = (await loadSample(id))!.filter((f) => f.path.endsWith('.Designer.cs'))
|
||||
const names = tableNames(task)
|
||||
expect(names.length, `${id} 的任务说明没有控件表格`).toBeGreaterThan(0)
|
||||
for (const name of names) {
|
||||
// 窗体本身写的是类名,控件是 this.名字
|
||||
const found = designer.some((f) => f.content.includes(`this.${name} `) || f.content.includes(`partial class ${name}`))
|
||||
expect(found, `${id}:任务说明里的 ${name} 在 Designer.cs 里找不到`).toBe(true)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
expect(checked).toBeGreaterThan(0)
|
||||
})
|
||||
Reference in new issue
Block a user