评阅与成绩:老师看代码、运行学生程序、打分、写评语;成绩表与导出
- 评阅页:全班学生列表(提交状态、得分、是否已评阅);选中学生的代码(只读)、自动评分结果、 规范提示(代码里用到的默认名字控件、注释行数)、老师评分项打分和评语,"保存并看下一个" - "运行他的程序":新标签页里打开学生的提交,可以操作实训台,改动不保存 - 学生能看到老师的各项得分和评语;作业列表显示总分 - 班级管理页"成绩"标签页:学生 × 作业的总分,点分数进入评阅;单个作业和全班成绩导出 CSV - 老师增删评分项后,已打的分按新的评分项对齐 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e6b0d5cb2
commit
89e9956a22
20 files changed
+997
-125
No files matched your search
@@ -71,7 +71,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
|||||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。
|
- 部署: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
|
flowchart TB
|
||||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
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 模拟数据库、图表、虚拟时钟"]
|
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||||
@@ -273,6 +273,7 @@ flowchart TB
|
|||||||
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
- 2026-10-22:账号与班级:老师账号由配置设定;老师建班、上传 CSV 名单导入学生(兼容 Excel 导出的 GBK 和 UTF-8,识别表头,发现被 Excel 变成科学计数法的学号),查看、重置密码,导出带密码的名单(Excel 打开不乱码);登录页取代原来的统一访问口令;登录用访问令牌(JWT)+ 刷新令牌,不用 cookie。学生的作品自动保存在服务器上(按账号和实验),换电脑登录可以接着做;页面上显示保存状态。
|
||||||
- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。
|
- 2026-10-22:自动评分:每个示例一份 `checks.yaml`(共 23 项,由书上评价表的功能项转来);引擎在沙箱里每项单独启动一次学生程序,按书上的控件名操作、调节实训台、核对结果,不通过时写明期望和实际。参考答案全部通过,几种典型错误各自报出对应原因。
|
||||||
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
|
- 2026-10-22:布置与提交作业:老师选实验、起点(界面已做好 / 空白工程,起点工程由设计器引擎从参考答案生成)、截止时间和每项分值(自动评分项 + 老师评分项);布置时保存起点和评分规则的快照。学生在"我的作业"里打开作业,提交后排队评分(全站同时最多评 3 份、跑 6 个检查项),结果立即可见;截止后不能再交。老师看全班的提交情况和自动得分。
|
||||||
|
- 2026-10-22:评阅与成绩:评阅页左边是全班学生(提交状态、得分、是否已评阅),右边是选中学生的代码(只读)、自动评分结果、规范提示(代码里用到却还是默认名字的控件、注释行数)和老师评分项打分、评语,可以"保存并看下一个";"运行他的程序"在新标签页的实验页里打开学生的提交(可以操作实训台,改动不保存)。学生能看到老师的各项得分和评语。班级管理页加"成绩"标签页:学生 × 作业的总分(自动评分 + 老师评分),点分数进入评阅;单个作业和全班成绩都能导出 CSV(Excel 打开不乱码)。
|
||||||
|
|
||||||
## 技术风险与待确认事项
|
## 技术风险与待确认事项
|
||||||
|
|
||||||
|
|||||||
+35
-4
@@ -16,6 +16,7 @@ import {
|
|||||||
updateAssignment,
|
updateAssignment,
|
||||||
} from './assignments'
|
} from './assignments'
|
||||||
import { parseChecks } from './grading/checks'
|
import { parseChecks } from './grading/checks'
|
||||||
|
import { getReview, gradebook, manualScore, saveReview, studentDetail, submissionFiles } from './reviews'
|
||||||
import type { GradingQueue } from './grading/queue'
|
import type { GradingQueue } from './grading/queue'
|
||||||
import { listSamples, loadChecks, loadSample } from './samples'
|
import { listSamples, loadChecks, loadSample } from './samples'
|
||||||
import { serveStatic } from './static'
|
import { serveStatic } from './static'
|
||||||
@@ -114,7 +115,7 @@ export function createApp(db: Database, gradingQueue: GradingQueue) {
|
|||||||
return c.json(
|
return c.json(
|
||||||
listAssignments(db, user.classId).map((a) => {
|
listAssignments(db, user.classId).map((a) => {
|
||||||
const latest = latestSubmission(db, a.id, user.id)
|
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) => {
|
app.get('/api/assignments/:id', (c) => {
|
||||||
const assignment = visibleAssignment(c)
|
const assignment = visibleAssignment(c)
|
||||||
if (!assignment) return c.json({ message: '作业不存在' }, 404)
|
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) => {
|
app.post('/api/assignments/:id/submit', async (c) => {
|
||||||
@@ -236,8 +238,37 @@ export function createApp(db: Database, gradingQueue: GradingQueue) {
|
|||||||
return c.json({})
|
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))
|
app.all('/api/*', (c) => c.json({ message: '接口不存在' }, 404))
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
// SQLite 数据库:账号、班级、学生作品、作业与提交、平台设置(如 JWT 签名密钥)。
|
// SQLite 数据库:账号、班级、学生作品、作业、提交与老师评阅、平台设置(如 JWT 签名密钥)。
|
||||||
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
|
// 表结构用编号迁移:MIGRATIONS 只能在末尾追加,已执行过的不再改动。
|
||||||
import { Database } from 'bun:sqlite'
|
import { Database } from 'bun:sqlite'
|
||||||
import { mkdirSync } from 'node:fs'
|
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_assignment_user ON submissions (assignment_id, user_id, id);
|
||||||
CREATE INDEX submissions_status ON submissions (status);
|
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:" 时用内存数据库(测试用) */
|
/** 打开数据库并执行未执行过的迁移。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([])
|
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
|
queueAhead?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 老师评阅:老师评分项各自的得分(null 表示还没打分,和 scoring.manual 一一对应)和评语 */
|
||||||
|
export interface Review {
|
||||||
|
scores: (number | null)[]
|
||||||
|
comment: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ const props = defineProps<{
|
|||||||
files: SourceFile[]
|
files: SourceFile[]
|
||||||
activeFile?: string
|
activeFile?: string
|
||||||
diagnostics: Diagnostic[]
|
diagnostics: Diagnostic[]
|
||||||
|
/** 只读(老师查看学生提交的代码) */
|
||||||
|
readOnly?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -96,6 +98,7 @@ onMounted(() => {
|
|||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
renderWhitespace: 'selection',
|
renderWhitespace: 'selection',
|
||||||
unicodeHighlight: { ambiguousCharacters: false },
|
unicodeHighlight: { ambiguousCharacters: false },
|
||||||
|
readOnly: props.readOnly,
|
||||||
})
|
})
|
||||||
syncModels()
|
syncModels()
|
||||||
showActive()
|
showActive()
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 一次提交的自动评分结果:每项通过与否、得分和不通过的原因;老师评分项只列出名称和分值。
|
// 一次提交的评分结果:自动评分每项通过与否、得分和不通过的原因;
|
||||||
|
// 老师评分项:老师已评阅时显示各项得分和评语,还没评阅时只列出名称和分值。
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { NSpin } from 'naive-ui'
|
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 autoTotal = computed(() => props.assignment.scoring.auto.reduce((a, b) => a + b, 0))
|
||||||
const manual = computed(() => props.assignment.scoring.manual)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -34,9 +40,22 @@ const manual = computed(() => props.assignment.scoring.manual)
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="manual.length" class="manual">
|
<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>
|
由老师评分:<span v-for="m in manual" :key="m.title">{{ m.title }}({{ m.points }} 分)</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,6 +118,21 @@ const manual = computed(() => props.assignment.scoring.manual)
|
|||||||
color: #b91c1c;
|
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 {
|
.manual {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Assignment, Submission } from '@iot/shared'
|
|||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { currentUser, logout } from '../auth'
|
import { currentUser, logout } from '../auth'
|
||||||
|
|
||||||
type Row = Assignment & { latest: Submission | null }
|
type Row = Assignment & { latest: Submission | null; manualScore: number | null }
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const rows = ref<Row[]>([])
|
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) 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 === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
|
||||||
if (s.status === 'error') return h(NTag, { type: 'error', 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)
|
const full = s.autoScore === total(row)
|
||||||
return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${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">
|
<script setup lang="ts">
|
||||||
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业、查看提交情况。
|
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业(评阅在 ReviewPage);全班成绩表。
|
||||||
import { computed, h, onMounted, ref } from 'vue'
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, NTabPane, NTabs, 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 { api } from '../api'
|
||||||
import { currentUser, logout } from '../auth'
|
import { currentUser, logout } from '../auth'
|
||||||
import AssignmentsPanel from '../teacher/AssignmentsPanel.vue'
|
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'
|
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
|
||||||
|
|
||||||
interface ClassInfo {
|
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() {
|
function exportRoster() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
downloadCsv(`${selected.value.name}-名单.csv`, [
|
downloadCsv(`${selected.value.name}-名单.csv`, [
|
||||||
@@ -282,6 +273,9 @@ async function signOut() {
|
|||||||
<NTabPane name="assignments" tab="作业">
|
<NTabPane name="assignments" tab="作业">
|
||||||
<AssignmentsPanel :class-id="selected.id" />
|
<AssignmentsPanel :class-id="selected.id" />
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
|
<NTabPane name="grades" tab="成绩" display-directive="if">
|
||||||
|
<GradebookPanel :class-id="selected.id" :class-name="selected.name" />
|
||||||
|
</NTabPane>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="students">
|
<section v-else class="students">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||||
// 两种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分)。
|
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
|
||||||
|
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
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 FormDesigner from '../designer/FormDesigner.vue'
|
||||||
import { createDesignerHistory } from '../designer/history'
|
import { createDesignerHistory } from '../designer/history'
|
||||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
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 FormWindow from '../winforms/FormWindow.vue'
|
||||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||||
|
|
||||||
const props = defineProps<{ assignmentId?: string }>()
|
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
|
||||||
|
|
||||||
const session = createRunSession()
|
const session = createRunSession()
|
||||||
const { state } = session
|
const { state } = session
|
||||||
@@ -98,6 +99,10 @@ onMounted(async () => {
|
|||||||
await loadAssignment()
|
await loadAssignment()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (props.submissionId) {
|
||||||
|
await loadSubmission()
|
||||||
|
return
|
||||||
|
}
|
||||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||||
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
|
||||||
@@ -128,14 +133,26 @@ async function loadSample(id: string, discardWork = false) {
|
|||||||
// ---------- 作业 ----------
|
// ---------- 作业 ----------
|
||||||
const assignment = ref<Assignment | null>(null)
|
const assignment = ref<Assignment | null>(null)
|
||||||
const submission = ref<Submission | null>(null)
|
const submission = ref<Submission | null>(null)
|
||||||
|
const review = ref<Review | null>(null)
|
||||||
const resultOpen = ref(false)
|
const resultOpen = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const pastDeadline = computed(() => !!assignment.value?.deadline && Date.now() > assignment.value.deadline)
|
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 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
|
let pollTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
async function loadAssignment(discardWork = false) {
|
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',
|
'GET',
|
||||||
`/api/assignments/${props.assignmentId}`,
|
`/api/assignments/${props.assignmentId}`,
|
||||||
).catch((e: Error) => {
|
).catch((e: Error) => {
|
||||||
@@ -145,10 +162,28 @@ async function loadAssignment(discardWork = false) {
|
|||||||
if (!detail) return
|
if (!detail) return
|
||||||
assignment.value = detail.assignment
|
assignment.value = detail.assignment
|
||||||
submission.value = detail.latest
|
submission.value = detail.latest
|
||||||
|
review.value = detail.review
|
||||||
if (detail.latest?.status === 'pending') pollSubmission()
|
if (detail.latest?.status === 'pending') pollSubmission()
|
||||||
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
|
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() {
|
async function submit() {
|
||||||
if (!assignment.value || submitting.value) return
|
if (!assignment.value || submitting.value) return
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
@@ -227,7 +262,8 @@ function updateFile(path: string, content: string) {
|
|||||||
|
|
||||||
function filesChanged() {
|
function filesChanged() {
|
||||||
modified.value = true
|
modified.value = true
|
||||||
scheduleSave()
|
// 老师试运行学生的提交时没有保存位置,不保存
|
||||||
|
if (workKey.value) scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 新建、删除窗体 ----------
|
// ---------- 新建、删除窗体 ----------
|
||||||
@@ -318,7 +354,11 @@ function run() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="toolbar">
|
<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>
|
<RouterLink to="/assignments" class="nav-link">← 我的作业</RouterLink>
|
||||||
<strong class="assignment-title">{{ assignment?.title }}</strong>
|
<strong class="assignment-title">{{ assignment?.title }}</strong>
|
||||||
<span v-if="assignment?.deadline" class="deadline" :class="{ past: pastDeadline }">
|
<span v-if="assignment?.deadline" class="deadline" :class="{ past: pastDeadline }">
|
||||||
@@ -348,18 +388,18 @@ function run() {
|
|||||||
提交
|
提交
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
|
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
|
||||||
{{ submission.status === 'pending' ? '评分中…' : submission.status === 'done' ? `得分 ${submission.autoScore} / ${autoTotal}` : '评分出错' }}
|
{{ scoreText }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</template>
|
</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>
|
<template #trigger>
|
||||||
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
|
||||||
</template>
|
</template>
|
||||||
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
|
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
|
||||||
</NPopconfirm>
|
</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>
|
<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.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||||
{{ state.metrics.lastRoundTripMs }} ms
|
{{ state.metrics.lastRoundTripMs }} ms
|
||||||
</span>
|
</span>
|
||||||
@@ -379,7 +419,7 @@ function run() {
|
|||||||
:title="`评分结果:${assignment.title}`"
|
:title="`评分结果:${assignment.title}`"
|
||||||
style="width: 560px; max-width: calc(100vw - 32px)"
|
style="width: 560px; max-width: calc(100vw - 32px)"
|
||||||
>
|
>
|
||||||
<GradeResultView :assignment="assignment" :submission="submission" />
|
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
|
||||||
</NModal>
|
</NModal>
|
||||||
|
|
||||||
<main class="workspace" :class="{ designing: activeForm }">
|
<main class="workspace" :class="{ designing: activeForm }">
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,4 @@
|
|||||||
// 页面路由:登录页、实验页(自由练习和做作业共用)、学生的作业列表、老师的班级管理页。
|
// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、老师的班级管理页和评阅页。
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { apiHooks } from './api'
|
import { apiHooks } from './api'
|
||||||
import { currentUser } from './auth'
|
import { currentUser } from './auth'
|
||||||
@@ -11,6 +11,18 @@ export const router = createRouter({
|
|||||||
{ path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') },
|
{ path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') },
|
||||||
{ path: '/assignments/:id', component: () => import('./pages/WorkbenchPage.vue'), props: (route) => ({ assignmentId: String(route.params.id) }) },
|
{ 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', 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: '/' },
|
{ path: '/:rest(.*)*', redirect: '/' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 一个班的作业列表:布置、修改、删除作业,查看提交情况。
|
// 一个班的作业列表:布置、修改、删除作业;点"已提交"进入评阅页。
|
||||||
import { h, ref, watch } from 'vue'
|
import { h, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { NButton, NDataTable, NEmpty, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
import { NButton, NDataTable, NEmpty, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
|
||||||
import type { Assignment } from '@iot/shared'
|
import type { Assignment } from '@iot/shared'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import AssignmentDialog from './AssignmentDialog.vue'
|
import AssignmentDialog from './AssignmentDialog.vue'
|
||||||
import SubmissionsDialog from './SubmissionsDialog.vue'
|
|
||||||
|
|
||||||
type Row = Assignment & { studentCount: number; submittedCount: number }
|
type Row = Assignment & { studentCount: number; submittedCount: number }
|
||||||
|
|
||||||
const props = defineProps<{ classId: number }>()
|
const props = defineProps<{ classId: number }>()
|
||||||
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const rows = ref<Row[]>([])
|
const rows = ref<Row[]>([])
|
||||||
const editorOpen = ref(false)
|
const editorOpen = ref(false)
|
||||||
const editing = ref<Assignment | null>(null)
|
const editing = ref<Assignment | null>(null)
|
||||||
const submissionsOpen = ref(false)
|
|
||||||
const viewing = ref<Assignment | null>(null)
|
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
rows.value = await api<Row[]>('GET', `/api/teacher/classes/${props.classId}/assignments`).catch((e: Error) => {
|
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) {
|
function review(row: Row) {
|
||||||
viewing.value = row
|
void router.push(`/teacher/assignments/${row.id}`)
|
||||||
submissionsOpen.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (ms: number) =>
|
const formatTime = (ms: number) =>
|
||||||
@@ -65,8 +63,9 @@ const columns: DataTableColumns<Row> = [
|
|||||||
{
|
{
|
||||||
title: '已提交',
|
title: '已提交',
|
||||||
key: 'submitted',
|
key: 'submitted',
|
||||||
width: 120,
|
width: 150,
|
||||||
render: (r) => h(NButton, { text: true, type: 'primary', onClick: () => showSubmissions(r) }, () => `${r.submittedCount} / ${r.studentCount} 人`),
|
render: (r) =>
|
||||||
|
h(NButton, { text: true, type: 'primary', title: '查看提交、评阅', onClick: () => review(r) }, () => `${r.submittedCount} / ${r.studentCount} 人 · 评阅`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
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" />
|
<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" />
|
<NEmpty v-else description="还没有布置作业" class="empty" />
|
||||||
<AssignmentDialog v-model:show="editorOpen" :class-id="classId" :editing="editing" @saved="load" />
|
<AssignmentDialog v-model:show="editorOpen" :class-id="classId" :editing="editing" @saved="load" />
|
||||||
<SubmissionsDialog v-model:show="submissionsOpen" :assignment="viewing" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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