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