布置作业、提交与自动评分
- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项); 起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照 - 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交 - 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示 - 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分 - 串口列表为空时提示在 Load 或 Click 事件里填充 - 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
37fc747b49
commit
4e6b0d5cb2
23 files changed
+1439
-50
No files matched your search
@@ -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 名单导入、学生作品保存在服务器上。
|
||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分、老师查看提交情况。
|
||||
|
||||
尚未完成:
|
||||
|
||||
|
||||
+3
-1
@@ -234,7 +234,7 @@ IOT/
|
||||
flowchart TB
|
||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔<br/>布置与提交作业、自动评测、老师看板、成绩导出;一个班试用"]
|
||||
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>老师看板(看代码、运行、老师评分)、成绩导出;一个班试用"]
|
||||
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||
@@ -271,6 +271,8 @@ flowchart TB
|
||||
**第 2 阶段进展**:
|
||||
|
||||
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||
- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。
|
||||
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
+116
-4
@@ -3,7 +3,21 @@ import type { Database } from 'bun:sqlite'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { bearerToken, login, logout, refresh, tokenUser, tooManyFailures, type Tokens, type User } from './auth'
|
||||
import { createClass, deleteClass, deleteStudent, importStudents, listClasses, listStudents, renameClass, resetPassword, type ImportRow } from './classes'
|
||||
import { listSamples, loadSample } from './samples'
|
||||
import type { Scoring, StartMode } from '@iot/shared'
|
||||
import {
|
||||
assignmentTemplate,
|
||||
classSubmissions,
|
||||
createAssignment,
|
||||
createSubmission,
|
||||
deleteAssignment,
|
||||
getAssignment,
|
||||
latestSubmission,
|
||||
listAssignments,
|
||||
updateAssignment,
|
||||
} from './assignments'
|
||||
import { parseChecks } from './grading/checks'
|
||||
import type { GradingQueue } from './grading/queue'
|
||||
import { listSamples, loadChecks, loadSample } from './samples'
|
||||
import { serveStatic } from './static'
|
||||
import { deleteWork, getWork, saveWork, validFiles } from './works'
|
||||
|
||||
@@ -22,7 +36,7 @@ function clientIp(c: AppContext) {
|
||||
/** 读取 JSON 请求体,格式不对时返回空对象 */
|
||||
const body = <T>(c: AppContext) => c.req.json<Partial<T>>().catch(() => ({}) as Partial<T>)
|
||||
|
||||
export function createApp(db: Database) {
|
||||
export function createApp(db: Database, gradingQueue: GradingQueue) {
|
||||
const app = new Hono<{ Bindings: Env; Variables: { user: User } }>()
|
||||
|
||||
app.post('/api/login', async (c) => {
|
||||
@@ -82,14 +96,62 @@ export function createApp(db: Database) {
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
// ---- 作业(学生) ----
|
||||
/** 当前用户能不能看这个作业:老师都能看,学生只能看自己班的 */
|
||||
const id = (c: AppContext) => Number(c.req.param('id'))
|
||||
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
|
||||
|
||||
const visibleAssignment = (c: AppContext) => {
|
||||
const assignment = getAssignment(db, id(c))
|
||||
const user = c.get('user')
|
||||
return assignment && (user.role === 'teacher' || assignment.classId === user.classId) ? assignment : null
|
||||
}
|
||||
|
||||
// 自己班的作业,带上自己最近一次提交的状态
|
||||
app.get('/api/assignments', (c) => {
|
||||
const user = c.get('user')
|
||||
if (user.classId === null) return c.json([])
|
||||
return c.json(
|
||||
listAssignments(db, user.classId).map((a) => {
|
||||
const latest = latestSubmission(db, a.id, user.id)
|
||||
return { ...a, latest: latest && { ...latest, result: null } }
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
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) })
|
||||
})
|
||||
|
||||
app.post('/api/assignments/:id/submit', async (c) => {
|
||||
const assignment = visibleAssignment(c)
|
||||
if (!assignment) return c.json({ message: '作业不存在' }, 404)
|
||||
const user = c.get('user')
|
||||
if (user.role === 'student' && assignment.deadline !== null && Date.now() > assignment.deadline) {
|
||||
return c.json({ message: '已经过了截止时间,不能再提交' }, 400)
|
||||
}
|
||||
const { files } = await body<{ files: unknown }>(c)
|
||||
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
|
||||
const submissionId = createSubmission(db, assignment.id, user.id, files)
|
||||
if (submissionId === null) return c.json({ message: '上一次提交还在评分,请等评分结束再提交' }, 409)
|
||||
gradingQueue.enqueue(submissionId)
|
||||
return c.json(latestSubmission(db, assignment.id, user.id))
|
||||
})
|
||||
|
||||
app.get('/api/assignments/:id/submission', (c) => {
|
||||
const assignment = visibleAssignment(c)
|
||||
if (!assignment) return c.json({ message: '作业不存在' }, 404)
|
||||
return c.json(latestSubmission(db, assignment.id, c.get('user').id))
|
||||
})
|
||||
|
||||
// ---- 老师 ----
|
||||
app.use('/api/teacher/*', async (c, next) => {
|
||||
if (c.get('user').role !== 'teacher') return c.json({ message: '只有老师能使用' }, 403)
|
||||
await next()
|
||||
})
|
||||
|
||||
const id = (c: AppContext) => Number(c.req.param('id'))
|
||||
const reply = (c: AppContext, result: { error?: string }) => (result.error ? c.json({ message: result.error }, 400) : c.json(result))
|
||||
|
||||
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
|
||||
|
||||
@@ -127,6 +189,56 @@ export function createApp(db: Database) {
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
// 示例的自动评分项名称(布置作业时设置分值用)
|
||||
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(
|
||||
listAssignments(db, id(c)).map((a) => {
|
||||
const students = classSubmissions(db, a.id)
|
||||
return { ...a, studentCount: students.length, submittedCount: students.filter((s) => s.status).length }
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
if (a.startMode !== 'blank' && a.startMode !== 'ui') return c.json({ message: '起点只能是空白工程或界面已做好' }, 400)
|
||||
if (!validFiles(a.template)) return c.json({ message: '起点工程格式不对' }, 400)
|
||||
const result = createAssignment(db, {
|
||||
classId: a.classId!,
|
||||
sampleId: a.sampleId!,
|
||||
title: String(a.title ?? ''),
|
||||
startMode: a.startMode,
|
||||
template: a.template,
|
||||
checks,
|
||||
scoring: a.scoring as Scoring,
|
||||
deadline: typeof a.deadline === 'number' ? a.deadline : 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 }))
|
||||
})
|
||||
|
||||
app.delete('/api/teacher/assignments/:id', (c) => {
|
||||
deleteAssignment(db, id(c))
|
||||
return c.json({})
|
||||
})
|
||||
|
||||
// 全班每个学生最近一次提交的情况
|
||||
app.get('/api/teacher/assignments/:id/submissions', (c) => c.json(classSubmissions(db, id(c))))
|
||||
|
||||
app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404))
|
||||
|
||||
// 打包好的网页(部署时);开发时网页由 Vite 提供
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 作业:老师把一个实验布置给一个班,学生提交后自动评分。
|
||||
// 布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import type { Assignment, GradeResult, Scoring, SourceFile, StartMode, Submission } from '@iot/shared'
|
||||
import { parseChecks } from './grading/checks'
|
||||
|
||||
interface AssignmentRow {
|
||||
id: number
|
||||
class_id: number
|
||||
class_name: string
|
||||
sample_id: string
|
||||
title: string
|
||||
start_mode: StartMode
|
||||
checks: string
|
||||
scoring: string
|
||||
deadline: number | null
|
||||
created_at: number
|
||||
}
|
||||
|
||||
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
|
||||
FROM assignments a JOIN classes c ON c.id = a.class_id`
|
||||
|
||||
function toAssignment(row: AssignmentRow): Assignment {
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.class_id,
|
||||
className: row.class_name,
|
||||
sampleId: row.sample_id,
|
||||
title: row.title,
|
||||
startMode: row.start_mode,
|
||||
deadline: row.deadline,
|
||||
createdAt: row.created_at,
|
||||
checkTitles: parseChecks(row.checks).map((c) => c.title),
|
||||
scoring: JSON.parse(row.scoring) as Scoring,
|
||||
}
|
||||
}
|
||||
|
||||
export function listAssignments(db: Database, classId: number): Assignment[] {
|
||||
return db.query<AssignmentRow, [number]>(`${SELECT_ASSIGNMENT} WHERE a.class_id = ? ORDER BY a.created_at DESC`).all(classId).map(toAssignment)
|
||||
}
|
||||
|
||||
export function getAssignment(db: Database, id: number): Assignment | null {
|
||||
const row = db.query<AssignmentRow, [number]>(`${SELECT_ASSIGNMENT} WHERE a.id = ?`).get(id)
|
||||
return row ? toAssignment(row) : null
|
||||
}
|
||||
|
||||
/** 评分规则的快照(评分时用) */
|
||||
export function assignmentChecks(db: Database, id: number) {
|
||||
const row = db.query<{ checks: string }, [number]>('SELECT checks FROM assignments WHERE id = ?').get(id)
|
||||
return row ? parseChecks(row.checks) : null
|
||||
}
|
||||
|
||||
export function assignmentTemplate(db: Database, id: number): SourceFile[] | null {
|
||||
const row = db.query<{ template: string }, [number]>('SELECT template FROM assignments WHERE id = ?').get(id)
|
||||
return row ? (JSON.parse(row.template) as SourceFile[]) : null
|
||||
}
|
||||
|
||||
/** 检查分值:自动评分项个数要和规则一致,分值是不小于 0 的数,老师评分项要有名称 */
|
||||
function scoringError(scoring: Scoring, checkCount: number): string | null {
|
||||
if (!Array.isArray(scoring?.auto) || scoring.auto.length !== checkCount) return `自动评分项应有 ${checkCount} 项`
|
||||
if (!Array.isArray(scoring.manual)) return '老师评分项格式不对'
|
||||
const valid = (p: unknown) => typeof p === 'number' && Number.isFinite(p) && p >= 0
|
||||
if (!scoring.auto.every(valid) || !scoring.manual.every((m) => valid(m?.points))) return '分值要填不小于 0 的数'
|
||||
if (scoring.manual.some((m) => typeof m.title !== 'string' || !m.title.trim())) return '老师评分项要填名称'
|
||||
return null
|
||||
}
|
||||
|
||||
export interface NewAssignment {
|
||||
classId: number
|
||||
sampleId: string
|
||||
title: string
|
||||
startMode: StartMode
|
||||
template: SourceFile[]
|
||||
/** checks.yaml 的原文 */
|
||||
checks: string
|
||||
scoring: Scoring
|
||||
deadline: number | null
|
||||
}
|
||||
|
||||
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)
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now())
|
||||
return { id: Number(result.lastInsertRowid) }
|
||||
}
|
||||
|
||||
/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */
|
||||
export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } {
|
||||
const checks = assignmentChecks(db, id)
|
||||
if (!checks) return { error: '作业不存在' }
|
||||
if (!changes.title.trim()) return { error: '作业名称不能为空' }
|
||||
const error = scoringError(changes.scoring, checks.length)
|
||||
if (error) return { error }
|
||||
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run(
|
||||
changes.title.trim(),
|
||||
changes.deadline,
|
||||
JSON.stringify(changes.scoring),
|
||||
id,
|
||||
)
|
||||
// 分值变了,已评分的提交重新算分
|
||||
for (const s of db.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`).all(id)) {
|
||||
db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function deleteAssignment(db: Database, id: number) {
|
||||
db.query('DELETE FROM assignments WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
/** 通过的自动评分项的分值之和 */
|
||||
export function autoScore(result: GradeResult, scoring: Scoring) {
|
||||
return result.results.reduce((sum, r, i) => sum + (r.passed ? (scoring.auto[i] ?? 0) : 0), 0)
|
||||
}
|
||||
|
||||
// ---------- 提交 ----------
|
||||
|
||||
interface SubmissionRow {
|
||||
id: number
|
||||
status: Submission['status']
|
||||
result: string | null
|
||||
auto_score: number | null
|
||||
created_at: number
|
||||
}
|
||||
|
||||
function toSubmission(db: Database, row: SubmissionRow): Submission {
|
||||
const submission: Submission = {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
result: row.result ? (JSON.parse(row.result) as GradeResult) : null,
|
||||
autoScore: row.auto_score,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
if (row.status === 'pending') {
|
||||
submission.queueAhead = db.query<{ n: number }, [number]>(`SELECT COUNT(*) AS n FROM submissions WHERE status = 'pending' AND id < ?`).get(row.id)!.n
|
||||
}
|
||||
return submission
|
||||
}
|
||||
|
||||
/** 某个学生在某个作业上最近一次提交 */
|
||||
export function latestSubmission(db: Database, assignmentId: number, userId: number): Submission | null {
|
||||
const row = db
|
||||
.query<SubmissionRow, [number, number]>(
|
||||
'SELECT id, status, result, auto_score, created_at FROM submissions WHERE assignment_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1',
|
||||
)
|
||||
.get(assignmentId, userId)
|
||||
return row ? toSubmission(db, row) : null
|
||||
}
|
||||
|
||||
/** 新建一份等待评分的提交;同一个人上一份还在评分时返回 null */
|
||||
export function createSubmission(db: Database, assignmentId: number, userId: number, files: SourceFile[]): number | null {
|
||||
if (latestSubmission(db, assignmentId, userId)?.status === 'pending') return null
|
||||
const result = db
|
||||
.query(`INSERT INTO submissions (assignment_id, user_id, files, status, created_at) VALUES (?, ?, ?, 'pending', ?)`)
|
||||
.run(assignmentId, userId, JSON.stringify(files), Date.now())
|
||||
return Number(result.lastInsertRowid)
|
||||
}
|
||||
|
||||
export function pendingSubmissionIds(db: Database): number[] {
|
||||
return db.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`).all().map((r) => r.id)
|
||||
}
|
||||
|
||||
export function submissionForGrading(db: Database, id: number) {
|
||||
const row = db.query<{ assignment_id: number; files: string }, [number]>('SELECT assignment_id, files FROM submissions WHERE id = ?').get(id)
|
||||
return row ? { assignmentId: row.assignment_id, files: JSON.parse(row.files) as SourceFile[] } : null
|
||||
}
|
||||
|
||||
export function saveGrade(db: Database, id: number, result: GradeResult | null) {
|
||||
const row = db.query<{ scoring: string }, [number]>('SELECT a.scoring FROM submissions s JOIN assignments a ON a.id = s.assignment_id WHERE s.id = ?').get(id)
|
||||
if (!row) return
|
||||
if (!result) {
|
||||
db.query(`UPDATE submissions SET status = 'error', graded_at = ? WHERE id = ?`).run(Date.now(), id)
|
||||
return
|
||||
}
|
||||
db.query(`UPDATE submissions SET status = 'done', result = ?, auto_score = ?, graded_at = ? WHERE id = ?`).run(
|
||||
JSON.stringify(result),
|
||||
autoScore(result, JSON.parse(row.scoring) as Scoring),
|
||||
Date.now(),
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
/** 每个学生最近一次提交的状态(老师看提交情况) */
|
||||
export function classSubmissions(db: Database, assignmentId: number) {
|
||||
return db
|
||||
.query<{ user_id: number; username: string; name: string; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number }, [number]>(
|
||||
`SELECT u.id AS user_id, u.username, u.name, s.status, s.auto_score, s.created_at,
|
||||
(SELECT COUNT(*) FROM submissions x WHERE x.assignment_id = ?1 AND x.user_id = u.id) AS count
|
||||
FROM users u
|
||||
JOIN assignments a ON a.id = ?1 AND u.class_id = a.class_id AND u.role = 'student'
|
||||
LEFT JOIN submissions s ON s.id = (SELECT MAX(id) FROM submissions WHERE assignment_id = ?1 AND user_id = u.id)
|
||||
ORDER BY u.username`,
|
||||
)
|
||||
.all(assignmentId)
|
||||
.map((r) => ({ userId: r.user_id, username: r.username, name: r.name, status: r.status, autoScore: r.auto_score, submittedAt: r.created_at, count: r.count }))
|
||||
}
|
||||
+32
-1
@@ -1,4 +1,4 @@
|
||||
// SQLite 数据库:账号、班级、学生作品、平台设置(如 JWT 签名密钥)。
|
||||
// SQLite 数据库:账号、班级、学生作品、作业与提交、平台设置(如 JWT 签名密钥)。
|
||||
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
@@ -54,6 +54,37 @@ const MIGRATIONS = [
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
// 作业:布置时保存起点工程和评分规则的快照,以后改示例不影响已布置的作业。
|
||||
// scoring 是 JSON:{ auto: 每个自动评分项的分值(按规则顺序), manual: [{ title, points }] 老师评分项 }
|
||||
`
|
||||
CREATE TABLE assignments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
class_id INTEGER NOT NULL REFERENCES classes (id) ON DELETE CASCADE,
|
||||
sample_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
start_mode TEXT NOT NULL CHECK (start_mode IN ('blank', 'ui')),
|
||||
template TEXT NOT NULL,
|
||||
checks TEXT NOT NULL,
|
||||
scoring TEXT NOT NULL,
|
||||
deadline INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX assignments_class ON assignments (class_id);
|
||||
-- 提交:status 为 pending(排队评分中)、done(已评分)或 error(评分出错);result 是自动评分结果 JSON
|
||||
CREATE TABLE submissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
assignment_id INTEGER NOT NULL REFERENCES assignments (id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
files TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'done', 'error')),
|
||||
result TEXT,
|
||||
auto_score REAL,
|
||||
created_at INTEGER NOT NULL,
|
||||
graded_at INTEGER
|
||||
);
|
||||
CREATE INDEX submissions_assignment_user ON submissions (assignment_id, user_id, id);
|
||||
CREATE INDEX submissions_status ON submissions (status);
|
||||
`,
|
||||
]
|
||||
|
||||
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||||
// 服务器只有 2 核,全站同时最多跑 GRADE_CONCURRENCY 个检查,其余排队。
|
||||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;2 核的服务器全站同时跑 GRADE_CONCURRENCY 个检查,其余排队。
|
||||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
||||
import { compileService } from '../compile-service'
|
||||
import type { Check, Expectation, Step } from './checks'
|
||||
import { ProgramDriver } from './driver'
|
||||
|
||||
const GRADE_CONCURRENCY = 2
|
||||
const GRADE_CONCURRENCY = 6
|
||||
/** 程序启动后多久内应该显示出窗体 */
|
||||
const START_TIMEOUT = 10_000
|
||||
|
||||
@@ -92,7 +92,9 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
||||
const items = ((node.props.Items as unknown[] | undefined) ?? []).map(String)
|
||||
const index = items.indexOf(step.item)
|
||||
if (index < 0) {
|
||||
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})`)
|
||||
// 串口列表是空的:最常见的原因是没有填充
|
||||
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
|
||||
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
|
||||
}
|
||||
driver.act(node, { action: 'select', index })
|
||||
await driver.settle()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 评分队列:学生提交后立即返回,后台按提交顺序评分(同时最多评 MAX_ACTIVE 份),结果写回数据库。
|
||||
// 服务器重启前没评完的提交,启动时继续评。
|
||||
import type { Database } from 'bun:sqlite'
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { assignmentChecks, pendingSubmissionIds, saveGrade, submissionForGrading } from '../assignments'
|
||||
import { config } from '../config'
|
||||
import { safeJoin } from '../run-session'
|
||||
import { grade } from './grader'
|
||||
|
||||
const MAX_ACTIVE = 3
|
||||
|
||||
export function createGradingQueue(db: Database) {
|
||||
const queue: number[] = []
|
||||
let active = 0
|
||||
let idleWaiters: (() => void)[] = []
|
||||
|
||||
async function gradeOne(id: number) {
|
||||
const submission = submissionForGrading(db, id)
|
||||
const checks = submission && assignmentChecks(db, submission.assignmentId)
|
||||
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)
|
||||
}
|
||||
saveGrade(db, id, await grade(dir, checks))
|
||||
} catch (error) {
|
||||
console.error(`评分出错(提交 ${id}):`, error)
|
||||
saveGrade(db, id, null)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function pump() {
|
||||
while (active < MAX_ACTIVE && queue.length > 0) {
|
||||
const id = queue.shift()!
|
||||
active++
|
||||
void gradeOne(id).finally(() => {
|
||||
active--
|
||||
pump()
|
||||
})
|
||||
}
|
||||
if (active === 0 && queue.length === 0) {
|
||||
for (const resolve of idleWaiters) resolve()
|
||||
idleWaiters = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of pendingSubmissionIds(db)) queue.push(id)
|
||||
pump()
|
||||
|
||||
return {
|
||||
enqueue(id: number) {
|
||||
queue.push(id)
|
||||
pump()
|
||||
},
|
||||
/** 等队列全部评完(测试用) */
|
||||
idle(): Promise<void> {
|
||||
if (active === 0 && queue.length === 0) return Promise.resolve()
|
||||
return new Promise((resolve) => idleWaiters.push(resolve))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type GradingQueue = ReturnType<typeof createGradingQueue>
|
||||
+2
-1
@@ -4,6 +4,7 @@ import { ensureTeacher, tokenUser } from './auth'
|
||||
import { compileService } from './compile-service'
|
||||
import { config } from './config'
|
||||
import { openDatabase } from './db'
|
||||
import { createGradingQueue } from './grading/queue'
|
||||
import { cleanupStaleRuns, RunSession } from './run-session'
|
||||
import { checkSandbox, sandboxError } from './sandbox'
|
||||
|
||||
@@ -13,7 +14,7 @@ if (sandboxError) console.error(sandboxError)
|
||||
|
||||
const db = openDatabase()
|
||||
ensureTeacher(db)
|
||||
const app = createApp(db)
|
||||
const app = createApp(db, createGradingQueue(db))
|
||||
|
||||
interface SocketData {
|
||||
session?: RunSession
|
||||
|
||||
@@ -170,7 +170,7 @@ export class RunSession {
|
||||
}
|
||||
|
||||
/** 拼接路径并拒绝跳出工程目录的文件名 */
|
||||
function safeJoin(base: string, path: string) {
|
||||
export function safeJoin(base: string, path: string) {
|
||||
const target = resolve(base, normalize(path))
|
||||
if (!target.startsWith(`${base}/`)) throw new Error(`非法文件路径:${path}`)
|
||||
return target
|
||||
|
||||
@@ -32,3 +32,11 @@ export async function loadSample(id: string): Promise<SourceFile[] | null> {
|
||||
}
|
||||
return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null
|
||||
}
|
||||
|
||||
/** 示例的评分规则(checks.yaml 原文);没有规则返回 null */
|
||||
export async function loadChecks(id: string): Promise<string | null> {
|
||||
const path = resolve(SAMPLES_DIR, id, 'checks.yaml')
|
||||
if (!path.startsWith(`${SAMPLES_DIR}/`)) return null
|
||||
const file = Bun.file(path)
|
||||
return (await file.exists()) ? file.text() : null
|
||||
}
|
||||
+102
-1
@@ -4,13 +4,17 @@ import { createApp } from '../src/app'
|
||||
import { ensureTeacher } from '../src/auth'
|
||||
import { config } from '../src/config'
|
||||
import { openDatabase } from '../src/db'
|
||||
import { createGradingQueue, type GradingQueue } from '../src/grading/queue'
|
||||
import { loadSample } from '../src/samples'
|
||||
|
||||
let app: ReturnType<typeof createApp>
|
||||
let queue: GradingQueue
|
||||
|
||||
beforeEach(() => {
|
||||
const db = openDatabase(':memory:')
|
||||
ensureTeacher(db)
|
||||
app = createApp(db)
|
||||
queue = createGradingQueue(db)
|
||||
app = createApp(db, queue)
|
||||
})
|
||||
|
||||
/** 一个浏览器:记住访问令牌和刷新令牌 */
|
||||
@@ -238,3 +242,100 @@ describe('令牌', () => {
|
||||
expect((await t.get('/api/me')).status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('作业', () => {
|
||||
const SAMPLE = 'p4-t3-报警灯'
|
||||
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }] }
|
||||
|
||||
/** 老师建班、导入一个学生、布置报警灯作业;返回老师、学生和作业 id */
|
||||
async function setup(deadline: number | null = null) {
|
||||
const t = await teacher()
|
||||
const classId = await classWithStudents(t, [{ username: 's1', name: '甲', password: 'p1' }])
|
||||
const template = (await loadSample(SAMPLE))!
|
||||
const created = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '控制报警灯', startMode: 'ui', template, scoring, deadline })
|
||||
expect(created.status).toBe(200)
|
||||
const student = new Browser()
|
||||
await student.login('s1', 'p1')
|
||||
return { t, student, classId, assignmentId: created.body.id as number, files: template }
|
||||
}
|
||||
|
||||
test('分值项数要和评分规则一致', async () => {
|
||||
const t = await teacher()
|
||||
const classId = await classWithStudents(t, [])
|
||||
const result = await t.post('/api/teacher/assignments', {
|
||||
classId,
|
||||
sampleId: SAMPLE,
|
||||
title: '控制报警灯',
|
||||
startMode: 'ui',
|
||||
template: [],
|
||||
scoring: { auto: [10], manual: [] },
|
||||
deadline: null,
|
||||
})
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.body.message).toBe('自动评分项应有 2 项')
|
||||
expect((await t.get(`/api/teacher/samples/${encodeURIComponent(SAMPLE)}/checks`)).body).toEqual(['能正确打开报警灯', '能正确关闭报警灯'])
|
||||
})
|
||||
|
||||
test('学生能看到自己班的作业和起点工程,别的班看不到', async () => {
|
||||
const { t, assignmentId } = await setup()
|
||||
const s1 = new Browser()
|
||||
await s1.login('s1', 'p1')
|
||||
const list = (await s1.get('/api/assignments')).body
|
||||
expect(list).toHaveLength(1)
|
||||
expect(list[0]).toMatchObject({ title: '控制报警灯', checkTitles: ['能正确打开报警灯', '能正确关闭报警灯'], latest: null })
|
||||
expect((await s1.get(`/api/assignments/${assignmentId}`)).body.template.length).toBeGreaterThan(0)
|
||||
|
||||
const { body: other } = await t.post('/api/teacher/classes', { name: '另一个班' })
|
||||
await t.post(`/api/teacher/classes/${other.id}/import`, { rows: [{ username: 's2', name: '乙', password: 'p2' }] })
|
||||
const s2 = new Browser()
|
||||
await s2.login('s2', 'p2')
|
||||
expect((await s2.get('/api/assignments')).body).toEqual([])
|
||||
expect((await s2.get(`/api/assignments/${assignmentId}`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('提交后排队评分,评完得到每项结果和分数;老师能看到提交情况', async () => {
|
||||
const { t, student, assignmentId, files } = await setup()
|
||||
const submitted = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
|
||||
expect(submitted.body.status).toBe('pending')
|
||||
// 上一份还没评完时不能再交
|
||||
expect((await student.post(`/api/assignments/${assignmentId}/submit`, { files })).status).toBe(409)
|
||||
|
||||
await queue.idle()
|
||||
const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body
|
||||
expect(graded).toMatchObject({ status: 'done', autoScore: 20 })
|
||||
expect(graded.result.results.map((r: any) => r.passed)).toEqual([true, true])
|
||||
|
||||
const submissions = (await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body
|
||||
expect(submissions).toEqual([expect.objectContaining({ username: 's1', status: 'done', autoScore: 20, count: 1 })])
|
||||
expect((await t.get(`/api/teacher/classes/${(await t.get('/api/teacher/classes')).body[0].id}/assignments`)).body[0]).toMatchObject({
|
||||
studentCount: 1,
|
||||
submittedCount: 1,
|
||||
})
|
||||
}, 60000)
|
||||
|
||||
test('只做对一部分时按通过的项得分;老师改分值后重新算分', async () => {
|
||||
const { t, student, assignmentId, files } = await setup()
|
||||
const broken = files.map((f) => (f.path === 'Form1.cs' ? { ...f, content: f.content.replace('AlarmOnOff("Alarm", false);', '') } : f))
|
||||
await student.post(`/api/assignments/${assignmentId}/submit`, { files: broken })
|
||||
await queue.idle()
|
||||
const graded = (await student.get(`/api/assignments/${assignmentId}/submission`)).body
|
||||
expect(graded.autoScore).toBe(10)
|
||||
expect(graded.result.results[1]).toMatchObject({ title: '能正确关闭报警灯', passed: false })
|
||||
|
||||
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring: { ...scoring, auto: [30, 20] } })
|
||||
expect((await student.get(`/api/assignments/${assignmentId}/submission`)).body.autoScore).toBe(30)
|
||||
}, 60000)
|
||||
|
||||
test('过了截止时间不能提交', async () => {
|
||||
const { student, assignmentId, files } = await setup(Date.now() - 1000)
|
||||
const result = await student.post(`/api/assignments/${assignmentId}/submit`, { files })
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.body.message).toBe('已经过了截止时间,不能再提交')
|
||||
})
|
||||
|
||||
test('删除作业后学生看不到', async () => {
|
||||
const { t, student, assignmentId } = await setup()
|
||||
await t.delete(`/api/teacher/assignments/${assignmentId}`)
|
||||
expect((await student.get('/api/assignments')).body).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -15,3 +15,40 @@ export interface GradeResult {
|
||||
/** 评分用时(毫秒) */
|
||||
ms: number
|
||||
}
|
||||
|
||||
// ---------- 作业 ----------
|
||||
|
||||
/** 学生的起点:blank 空白工程(从界面做起),ui 界面已做好(只写代码) */
|
||||
export type StartMode = 'blank' | 'ui'
|
||||
|
||||
/** 分值:自动评分项按规则顺序各自的分值;老师评分项(界面、注释、命名等)由老师打分 */
|
||||
export interface Scoring {
|
||||
auto: number[]
|
||||
manual: { title: string; points: number }[]
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
id: number
|
||||
classId: number
|
||||
className: string
|
||||
sampleId: string
|
||||
title: string
|
||||
startMode: StartMode
|
||||
/** 截止时间(毫秒时间戳),null 表示不限 */
|
||||
deadline: number | null
|
||||
createdAt: number
|
||||
/** 自动评分项的名称,和 scoring.auto 一一对应 */
|
||||
checkTitles: string[]
|
||||
scoring: Scoring
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
id: number
|
||||
status: 'pending' | 'done' | 'error'
|
||||
result: GradeResult | null
|
||||
/** 自动评分得分(通过的项的分值之和) */
|
||||
autoScore: number | null
|
||||
createdAt: number
|
||||
/** 排队评分时前面还有几份 */
|
||||
queueAhead?: number
|
||||
}
|
||||
+2
-1
@@ -6,7 +6,8 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
|
||||
<NConfigProvider :locale="zhCN">
|
||||
<NMessageProvider>
|
||||
<NDialogProvider>
|
||||
<RouterView />
|
||||
<!-- 换一个作业时重新创建页面(同一页面里只换参数时 Vue 默认会复用组件) -->
|
||||
<RouterView :key="$route.path" />
|
||||
</NDialogProvider>
|
||||
</NMessageProvider>
|
||||
</NConfigProvider>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test } from 'bun:test'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { addControl } from './edit'
|
||||
import { controlDef } from './catalog'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
|
||||
import { assignmentTemplate, formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
|
||||
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
|
||||
@@ -27,3 +27,36 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正
|
||||
expect(text).toContain(' this.Controls.Add(this.button1);\n this.Name = "AddUser";')
|
||||
expect(text).toContain(' #endregion\n\n private System.Windows.Forms.Button button1;\n }')
|
||||
})
|
||||
|
||||
/** 读取 samples/ 下的示例工程 */
|
||||
async function sample(id: string) {
|
||||
const dir = `${import.meta.dir}/../../../samples/${id}`
|
||||
const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir }))
|
||||
return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() })))
|
||||
}
|
||||
|
||||
test('作业起点"界面已做好":保留控件和属性,去掉事件绑定和事件处理方法', async () => {
|
||||
const files = assignmentTemplate(await sample('p2-t1-LED显示屏'), 'ui')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
|
||||
const designer = files.find((f) => f.path === 'Form1.Designer.cs')!.content
|
||||
expect(designer).toContain('this.btn_Open = new System.Windows.Forms.Button();')
|
||||
expect(designer).toContain('this.btn_Open.Text = "打 开";')
|
||||
expect(designer).not.toContain('+=')
|
||||
const code = files.find((f) => f.path === 'Form1.cs')!.content
|
||||
expect(code).toContain('namespace LED显示屏')
|
||||
expect(code).not.toContain('btn_Open_Click')
|
||||
})
|
||||
|
||||
test('作业起点"界面已做好":多个窗体都保留,其他类(PublicData)由学生自己写', async () => {
|
||||
const files = assignmentTemplate(await sample('p1-t1-登录'), 'ui')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['AddUser.Designer.cs', 'AddUser.cs', 'Login.Designer.cs', 'Login.cs', 'Program.cs'])
|
||||
expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form')
|
||||
})
|
||||
|
||||
test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
|
||||
const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
|
||||
const program = files.find((f) => f.path === 'Program.cs')!.content
|
||||
expect(program).toContain('Application.Run(new Form1())')
|
||||
expect(files.find((f) => f.path === 'Form1.Designer.cs')!.content).not.toContain('com_port')
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// 新建窗体:生成与 VS(中文版、.NET Framework 4.5)新建窗体并保存一次后相同的两个文件。
|
||||
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
|
||||
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
|
||||
export function projectNamespace(files: SourceFile[]): string {
|
||||
@@ -104,3 +105,29 @@ ${blockHeader(name)}
|
||||
{ path: `${name}.Designer.cs`, content: designer },
|
||||
]
|
||||
}
|
||||
|
||||
export type StartMode = 'blank' | 'ui'
|
||||
|
||||
/**
|
||||
* 布置作业时学生的起点工程(由参考答案生成):
|
||||
* - blank:空白工程,只有 Program.cs 和一个空的 Form1,学生从搭界面做起;
|
||||
* - ui:界面已做好,保留每个窗体的控件和属性,去掉事件绑定,窗体代码只剩构造函数,
|
||||
* 学生在设计器里双击控件生成事件处理方法、写代码。其他类(如 PublicData)也要学生自己写。
|
||||
*/
|
||||
export function assignmentTemplate(files: SourceFile[], mode: StartMode): SourceFile[] {
|
||||
const namespace = projectNamespace(files)
|
||||
const program = files.find((f) => f.path === 'Program.cs')
|
||||
if (mode === 'blank') {
|
||||
const startup = startupForm(files)
|
||||
const programText = program && startup ? program.content.replace(new RegExp(`new\\s+${startup}\\s*\\(`, 'u'), 'new Form1(') : undefined
|
||||
return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')]
|
||||
}
|
||||
const result: SourceFile[] = program ? [{ ...program }] : []
|
||||
for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) {
|
||||
const doc = parseDesigner(designer.content)
|
||||
for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event')
|
||||
const [code] = newFormFiles(namespace, doc.className)
|
||||
result.push({ path: designer.path.replace(/\.Designer\.cs$/, '.cs'), content: code.content }, { path: designer.path, content: generateDesigner(doc) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
// 一次提交的自动评分结果:每项通过与否、得分和不通过的原因;老师评分项只列出名称和分值。
|
||||
import { computed } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import type { Assignment, Submission } from '@iot/shared'
|
||||
|
||||
const props = defineProps<{ assignment: Assignment; submission: Submission }>()
|
||||
|
||||
const autoTotal = computed(() => props.assignment.scoring.auto.reduce((a, b) => a + b, 0))
|
||||
const manual = computed(() => props.assignment.scoring.manual)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grade">
|
||||
<div v-if="submission.status === 'pending'" class="pending">
|
||||
<NSpin size="small" />
|
||||
<span>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span>
|
||||
</div>
|
||||
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
|
||||
<template v-else-if="submission.result">
|
||||
<div class="summary">
|
||||
自动评分 <b>{{ submission.autoScore }}</b> / {{ autoTotal }} 分
|
||||
<span v-if="!submission.result.compiled" class="error">(编译没有通过,先改正编译错误)</span>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="(r, i) in submission.result.results" :key="i" :class="r.passed ? 'passed' : 'failed'">
|
||||
<span class="mark">{{ r.passed ? '✓' : '✗' }}</span>
|
||||
<div>
|
||||
<div class="title">
|
||||
{{ r.title }}<span class="points">{{ r.passed ? assignment.scoring.auto[i] : 0 }} / {{ assignment.scoring.auto[i] }} 分</span>
|
||||
</div>
|
||||
<div v-if="r.message" class="message">{{ r.message }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<div v-if="manual.length" class="manual">
|
||||
由老师评分:<span v-for="m in manual" :key="m.title">{{ m.title }}({{ m.points }} 分)</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grade {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary b {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.items li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.passed .mark {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.failed .mark,
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.points {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 2px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.manual {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.manual span + span::before {
|
||||
content: "、";
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
// 学生的作业列表:自己班的作业、截止时间、提交和得分情况。
|
||||
import { h, onMounted, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { NButton, NDataTable, NEmpty, NTag, type DataTableColumns } from 'naive-ui'
|
||||
import type { Assignment, Submission } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
|
||||
type Row = Assignment & { latest: Submission | null }
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<Row[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
rows.value = await api<Row[]>('GET', '/api/assignments').catch(() => [])
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
|
||||
|
||||
function status(row: Row) {
|
||||
const s = row.latest
|
||||
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' }, () => '评分出错')
|
||||
const full = s.autoScore === total(row)
|
||||
return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${s.autoScore} / ${total(row)}`)
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<Row> = [
|
||||
{ title: '作业', key: 'title', render: (r) => h(RouterLink, { to: `/assignments/${r.id}`, class: 'link' }, () => r.title) },
|
||||
{ title: '起点', key: 'startMode', width: 120, render: (r) => (r.startMode === 'ui' ? '界面已做好' : '空白工程') },
|
||||
{ title: '截止时间', key: 'deadline', width: 140, render: (r) => (r.deadline ? formatTime(r.deadline) : '不限') },
|
||||
{ title: '状态', key: 'status', width: 220, render: status },
|
||||
]
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<span class="title">我的作业</span>
|
||||
<RouterLink to="/" class="nav-link">自由练习</RouterLink>
|
||||
<span class="spacer" />
|
||||
<span class="user">
|
||||
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||
</span>
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
<main class="content">
|
||||
<NDataTable v-if="rows.length" :columns="columns" :data="rows" :row-key="(r: Row) => r.id" :bordered="false" />
|
||||
<NEmpty v-else-if="loaded" description="老师还没有布置作业" class="empty" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
:deep(.link) {
|
||||
color: #2563eb;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生。
|
||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业、查看提交情况。
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, NTabPane, NTabs, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import AssignmentsPanel from '../teacher/AssignmentsPanel.vue'
|
||||
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
|
||||
|
||||
interface ClassInfo {
|
||||
@@ -264,16 +265,24 @@ async function signOut() {
|
||||
</template>
|
||||
只能删除没有学生的班级。确定删除"{{ selected.name }}"吗?
|
||||
</NPopconfirm>
|
||||
<span class="spacer" />
|
||||
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
|
||||
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
|
||||
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||
</div>
|
||||
<p class="tip">
|
||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||
</p>
|
||||
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||
<NTabs type="line" default-value="students">
|
||||
<NTabPane name="students" :tab="`学生(${students.length})`">
|
||||
<div class="students-actions">
|
||||
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
|
||||
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
|
||||
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||
</div>
|
||||
<p class="tip">
|
||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||
</p>
|
||||
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||
</NTabPane>
|
||||
<NTabPane name="assignments" tab="作业">
|
||||
<AssignmentsPanel :class-id="selected.id" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</section>
|
||||
<section v-else class="students">
|
||||
<NEmpty description="选择或新建一个班级" />
|
||||
@@ -425,6 +434,12 @@ async function signOut() {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.students-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
|
||||
+145
-22
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
// 两种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分)。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag } from 'naive-ui'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
||||
import type { Assignment, SourceFile, Submission } from '@iot/shared'
|
||||
import FormDesigner from '../designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from '../designer/history'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||
@@ -12,10 +13,13 @@ import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import { createRunSession } from '../run/session'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||
import FormWindow from '../winforms/FormWindow.vue'
|
||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||
|
||||
const props = defineProps<{ assignmentId?: string }>()
|
||||
|
||||
const session = createRunSession()
|
||||
const { state } = session
|
||||
|
||||
@@ -64,6 +68,9 @@ const modified = ref(false)
|
||||
const editorRef = ref<InstanceType<typeof CodeEditor>>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
/** 当前工程的作品保存在服务器上的哪个 key */
|
||||
const workKey = ref<string>()
|
||||
|
||||
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
||||
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
|
||||
@@ -87,6 +94,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture
|
||||
onMounted(async () => {
|
||||
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
||||
void session.connect()
|
||||
if (props.assignmentId) {
|
||||
await loadAssignment()
|
||||
return
|
||||
}
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||
@@ -94,21 +105,79 @@ onMounted(async () => {
|
||||
if (initial) await loadSample(initial.id)
|
||||
})
|
||||
|
||||
async function loadSample(id: string, discardWork = false) {
|
||||
/** 打开一个工程:服务器上有保存的作品就用作品,没有就用原始代码;discardWork 表示丢弃作品恢复原始代码 */
|
||||
async function openProject(key: string, original: () => Promise<SourceFile[]>, discardWork: boolean) {
|
||||
await flushSave()
|
||||
sampleId.value = id
|
||||
void router.replace({ query: { sample: id } })
|
||||
workKey.value = key
|
||||
designerHistory.clear()
|
||||
const key = sampleWorkKey(id)
|
||||
if (discardWork) await deleteWork(key).catch(() => {})
|
||||
const work = discardWork ? null : await loadWork(key).catch(() => null)
|
||||
modified.value = work !== null
|
||||
saveState.value = work ? 'saved' : 'idle'
|
||||
files.value = work ?? (await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`))
|
||||
files.value = work ?? (await original())
|
||||
// 和 VS 一样,默认打开主窗体的设计器
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
async function loadSample(id: string, discardWork = false) {
|
||||
sampleId.value = id
|
||||
void router.replace({ query: { sample: id } })
|
||||
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
|
||||
}
|
||||
|
||||
// ---------- 作业 ----------
|
||||
const assignment = ref<Assignment | null>(null)
|
||||
const submission = ref<Submission | null>(null)
|
||||
const resultOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pastDeadline = computed(() => !!assignment.value?.deadline && Date.now() > assignment.value.deadline)
|
||||
const autoTotal = computed(() => assignment.value?.scoring.auto.reduce((a, b) => a + b, 0) ?? 0)
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadAssignment(discardWork = false) {
|
||||
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null }>(
|
||||
'GET',
|
||||
`/api/assignments/${props.assignmentId}`,
|
||||
).catch((e: Error) => {
|
||||
message.error(e.message)
|
||||
return null
|
||||
})
|
||||
if (!detail) return
|
||||
assignment.value = detail.assignment
|
||||
submission.value = detail.latest
|
||||
if (detail.latest?.status === 'pending') pollSubmission()
|
||||
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!assignment.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await flushSave()
|
||||
submission.value = await api<Submission>('POST', `/api/assignments/${assignment.value.id}/submit`, { files: files.value })
|
||||
resultOpen.value = true
|
||||
pollSubmission()
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 评分中时每 2 秒查一次结果 */
|
||||
function pollSubmission() {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = setTimeout(async () => {
|
||||
const latest = await api<Submission | null>('GET', `/api/assignments/${props.assignmentId}/submission`).catch(() => submission.value)
|
||||
submission.value = latest
|
||||
if (latest?.status === 'pending') pollSubmission()
|
||||
}, 2000)
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(pollTimer))
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
// ---------- 自动保存到服务器 ----------
|
||||
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
||||
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle')
|
||||
@@ -124,11 +193,11 @@ function scheduleSave() {
|
||||
/** 立即保存还没保存的改动(切换示例、离开页面之前) */
|
||||
async function flushSave(keepalive = false) {
|
||||
clearTimeout(saveTimer)
|
||||
if (saveState.value !== 'pending' || !sampleId.value) return
|
||||
if (saveState.value !== 'pending' || !workKey.value) return
|
||||
saveState.value = 'saving'
|
||||
const snapshot = files.value.map((f) => ({ ...f }))
|
||||
try {
|
||||
await saveWork(sampleWorkKey(sampleId.value), snapshot, keepalive)
|
||||
await saveWork(workKey.value, snapshot, keepalive)
|
||||
// 保存期间又有改动时保持 pending,等下一次保存
|
||||
if (saveState.value === 'saving') saveState.value = 'saved'
|
||||
} catch {
|
||||
@@ -236,7 +305,8 @@ async function showDiagnostic(file: string, line: number, column: number) {
|
||||
}
|
||||
|
||||
function restoreOriginal() {
|
||||
if (sampleId.value) void loadSample(sampleId.value, true)
|
||||
if (props.assignmentId) void loadAssignment(true)
|
||||
else if (sampleId.value) void loadSample(sampleId.value, true)
|
||||
}
|
||||
|
||||
function run() {
|
||||
@@ -248,28 +318,53 @@ function run() {
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
:options="samples"
|
||||
placeholder="选择示例工程"
|
||||
@update:value="loadSample"
|
||||
/>
|
||||
<template v-if="assignmentId">
|
||||
<RouterLink to="/assignments" class="nav-link">← 我的作业</RouterLink>
|
||||
<strong class="assignment-title">{{ assignment?.title }}</strong>
|
||||
<span v-if="assignment?.deadline" class="deadline" :class="{ past: pastDeadline }">
|
||||
{{ pastDeadline ? '已截止' : '截止' }} {{ formatTime(assignment.deadline) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
:options="samples"
|
||||
placeholder="选择示例工程"
|
||||
@update:value="loadSample"
|
||||
/>
|
||||
</template>
|
||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||
<template v-if="assignment">
|
||||
<NButton
|
||||
type="warning"
|
||||
:loading="submitting"
|
||||
:disabled="submission?.status === 'pending' || (pastDeadline && currentUser?.role === 'student')"
|
||||
title="提交后自动评分,截止前可以改了再交"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</NButton>
|
||||
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
|
||||
{{ submission.status === 'pending' ? '评分中…' : submission.status === 'done' ? `得分 ${submission.autoScore} / ${autoTotal}` : '评分出错' }}
|
||||
</NButton>
|
||||
</template>
|
||||
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">恢复原始代码</NButton>
|
||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||
</template>
|
||||
你对这个示例的修改将全部丢弃,确定恢复吗?
|
||||
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
|
||||
</NPopconfirm>
|
||||
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||
<span class="metrics">
|
||||
<span v-if="!assignmentId" class="metrics">
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
{{ state.metrics.lastRoundTripMs }} ms
|
||||
</span>
|
||||
<span v-else class="spacer" />
|
||||
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
||||
<span class="user">
|
||||
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
|
||||
</span>
|
||||
@@ -277,6 +372,16 @@ function run() {
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
|
||||
<NModal
|
||||
v-if="assignment && submission"
|
||||
v-model:show="resultOpen"
|
||||
preset="card"
|
||||
:title="`评分结果:${assignment.title}`"
|
||||
style="width: 560px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<GradeResultView :assignment="assignment" :submission="submission" />
|
||||
</NModal>
|
||||
|
||||
<main class="workspace" :class="{ designing: activeForm }">
|
||||
<section class="editor">
|
||||
<div class="tab-row">
|
||||
@@ -388,6 +493,24 @@ function run() {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deadline {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.deadline.past {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
// 页面路由:登录页、实验页(学生和老师都用)、老师的班级管理页。
|
||||
// 页面路由:登录页、实验页(自由练习和做作业共用)、学生的作业列表、老师的班级管理页。
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { apiHooks } from './api'
|
||||
import { currentUser } from './auth'
|
||||
@@ -8,6 +8,8 @@ export const router = createRouter({
|
||||
routes: [
|
||||
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
|
||||
{ path: '/', component: () => import('./pages/WorkbenchPage.vue') },
|
||||
{ path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') },
|
||||
{ path: '/assignments/:id', component: () => import('./pages/WorkbenchPage.vue'), props: (route) => ({ assignmentId: String(route.params.id) }) },
|
||||
{ path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } },
|
||||
{ path: '/:rest(.*)*', redirect: '/' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
// 布置作业 / 修改作业。布置时选实验、起点和截止时间,给每个评分项设分值;
|
||||
// 起点工程由浏览器用设计器引擎从参考答案生成(界面已做好:去掉事件和代码;空白工程:只有空的 Form1)。
|
||||
// 修改时只能改名称、截止时间和分值。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NButton, NDatePicker, NInput, NInputNumber, NModal, NRadio, NRadioGroup, NSelect, useMessage } from 'naive-ui'
|
||||
import type { Assignment, Scoring, SourceFile, StartMode } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { assignmentTemplate } from '../designer/templates'
|
||||
|
||||
const props = defineProps<{ show: boolean; classId: number; editing: Assignment | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [show: boolean]; saved: [] }>()
|
||||
const message = useMessage()
|
||||
|
||||
const DEFAULT_MANUAL = [
|
||||
{ title: '窗体界面(布局合理、美观)', points: 10 },
|
||||
{ title: '注释书写规范', points: 10 },
|
||||
{ title: '变量与控件命名规范', points: 10 },
|
||||
]
|
||||
|
||||
const samples = ref<{ label: string; value: string }[]>([])
|
||||
const sampleId = ref<string | null>(null)
|
||||
const title = ref('')
|
||||
const startMode = ref<StartMode>('ui')
|
||||
const deadline = ref<number | null>(null)
|
||||
const checkTitles = ref<string[]>([])
|
||||
const auto = ref<number[]>([])
|
||||
const manual = ref<Scoring['manual']>([])
|
||||
const saving = ref(false)
|
||||
|
||||
const total = computed(() => [...auto.value, ...manual.value.map((m) => m.points)].reduce((a, b) => a + (b || 0), 0))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (!show) return
|
||||
const e = props.editing
|
||||
if (e) {
|
||||
sampleId.value = e.sampleId
|
||||
title.value = e.title
|
||||
startMode.value = e.startMode
|
||||
deadline.value = e.deadline
|
||||
checkTitles.value = e.checkTitles
|
||||
auto.value = [...e.scoring.auto]
|
||||
manual.value = e.scoring.manual.map((m) => ({ ...m }))
|
||||
return
|
||||
}
|
||||
sampleId.value = null
|
||||
title.value = ''
|
||||
startMode.value = 'ui'
|
||||
deadline.value = null
|
||||
checkTitles.value = []
|
||||
auto.value = []
|
||||
manual.value = DEFAULT_MANUAL.map((m) => ({ ...m }))
|
||||
if (samples.value.length === 0) {
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
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`)
|
||||
auto.value = checkTitles.value.map(() => 10)
|
||||
} catch (e) {
|
||||
checkTitles.value = []
|
||||
auto.value = []
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!sampleId.value || saving.value) return
|
||||
saving.value = true
|
||||
const scoring: Scoring = { auto: auto.value.map((p) => p || 0), manual: manual.value.map((m) => ({ title: m.title.trim(), points: m.points || 0 })) }
|
||||
try {
|
||||
if (props.editing) {
|
||||
await api('PATCH', `/api/teacher/assignments/${props.editing.id}`, { title: title.value, deadline: deadline.value, scoring })
|
||||
} else {
|
||||
const files = await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(sampleId.value)}`)
|
||||
await api('POST', '/api/teacher/assignments', {
|
||||
classId: props.classId,
|
||||
sampleId: sampleId.value,
|
||||
title: title.value,
|
||||
startMode: startMode.value,
|
||||
template: assignmentTemplate(files, startMode.value),
|
||||
scoring,
|
||||
deadline: deadline.value,
|
||||
})
|
||||
}
|
||||
emit('saved')
|
||||
emit('update:show', false)
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="editing ? '修改作业' : '布置作业'"
|
||||
style="width: 620px; max-width: calc(100vw - 32px)"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<div class="form">
|
||||
<label>实验</label>
|
||||
<NSelect :value="sampleId" :options="samples" :disabled="!!editing" placeholder="选择实验" @update:value="chooseSample" />
|
||||
|
||||
<label>作业名称</label>
|
||||
<NInput v-model:value="title" placeholder="学生看到的作业名称" />
|
||||
|
||||
<label>学生的起点</label>
|
||||
<NRadioGroup v-model:value="startMode" :disabled="!!editing">
|
||||
<NRadio value="ui">界面已做好(只写事件和代码)</NRadio>
|
||||
<NRadio value="blank">空白工程(从搭界面做起)</NRadio>
|
||||
</NRadioGroup>
|
||||
|
||||
<label>截止时间</label>
|
||||
<NDatePicker v-model:value="deadline" type="datetime" clearable placeholder="不限" format="yyyy-MM-dd HH:mm" />
|
||||
|
||||
<template v-if="checkTitles.length">
|
||||
<label>自动评分</label>
|
||||
<div class="points">
|
||||
<div v-for="(t, i) in checkTitles" :key="i" class="point-row">
|
||||
<span class="point-title">{{ t }}</span>
|
||||
<NInputNumber v-model:value="auto[i]" :min="0" size="small" class="point-input" />
|
||||
<span>分</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<label>老师评分</label>
|
||||
<div class="points">
|
||||
<div v-for="(m, i) in manual" :key="i" class="point-row">
|
||||
<NInput v-model:value="m.title" size="small" class="point-title" />
|
||||
<NInputNumber v-model:value="m.points" :min="0" size="small" class="point-input" />
|
||||
<span>分</span>
|
||||
<NButton size="tiny" quaternary @click="manual.splice(i, 1)">删除</NButton>
|
||||
</div>
|
||||
<NButton size="small" dashed @click="manual.push({ title: '', points: 10 })">+ 添加老师评分项</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="footer">
|
||||
<span class="total">总分 {{ total }} 分</span>
|
||||
<NButton @click="emit('update:show', false)">取消</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="!sampleId || !title.trim()" @click="save">
|
||||
{{ editing ? '保存' : '布置' }}
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: 80px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form label {
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
align-self: start;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.point-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.point-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.point-input {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.total {
|
||||
margin-right: auto;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
// 一个班的作业列表:布置、修改、删除作业,查看提交情况。
|
||||
import { h, ref, watch } from 'vue'
|
||||
import { NButton, NDataTable, NEmpty, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import type { Assignment } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import AssignmentDialog from './AssignmentDialog.vue'
|
||||
import SubmissionsDialog from './SubmissionsDialog.vue'
|
||||
|
||||
type Row = Assignment & { studentCount: number; submittedCount: number }
|
||||
|
||||
const props = defineProps<{ classId: number }>()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const rows = ref<Row[]>([])
|
||||
const editorOpen = ref(false)
|
||||
const editing = ref<Assignment | null>(null)
|
||||
const submissionsOpen = ref(false)
|
||||
const viewing = ref<Assignment | null>(null)
|
||||
|
||||
async function load() {
|
||||
rows.value = await api<Row[]>('GET', `/api/teacher/classes/${props.classId}/assignments`).catch((e: Error) => {
|
||||
message.error(e.message)
|
||||
return []
|
||||
})
|
||||
}
|
||||
watch(() => props.classId, load, { immediate: true })
|
||||
|
||||
function create() {
|
||||
editing.value = null
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function edit(row: Row) {
|
||||
editing.value = row
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function remove(row: Row) {
|
||||
dialog.warning({
|
||||
title: '删除作业',
|
||||
content: `将删除"${row.title}"和全班的 ${row.submittedCount} 份提交,不能恢复。确定删除吗?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await api('DELETE', `/api/teacher/assignments/${row.id}`).catch((e: Error) => message.error(e.message))
|
||||
await load()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function showSubmissions(row: Row) {
|
||||
viewing.value = row
|
||||
submissionsOpen.value = true
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const columns: DataTableColumns<Row> = [
|
||||
{ title: '作业', key: 'title' },
|
||||
{ title: '起点', key: 'startMode', width: 110, render: (r) => (r.startMode === 'ui' ? '界面已做好' : '空白工程') },
|
||||
{ title: '截止时间', key: 'deadline', width: 130, render: (r) => (r.deadline ? formatTime(r.deadline) : '不限') },
|
||||
{
|
||||
title: '已提交',
|
||||
key: 'submitted',
|
||||
width: 120,
|
||||
render: (r) => h(NButton, { text: true, type: 'primary', onClick: () => showSubmissions(r) }, () => `${r.submittedCount} / ${r.studentCount} 人`),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 130,
|
||||
render: (r) =>
|
||||
h('div', { class: 'row-actions' }, [
|
||||
h(NButton, { size: 'tiny', onClick: () => edit(r) }, () => '修改'),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => remove(r) }, () => '删除'),
|
||||
]),
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="head">
|
||||
<NButton size="small" type="primary" @click="create">布置作业</NButton>
|
||||
</div>
|
||||
<NDataTable v-if="rows.length" :columns="columns" :data="rows" :row-key="(r: Row) => r.id" size="small" :bordered="false" />
|
||||
<NEmpty v-else description="还没有布置作业" class="empty" />
|
||||
<AssignmentDialog v-model:show="editorOpen" :class-id="classId" :editing="editing" @saved="load" />
|
||||
<SubmissionsDialog v-model:show="submissionsOpen" :assignment="viewing" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
:deep(.row-actions) {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 一个作业的提交情况:全班每个学生最近一次提交的状态和自动评分得分。
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { NDataTable, NModal, NTag, type DataTableColumns } from 'naive-ui'
|
||||
import type { Assignment } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
|
||||
interface Row {
|
||||
userId: number
|
||||
username: string
|
||||
name: string
|
||||
status: 'pending' | 'done' | 'error' | null
|
||||
autoScore: number | null
|
||||
submittedAt: number | null
|
||||
count: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ show: boolean; assignment: Assignment | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
|
||||
|
||||
const rows = ref<Row[]>([])
|
||||
const autoTotal = computed(() => props.assignment?.scoring.auto.reduce((a, b) => a + b, 0) ?? 0)
|
||||
const submitted = computed(() => rows.value.filter((r) => r.status).length)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (show && props.assignment) rows.value = await api<Row[]>('GET', `/api/teacher/assignments/${props.assignment.id}/submissions`).catch(() => [])
|
||||
},
|
||||
)
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const columns = computed<DataTableColumns<Row>>(() => [
|
||||
{ title: '学号', key: 'username', sorter: 'default' },
|
||||
{ title: '姓名', key: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (r) =>
|
||||
r.status === null
|
||||
? h(NTag, { size: 'small' }, () => '未提交')
|
||||
: r.status === 'pending'
|
||||
? h(NTag, { size: 'small', type: 'warning' }, () => '评分中')
|
||||
: r.status === 'error'
|
||||
? h(NTag, { size: 'small', type: 'error' }, () => '评分出错')
|
||||
: h(NTag, { size: 'small', type: 'success' }, () => '已评分'),
|
||||
},
|
||||
{
|
||||
title: `自动评分(满分 ${autoTotal.value})`,
|
||||
key: 'autoScore',
|
||||
sorter: (a, b) => (a.autoScore ?? -1) - (b.autoScore ?? -1),
|
||||
render: (r) => (r.autoScore === null ? '—' : String(r.autoScore)),
|
||||
},
|
||||
{ title: '提交次数', key: 'count' },
|
||||
{ title: '最后提交', key: 'submittedAt', render: (r) => (r.submittedAt ? formatTime(r.submittedAt) : '—') },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`提交情况:${assignment?.title ?? ''}`"
|
||||
style="width: 760px; max-width: calc(100vw - 32px)"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<p class="summary">已提交 {{ submitted }} / {{ rows.length }} 人</p>
|
||||
<NDataTable :columns="columns" :data="rows" :row-key="(r: Row) => r.userId" size="small" :max-height="480" />
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary {
|
||||
margin: 0 0 8px;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user