评阅与成绩:老师看代码、运行学生程序、打分、写评语;成绩表与导出

- 评阅页:全班学生列表(提交状态、得分、是否已评阅);选中学生的代码(只读)、自动评分结果、
  规范提示(代码里用到的默认名字控件、注释行数)、老师评分项打分和评语,"保存并看下一个"
- "运行他的程序":新标签页里打开学生的提交,可以操作实训台,改动不保存
- 学生能看到老师的各项得分和评语;作业列表显示总分
- 班级管理页"成绩"标签页:学生 × 作业的总分,点分数进入评阅;单个作业和全班成绩导出 CSV
- 老师增删评分项后,已打的分按新的评分项对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:13:30 +08:00
1 parent 4e6b0d5cb2
commit 89e9956a22
20 files changed
+999 -127

No files matched your search

+8 -10
View File
@@ -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>
+114
View File
@@ -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>
-79
View File
@@ -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>
+32
View File
@@ -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 行)' }])
})
+46
View File
@@ -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
}
+14
View File
@@ -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)
}