评阅与成绩:老师看代码、运行学生程序、打分、写评语;成绩表与导出
- 评阅页:全班学生列表(提交状态、得分、是否已评阅);选中学生的代码(只读)、自动评分结果、 规范提示(代码里用到的默认名字控件、注释行数)、老师评分项打分和评语,"保存并看下一个" - "运行他的程序":新标签页里打开学生的提交,可以操作实训台,改动不保存 - 学生能看到老师的各项得分和评语;作业列表显示总分 - 班级管理页"成绩"标签页:学生 × 作业的总分,点分数进入评阅;单个作业和全班成绩导出 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
@@ -71,7 +71,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
|
||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分、老师查看提交情况。
|
||||
- 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);布置作业、学生提交后自动评分;老师评阅(看代码、运行学生的程序、老师评分项打分、评语)、全班成绩表与 CSV 导出。
|
||||
|
||||
尚未完成:
|
||||
|
||||
|
||||
+2
-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
|
||||
@@ -273,6 +273,7 @@ flowchart TB
|
||||
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||
- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。
|
||||
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
|
||||
- 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
|
||||
+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)
|
||||
})
|
||||
@@ -52,3 +52,10 @@ export interface Submission {
|
||||
/** 排队评分时前面还有几份 */
|
||||
queueAhead?: number
|
||||
}
|
||||
|
||||
/** 老师评阅:老师评分项各自的得分(null 表示还没打分,和 scoring.manual 一一对应)和评语 */
|
||||
export interface Review {
|
||||
scores: (number | null)[]
|
||||
comment: string
|
||||
updatedAt: number
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const props = defineProps<{
|
||||
files: SourceFile[]
|
||||
activeFile?: string
|
||||
diagnostics: Diagnostic[]
|
||||
/** 只读(老师查看学生提交的代码) */
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -96,6 +98,7 @@ onMounted(() => {
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: 'selection',
|
||||
unicodeHighlight: { ambiguousCharacters: false },
|
||||
readOnly: props.readOnly,
|
||||
})
|
||||
syncModels()
|
||||
showActive()
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
// 一次提交的自动评分结果:每项通过与否、得分和不通过的原因;老师评分项只列出名称和分值。
|
||||
// 一次提交的评分结果:自动评分每项通过与否、得分和不通过的原因;
|
||||
// 老师评分项:老师已评阅时显示各项得分和评语,还没评阅时只列出名称和分值。
|
||||
import { computed } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import type { Assignment, Submission } from '@iot/shared'
|
||||
import type { Assignment, Review, Submission } from '@iot/shared'
|
||||
|
||||
const props = defineProps<{ assignment: Assignment; submission: Submission }>()
|
||||
const props = withDefaults(defineProps<{ assignment: Assignment; submission: Submission; review?: Review | null; showManual?: boolean }>(), {
|
||||
review: null,
|
||||
showManual: true,
|
||||
})
|
||||
|
||||
const autoTotal = computed(() => props.assignment.scoring.auto.reduce((a, b) => a + b, 0))
|
||||
const manual = computed(() => props.assignment.scoring.manual)
|
||||
const manualTotal = computed(() => manual.value.reduce((a, m) => a + m.points, 0))
|
||||
const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) => a + (s ?? 0), 0) ?? 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,9 +40,22 @@ const manual = computed(() => props.assignment.scoring.manual)
|
||||
</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>
|
||||
<template v-if="showManual && manual.length">
|
||||
<div v-if="review" class="reviewed">
|
||||
<div class="summary">
|
||||
老师评分 <b>{{ manualScore }}</b> / {{ manualTotal }} 分
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="(m, i) in manual" :key="i">
|
||||
<span class="title">{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
|
||||
</div>
|
||||
<div v-else class="manual">
|
||||
由老师评分:<span v-for="m in manual" :key="m.title">{{ m.title }}({{ m.points }} 分)</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -99,6 +118,21 @@ const manual = computed(() => props.assignment.scoring.manual)
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.reviewed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.comment {
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.manual {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { Assignment, Submission } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
|
||||
type Row = Assignment & { latest: Submission | null }
|
||||
type Row = Assignment & { latest: Submission | null; manualScore: number | null }
|
||||
|
||||
const router = useRouter()
|
||||
const rows = ref<Row[]>([])
|
||||
@@ -27,6 +27,10 @@ function status(row: Row) {
|
||||
if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交')
|
||||
if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
|
||||
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
|
||||
if (row.manualScore !== null) {
|
||||
const all = total(row) + row.scoring.manual.reduce((a, m) => a + m.points, 0)
|
||||
return h(NTag, { type: 'success', size: 'small' }, () => `老师已评阅 · 总分 ${(s.autoScore ?? 0) + row.manualScore!} / ${all}`)
|
||||
}
|
||||
const full = s.autoScore === total(row)
|
||||
return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${s.autoScore} / ${total(row)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
// 评阅一个作业:左边是全班学生(提交状态、自动评分、老师评分),右边是选中学生的代码、自动评分结果、
|
||||
// 规范提示和老师评分。"运行他的程序"在新标签页的实验页里打开学生的代码。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NEmpty, NInput, NInputNumber, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
||||
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import { conventionHints } from '../teacher/conventions'
|
||||
import { downloadCsv } from '../teacher/csv'
|
||||
|
||||
interface Row {
|
||||
userId: number
|
||||
username: string
|
||||
name: string
|
||||
status: Submission['status'] | null
|
||||
autoScore: number | null
|
||||
manualScore: number | null
|
||||
review: Review | null
|
||||
submittedAt: number | null
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Detail {
|
||||
assignment: Assignment
|
||||
student: { id: number; username: string; name: string }
|
||||
submission: Submission | null
|
||||
files: SourceFile[] | null
|
||||
review: Review | null
|
||||
submissionCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{ assignmentId: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const assignment = ref<Assignment | null>(null)
|
||||
const rows = ref<Row[]>([])
|
||||
const selectedId = ref<number>()
|
||||
const detail = ref<Detail | null>(null)
|
||||
const activeFile = ref<string>()
|
||||
const scores = ref<(number | null)[]>([])
|
||||
const comment = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const autoTotal = computed(() => assignment.value?.scoring.auto.reduce((a, b) => a + b, 0) ?? 0)
|
||||
const manualTotal = computed(() => assignment.value?.scoring.manual.reduce((a, m) => a + m.points, 0) ?? 0)
|
||||
const codeFiles = computed(() => detail.value?.files?.filter((f) => f.path.endsWith('.cs')) ?? [])
|
||||
const hints = computed(() => (detail.value?.files ? conventionHints(detail.value.files) : []))
|
||||
/** 正在打的老师评分之和(未保存) */
|
||||
const draftManual = computed(() => scores.value.reduce<number>((a, s) => a + (s ?? 0), 0))
|
||||
const index = computed(() => rows.value.findIndex((r) => r.userId === selectedId.value))
|
||||
const dirty = computed(() => {
|
||||
const saved = detail.value?.review
|
||||
return JSON.stringify(scores.value) !== JSON.stringify(saved?.scores ?? scores.value.map(() => null)) || comment.value !== (saved?.comment ?? '')
|
||||
})
|
||||
|
||||
async function loadRows() {
|
||||
rows.value = await api<Row[]>('GET', `/api/teacher/assignments/${props.assignmentId}/submissions`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
assignment.value = (await api<{ assignment: Assignment }>('GET', `/api/assignments/${props.assignmentId}`)).assignment
|
||||
await loadRows()
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
return
|
||||
}
|
||||
const wanted = Number(route.query.student)
|
||||
// 默认先看第一个已提交的学生
|
||||
await select(rows.value.find((r) => r.userId === wanted)?.userId ?? rows.value.find((r) => r.status)?.userId ?? rows.value[0]?.userId)
|
||||
})
|
||||
|
||||
async function select(userId: number | undefined) {
|
||||
if (userId === undefined) return
|
||||
selectedId.value = userId
|
||||
void router.replace({ query: { student: userId } })
|
||||
detail.value = await api<Detail>('GET', `/api/teacher/assignments/${props.assignmentId}/students/${userId}`).catch((e: Error) => {
|
||||
message.error(e.message)
|
||||
return null
|
||||
})
|
||||
const manual = assignment.value?.scoring.manual ?? []
|
||||
scores.value = manual.map((_, i) => detail.value?.review?.scores[i] ?? null)
|
||||
comment.value = detail.value?.review?.comment ?? ''
|
||||
// 默认显示第一个窗体的代码
|
||||
activeFile.value = codeFiles.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? codeFiles.value[0]?.path
|
||||
}
|
||||
|
||||
async function save(next = false) {
|
||||
if (!detail.value || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const review = await api<Review>('PUT', `/api/teacher/assignments/${props.assignmentId}/students/${detail.value.student.id}/review`, {
|
||||
scores: scores.value,
|
||||
comment: comment.value,
|
||||
})
|
||||
detail.value.review = review
|
||||
await loadRows()
|
||||
message.success('已保存')
|
||||
if (next) await go(1)
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 上一个 / 下一个已提交的学生 */
|
||||
async function go(step: 1 | -1) {
|
||||
for (let i = index.value + step; i >= 0 && i < rows.value.length; i += step) {
|
||||
if (rows.value[i].status) return select(rows.value[i].userId)
|
||||
}
|
||||
message.info(step > 0 ? '后面没有已提交的学生了' : '前面没有已提交的学生了')
|
||||
}
|
||||
|
||||
function runProgram() {
|
||||
if (detail.value?.submission) window.open(router.resolve(`/teacher/submissions/${detail.value.submission.id}/run`).href, '_blank')
|
||||
}
|
||||
|
||||
// 切换学生时,如果打了分还没保存,提醒一下
|
||||
watch(selectedId, (_, old) => {
|
||||
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
|
||||
})
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success'; text: string } {
|
||||
if (!row.status) return { type: 'default', text: '未提交' }
|
||||
if (row.status === 'pending') return { type: 'warning', text: '评分中' }
|
||||
if (row.status === 'error') return { type: 'error', text: '评分出错' }
|
||||
return { type: row.review ? 'success' : 'default', text: row.review ? '已评阅' : '待评阅' }
|
||||
}
|
||||
|
||||
/** 导出这个作业的成绩:每个学生的自动评分、老师评分各项、总分和评语 */
|
||||
function exportCsv() {
|
||||
const a = assignment.value
|
||||
if (!a) return
|
||||
downloadCsv(`${a.title}-成绩.csv`, [
|
||||
['学号', '姓名', `自动评分(${autoTotal.value})`, ...a.scoring.manual.map((m) => `${m.title}(${m.points})`), `总分(${autoTotal.value + manualTotal.value})`, '提交次数', '评语'],
|
||||
...rows.value.map((r) => [
|
||||
`\t${r.username}`,
|
||||
r.name,
|
||||
r.status ? (r.autoScore ?? '') : '未提交',
|
||||
...a.scoring.manual.map((_, i) => r.review?.scores[i] ?? ''),
|
||||
r.status ? (r.autoScore ?? 0) + (r.manualScore ?? 0) : '',
|
||||
r.count,
|
||||
r.review?.comment ?? '',
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<RouterLink to="/teacher" class="nav-link">← 班级管理</RouterLink>
|
||||
<strong>{{ assignment?.title }}</strong>
|
||||
<span class="muted">{{ assignment?.className }}</span>
|
||||
<span class="muted">已提交 {{ rows.filter((r) => r.status).length }} / {{ rows.length }} 人</span>
|
||||
<NButton size="small" @click="exportCsv">导出成绩</NButton>
|
||||
<span class="spacer" />
|
||||
<span class="user">{{ currentUser?.name }}</span>
|
||||
<NButton quaternary size="small" @click="signOut">退出</NButton>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<aside class="students">
|
||||
<button v-for="r in rows" :key="r.userId" class="student" :class="{ active: r.userId === selectedId }" @click="select(r.userId)">
|
||||
<span class="who">
|
||||
<span>{{ r.name }}</span>
|
||||
<span class="username">{{ r.username }}</span>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span v-if="r.status === 'done'" class="score">{{ (r.autoScore ?? 0) + (r.manualScore ?? 0) }}</span>
|
||||
<NTag size="tiny" :type="statusTag(r).type">{{ statusTag(r).text }}</NTag>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="detail && assignment" class="review">
|
||||
<div class="review-head">
|
||||
<h2>{{ detail.student.name }}</h2>
|
||||
<span class="muted">{{ detail.student.username }}</span>
|
||||
<span v-if="detail.submission" class="muted">提交 {{ detail.submissionCount }} 次 · 最后提交 {{ formatTime(detail.submission.createdAt) }}</span>
|
||||
<span class="spacer" />
|
||||
<NButton size="small" @click="go(-1)">上一个</NButton>
|
||||
<NButton size="small" @click="go(1)">下一个</NButton>
|
||||
<NButton size="small" type="primary" :disabled="!detail.submission" @click="runProgram">运行他的程序</NButton>
|
||||
</div>
|
||||
|
||||
<NEmpty v-if="!detail.submission" description="还没有提交" class="empty" />
|
||||
<div v-else class="review-body">
|
||||
<div class="code">
|
||||
<NTabs :value="activeFile" type="card" size="small" @update:value="(v: string) => (activeFile = v)">
|
||||
<NTab v-for="f in codeFiles" :key="f.path" :name="f.path">{{ f.path }}</NTab>
|
||||
</NTabs>
|
||||
<CodeEditor class="editor" :files="codeFiles" :active-file="activeFile" :diagnostics="[]" read-only />
|
||||
</div>
|
||||
|
||||
<div class="side">
|
||||
<section class="card">
|
||||
<h3>自动评分</h3>
|
||||
<GradeResultView :assignment="assignment" :submission="detail.submission" :show-manual="false" />
|
||||
</section>
|
||||
|
||||
<section v-if="hints.length" class="card">
|
||||
<h3>规范提示</h3>
|
||||
<ul class="hints">
|
||||
<li v-for="(h, i) in hints" :key="i" :class="h.level">{{ h.text }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>老师评分</h3>
|
||||
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="score-row">
|
||||
<span class="score-title">{{ m.title }}</span>
|
||||
<NInputNumber v-model:value="scores[i]" :min="0" :max="m.points" size="small" placeholder="未打分" class="score-input" />
|
||||
<span class="muted">/ {{ m.points }}</span>
|
||||
</div>
|
||||
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
|
||||
<div class="save-row">
|
||||
<span class="muted">
|
||||
总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }}
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="review">
|
||||
<NEmpty description="班里还没有学生" class="empty" />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.students {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.student {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.student:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.student.active {
|
||||
background: #e0ecff;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.review-head h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.review-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hints {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hints .warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.score-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.score-input {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 80px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业、查看提交情况。
|
||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业(评阅在 ReviewPage);全班成绩表。
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 GradebookPanel from '../teacher/GradebookPanel.vue'
|
||||
import { downloadCsv } from '../teacher/csv'
|
||||
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
|
||||
|
||||
interface ClassInfo {
|
||||
@@ -177,17 +179,6 @@ async function confirmImport() {
|
||||
})
|
||||
}
|
||||
|
||||
/** 下载 CSV:带 BOM,Excel 打开时中文不乱码;账号前加制表符,长学号不会变成科学计数法 */
|
||||
function downloadCsv(filename: string, rows: string[][]) {
|
||||
const quote = (cell: string) => (/[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell)
|
||||
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
function exportRoster() {
|
||||
if (!selected.value) return
|
||||
downloadCsv(`${selected.value.name}-名单.csv`, [
|
||||
@@ -282,6 +273,9 @@ async function signOut() {
|
||||
<NTabPane name="assignments" tab="作业">
|
||||
<AssignmentsPanel :class-id="selected.id" />
|
||||
</NTabPane>
|
||||
<NTabPane name="grades" tab="成绩" display-directive="if">
|
||||
<GradebookPanel :class-id="selected.id" :class-name="selected.name" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</section>
|
||||
<section v-else class="students">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
// 两种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分)。
|
||||
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
|
||||
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
||||
import type { Assignment, SourceFile, Submission } from '@iot/shared'
|
||||
import type { Assignment, Review, 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'
|
||||
@@ -18,7 +19,7 @@ import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||
import FormWindow from '../winforms/FormWindow.vue'
|
||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||
|
||||
const props = defineProps<{ assignmentId?: string }>()
|
||||
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
|
||||
|
||||
const session = createRunSession()
|
||||
const { state } = session
|
||||
@@ -98,6 +99,10 @@ onMounted(async () => {
|
||||
await loadAssignment()
|
||||
return
|
||||
}
|
||||
if (props.submissionId) {
|
||||
await loadSubmission()
|
||||
return
|
||||
}
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||
@@ -128,14 +133,26 @@ async function loadSample(id: string, discardWork = false) {
|
||||
// ---------- 作业 ----------
|
||||
const assignment = ref<Assignment | null>(null)
|
||||
const submission = ref<Submission | null>(null)
|
||||
const review = ref<Review | 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)
|
||||
const manualTotal = computed(() => assignment.value?.scoring.manual.reduce((a, m) => a + m.points, 0) ?? 0)
|
||||
/** 工具栏上的得分:老师评阅后显示总分,之前只显示自动评分 */
|
||||
const scoreText = computed(() => {
|
||||
const s = submission.value
|
||||
if (!s) return ''
|
||||
if (s.status === 'pending') return '评分中…'
|
||||
if (s.status === 'error') return '评分出错'
|
||||
if (!review.value) return `自动评分 ${s.autoScore} / ${autoTotal.value}`
|
||||
const manual = review.value.scores.reduce<number>((a, x) => a + (x ?? 0), 0)
|
||||
return `总分 ${(s.autoScore ?? 0) + manual} / ${autoTotal.value + manualTotal.value}`
|
||||
})
|
||||
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function loadAssignment(discardWork = false) {
|
||||
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null }>(
|
||||
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null; review: Review | null }>(
|
||||
'GET',
|
||||
`/api/assignments/${props.assignmentId}`,
|
||||
).catch((e: Error) => {
|
||||
@@ -145,10 +162,28 @@ async function loadAssignment(discardWork = false) {
|
||||
if (!detail) return
|
||||
assignment.value = detail.assignment
|
||||
submission.value = detail.latest
|
||||
review.value = detail.review
|
||||
if (detail.latest?.status === 'pending') pollSubmission()
|
||||
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
|
||||
}
|
||||
|
||||
// ---------- 老师运行学生提交的程序 ----------
|
||||
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
|
||||
|
||||
async function loadSubmission() {
|
||||
try {
|
||||
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
|
||||
'GET',
|
||||
`/api/teacher/submissions/${props.submissionId}`,
|
||||
)
|
||||
viewing.value = result
|
||||
files.value = result.files
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!assignment.value || submitting.value) return
|
||||
submitting.value = true
|
||||
@@ -227,7 +262,8 @@ function updateFile(path: string, content: string) {
|
||||
|
||||
function filesChanged() {
|
||||
modified.value = true
|
||||
scheduleSave()
|
||||
// 老师试运行学生的提交时没有保存位置,不保存
|
||||
if (workKey.value) scheduleSave()
|
||||
}
|
||||
|
||||
// ---------- 新建、删除窗体 ----------
|
||||
@@ -318,7 +354,11 @@ function run() {
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<template v-if="assignmentId">
|
||||
<template v-if="viewing">
|
||||
<strong class="assignment-title">{{ viewing.studentName }}({{ viewing.username }})的提交:{{ viewing.assignmentTitle }}</strong>
|
||||
<span class="deadline">可以运行和试改,改动不会保存</span>
|
||||
</template>
|
||||
<template v-else-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 }">
|
||||
@@ -348,18 +388,18 @@ function run() {
|
||||
提交
|
||||
</NButton>
|
||||
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
|
||||
{{ submission.status === 'pending' ? '评分中…' : submission.status === 'done' ? `得分 ${submission.autoScore} / ${autoTotal}` : '评分出错' }}
|
||||
{{ scoreText }}
|
||||
</NButton>
|
||||
</template>
|
||||
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||
</template>
|
||||
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
|
||||
</NPopconfirm>
|
||||
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||
<span v-if="!assignmentId" class="metrics">
|
||||
<span v-if="!assignmentId && !viewing" class="metrics">
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
{{ state.metrics.lastRoundTripMs }} ms
|
||||
</span>
|
||||
@@ -379,7 +419,7 @@ function run() {
|
||||
:title="`评分结果:${assignment.title}`"
|
||||
style="width: 560px; max-width: calc(100vw - 32px)"
|
||||
>
|
||||
<GradeResultView :assignment="assignment" :submission="submission" />
|
||||
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
|
||||
</NModal>
|
||||
|
||||
<main class="workspace" :class="{ designing: activeForm }">
|
||||
|
||||
+13
-1
@@ -1,4 +1,4 @@
|
||||
// 页面路由:登录页、实验页(自由练习和做作业共用)、学生的作业列表、老师的班级管理页。
|
||||
// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、老师的班级管理页和评阅页。
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { apiHooks } from './api'
|
||||
import { currentUser } from './auth'
|
||||
@@ -11,6 +11,18 @@ export const router = createRouter({
|
||||
{ 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: '/teacher/assignments/:id',
|
||||
component: () => import('./pages/ReviewPage.vue'),
|
||||
props: (route) => ({ assignmentId: String(route.params.id) }),
|
||||
meta: { teacher: true },
|
||||
},
|
||||
{
|
||||
path: '/teacher/submissions/:id/run',
|
||||
component: () => import('./pages/WorkbenchPage.vue'),
|
||||
props: (route) => ({ submissionId: String(route.params.id) }),
|
||||
meta: { teacher: true },
|
||||
},
|
||||
{ path: '/:rest(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
// 一个班的作业列表:布置、修改、删除作业,查看提交情况。
|
||||
// 一个班的作业列表:布置、修改、删除作业;点"已提交"进入评阅页。
|
||||
import { h, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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 router = useRouter()
|
||||
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) => {
|
||||
@@ -50,9 +49,8 @@ function remove(row: Row) {
|
||||
})
|
||||
}
|
||||
|
||||
function showSubmissions(row: Row) {
|
||||
viewing.value = row
|
||||
submissionsOpen.value = true
|
||||
function review(row: Row) {
|
||||
void router.push(`/teacher/assignments/${row.id}`)
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
@@ -65,8 +63,9 @@ const columns: DataTableColumns<Row> = [
|
||||
{
|
||||
title: '已提交',
|
||||
key: 'submitted',
|
||||
width: 120,
|
||||
render: (r) => h(NButton, { text: true, type: 'primary', onClick: () => showSubmissions(r) }, () => `${r.submittedCount} / ${r.studentCount} 人`),
|
||||
width: 150,
|
||||
render: (r) =>
|
||||
h(NButton, { text: true, type: 'primary', title: '查看提交、评阅', onClick: () => review(r) }, () => `${r.submittedCount} / ${r.studentCount} 人 · 评阅`),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -89,7 +88,6 @@ const columns: DataTableColumns<Row> = [
|
||||
<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>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
// 全班成绩表:每个学生在每个作业上的总分(自动评分 + 老师评分),点分数进入评阅页;可以导出给 Excel。
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NDataTable, NEmpty, useMessage, type DataTableColumns } from 'naive-ui'
|
||||
import { api } from '../api'
|
||||
import { downloadCsv } from './csv'
|
||||
|
||||
interface Cell {
|
||||
submitted: boolean
|
||||
auto: number | null
|
||||
manual: number | null
|
||||
total: number | null
|
||||
}
|
||||
|
||||
interface Gradebook {
|
||||
assignments: { id: number; title: string; total: number }[]
|
||||
students: { userId: number; username: string; name: string; scores: Record<number, Cell> }[]
|
||||
}
|
||||
|
||||
type Row = Gradebook['students'][number]
|
||||
|
||||
const props = defineProps<{ classId: number; className: string }>()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const book = ref<Gradebook>({ assignments: [], students: [] })
|
||||
|
||||
watch(
|
||||
() => props.classId,
|
||||
async (id) => {
|
||||
book.value = await api<Gradebook>('GET', `/api/teacher/classes/${id}/gradebook`).catch((e: Error) => {
|
||||
message.error(e.message)
|
||||
return { assignments: [], students: [] }
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const sum = (row: Row) => book.value.assignments.reduce((a, x) => a + (row.scores[x.id]?.total ?? 0), 0)
|
||||
const fullMarks = computed(() => book.value.assignments.reduce((a, x) => a + x.total, 0))
|
||||
|
||||
/** 单元格:已提交显示总分(老师还没评阅时标"待评"),未提交显示"未交" */
|
||||
function cell(row: Row, assignmentId: number) {
|
||||
const c = row.scores[assignmentId]
|
||||
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
|
||||
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
|
||||
return h(NButton, { text: true, type: 'primary', onClick: open }, () => (c.manual === null ? `${c.total}(待评)` : String(c.total)))
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<Row>>(() => [
|
||||
{ title: '学号', key: 'username', fixed: 'left', width: 110, sorter: 'default' },
|
||||
{ title: '姓名', key: 'name', fixed: 'left', width: 80 },
|
||||
...book.value.assignments.map((a) => ({
|
||||
title: `${a.title}(${a.total})`,
|
||||
key: `a${a.id}`,
|
||||
minWidth: 120,
|
||||
render: (row: Row) => cell(row, a.id),
|
||||
})),
|
||||
{ title: `合计(${fullMarks.value})`, key: 'sum', width: 110, sorter: (a: Row, b: Row) => sum(a) - sum(b), render: (row: Row) => String(sum(row)) },
|
||||
])
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(`${props.className}-成绩表.csv`, [
|
||||
['学号', '姓名', ...book.value.assignments.map((a) => `${a.title}(${a.total})`), `合计(${fullMarks.value})`],
|
||||
...book.value.students.map((s) => [
|
||||
`\t${s.username}`,
|
||||
s.name,
|
||||
...book.value.assignments.map((a) => (s.scores[a.id]?.submitted ? s.scores[a.id].total : '未交')),
|
||||
sum(s),
|
||||
]),
|
||||
])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="head">
|
||||
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"待评"表示老师还没评阅,只含自动评分。</span>
|
||||
<NButton size="small" :disabled="book.students.length === 0" @click="exportCsv">导出成绩表(CSV)</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
v-if="book.assignments.length"
|
||||
:columns="columns"
|
||||
:data="book.students"
|
||||
:row-key="(r: Row) => r.userId"
|
||||
size="small"
|
||||
:scroll-x="400 + book.assignments.length * 130"
|
||||
/>
|
||||
<NEmpty v-else description="还没有布置作业" class="empty" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
flex: 1;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.missing) {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin-top: 40px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,79 +0,0 @@
|
||||
<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>
|
||||
@@ -0,0 +1,32 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from 'bun:test'
|
||||
import { conventionHints } from './conventions'
|
||||
|
||||
const designer = (fields: string) => ({ path: 'Form1.Designer.cs', content: `partial class Form1\n{\n${fields}\n}` })
|
||||
|
||||
test('代码里用到的控件还是默认名字时提示;界面上的标签、教材的设备控件不提示', () => {
|
||||
const files = [
|
||||
designer(
|
||||
[
|
||||
'private System.Windows.Forms.Button button1;',
|
||||
'private System.Windows.Forms.Label label1;',
|
||||
'private System.Windows.Forms.TextBox txt_SendText;',
|
||||
'private WinFormsDeviceControlLibrary.Lamp lamp1;',
|
||||
'this.button1.Click += new System.EventHandler(this.button1_Click);',
|
||||
].join('\n'),
|
||||
),
|
||||
{ path: 'Form1.cs', content: 'void button1_Click()\n{\n // 打开灯\n lamp1.OnOff(true);\n txt_SendText.Text = "";\n}' },
|
||||
]
|
||||
expect(conventionHints(files)).toEqual([
|
||||
{ level: 'warn', text: '代码里用到的控件还是默认名字:button1(书上要求"控件缩写 + 有意义的英文",如 btn_Open)' },
|
||||
{ level: 'info', text: '注释 1 行,代码 3 行' },
|
||||
])
|
||||
})
|
||||
|
||||
test('没有注释时提示;Designer.cs 和 Program.cs 不算学生写的代码', () => {
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: '// 程序入口\nstatic void Main() { }' },
|
||||
{ path: 'Form1.cs', content: 'public Form1()\n{\n InitializeComponent();\n}' },
|
||||
]
|
||||
expect(conventionHints(files)).toEqual([{ level: 'warn', text: '没有写注释(代码 2 行)' }])
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
// 规范提示:给老师评"注释、命名"时参考,不计分。
|
||||
// - 控件命名:在代码里用到、却还是默认名字(button1、textBox1……)的控件。书上要求"控件缩写 + 有意义的英文",
|
||||
// 如 btn_Open;只放在界面上、代码里不用的标签、分组框可以保留默认名字(书上也是这样),
|
||||
// 教材自带的设备控件(lamp1、alarm1)书上就这样命名,也不提示。
|
||||
// - 注释:学生自己写的代码文件(不含 Designer.cs、Program.cs)里有多少行注释。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
|
||||
export interface ConventionHint {
|
||||
level: 'warn' | 'info'
|
||||
text: string
|
||||
}
|
||||
|
||||
const DEFAULT_NAME = /^(button|label|textBox|comboBox|checkBox|radioButton|listBox)\d+$/
|
||||
|
||||
const studentCode = (files: SourceFile[]) => files.filter((f) => f.path.endsWith('.cs') && !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')
|
||||
|
||||
export function conventionHints(files: SourceFile[]): ConventionHint[] {
|
||||
const hints: ConventionHint[] = []
|
||||
const code = studentCode(files)
|
||||
const codeText = code.map((f) => f.content).join('\n')
|
||||
|
||||
const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).map((f) => f.content)
|
||||
const controls = designers.flatMap((text) => [...text.matchAll(/^\s*private\s+[\w.]+\s+(\w+);/gm)].map((m) => m[1]))
|
||||
// 绑定了事件(this.button1.Click += ...)或在代码里用到,就算"用到"
|
||||
const withEvents = new Set(designers.flatMap((text) => [...text.matchAll(/this\.(\w+)\.\w+\s*\+=/g)].map((m) => m[1])))
|
||||
const usedInCode = (name: string) => new RegExp(`(?<![\\w])${name}(?![\\w])`).test(codeText)
|
||||
const defaults = controls.filter((name) => DEFAULT_NAME.test(name) && (withEvents.has(name) || usedInCode(name)))
|
||||
if (defaults.length) {
|
||||
hints.push({ level: 'warn', text: `代码里用到的控件还是默认名字:${defaults.join('、')}(书上要求"控件缩写 + 有意义的英文",如 btn_Open)` })
|
||||
}
|
||||
|
||||
let comments = 0
|
||||
let lines = 0
|
||||
for (const line of codeText.split('\n').map((l) => l.trim())) {
|
||||
if (!line || line === '{' || line === '}' || line.startsWith('using ')) continue
|
||||
if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) comments++
|
||||
else {
|
||||
lines++
|
||||
if (line.includes('//')) comments++
|
||||
}
|
||||
}
|
||||
if (lines > 0) {
|
||||
hints.push(comments === 0 ? { level: 'warn', text: `没有写注释(代码 ${lines} 行)` } : { level: 'info', text: `注释 ${comments} 行,代码 ${lines} 行` })
|
||||
}
|
||||
return hints
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// 下载 CSV:开头带 BOM,Excel 打开时中文不乱码。
|
||||
// 学号、密码等长数字前加制表符(cell 以 \t 开头),Excel 不会把它变成科学计数法。
|
||||
export function downloadCsv(filename: string, rows: (string | number | null)[][]) {
|
||||
const quote = (value: string | number | null) => {
|
||||
const cell = value === null ? '' : String(value)
|
||||
return /[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell
|
||||
}
|
||||
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
Reference in new issue
Block a user