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

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

+3
View File
@@ -9,6 +9,8 @@ const props = defineProps<{
files: SourceFile[]
activeFile?: string
diagnostics: Diagnostic[]
/** 只读(老师查看学生提交的代码) */
readOnly?: boolean
}>()
const emit = defineEmits<{
@@ -96,6 +98,7 @@ onMounted(() => {
scrollBeyondLastLine: false,
renderWhitespace: 'selection',
unicodeHighlight: { ambiguousCharacters: false },
readOnly: props.readOnly,
})
syncModels()
showActive()
+40 -6
View File
@@ -1,13 +1,19 @@
<script setup lang="ts">
// 一次提交的自动评分结果:每项通过与否、得分和不通过的原因;老师评分项只列出名称和分值。
// 一次提交的评分结果:自动评分每项通过与否、得分和不通过的原因;
// 老师评分项:老师已评阅时显示各项得分和评语,还没评阅时只列出名称和分值。
import { computed } from 'vue'
import { NSpin } from 'naive-ui'
import type { Assignment, Submission } from '@iot/shared'
import type { Assignment, Review, Submission } from '@iot/shared'
const props = defineProps<{ assignment: Assignment; submission: Submission }>()
const props = withDefaults(defineProps<{ assignment: Assignment; submission: Submission; review?: Review | null; showManual?: boolean }>(), {
review: null,
showManual: true,
})
const autoTotal = computed(() => props.assignment.scoring.auto.reduce((a, b) => a + b, 0))
const manual = computed(() => props.assignment.scoring.manual)
const manualTotal = computed(() => manual.value.reduce((a, m) => a + m.points, 0))
const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) => a + (s ?? 0), 0) ?? 0)
</script>
<template>
@@ -34,9 +40,22 @@ const manual = computed(() => props.assignment.scoring.manual)
</li>
</ul>
</template>
<div v-if="manual.length" class="manual">
由老师评分:<span v-for="m in manual" :key="m.title">{{ m.title }}({{ m.points }} 分)</span>
</div>
<template v-if="showManual && manual.length">
<div v-if="review" class="reviewed">
<div class="summary">
老师评分 <b>{{ manualScore }}</b> / {{ manualTotal }} 分
</div>
<ul class="items">
<li v-for="(m, i) in manual" :key="i">
<span class="title">{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span>
</li>
</ul>
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
</div>
<div v-else class="manual">
由老师评分:<span v-for="m in manual" :key="m.title">{{ m.title }}({{ m.points }} 分)</span>
</div>
</template>
</div>
</template>
@@ -99,6 +118,21 @@ const manual = computed(() => props.assignment.scoring.manual)
color: #b91c1c;
}
.reviewed {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.comment {
padding: 8px 10px;
background: #f9fafb;
border-radius: 4px;
white-space: pre-wrap;
}
.manual {
color: #6b7280;
}
+5 -1
View File
@@ -7,7 +7,7 @@ import type { Assignment, Submission } from '@iot/shared'
import { api } from '../api'
import { currentUser, logout } from '../auth'
type Row = Assignment & { latest: Submission | null }
type Row = Assignment & { latest: Submission | null; manualScore: number | null }
const router = useRouter()
const rows = ref<Row[]>([])
@@ -27,6 +27,10 @@ function status(row: Row) {
if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交')
if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
if (row.manualScore !== null) {
const all = total(row) + row.scoring.manual.reduce((a, m) => a + m.points, 0)
return h(NTag, { type: 'success', size: 'small' }, () => `老师已评阅 · 总分 ${(s.autoScore ?? 0) + row.manualScore!} / ${all}`)
}
const full = s.autoScore === total(row)
return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${s.autoScore} / ${total(row)}`)
}
+440
View File
@@ -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>
+6 -12
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业、查看提交情况。
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业(评阅在 ReviewPage);全班成绩表。
import { computed, h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, NTabPane, NTabs, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import AssignmentsPanel from '../teacher/AssignmentsPanel.vue'
import GradebookPanel from '../teacher/GradebookPanel.vue'
import { downloadCsv } from '../teacher/csv'
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
interface ClassInfo {
@@ -177,17 +179,6 @@ async function confirmImport() {
})
}
/** 下载 CSV:带 BOM,Excel 打开时中文不乱码;账号前加制表符,长学号不会变成科学计数法 */
function downloadCsv(filename: string, rows: string[][]) {
const quote = (cell: string) => (/[",\n]/.test(cell) ? `"${cell.replaceAll('"', '""')}"` : cell)
const text = '' + rows.map((r) => r.map(quote).join(',')).join('\r\n') + '\r\n'
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }))
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [
@@ -282,6 +273,9 @@ async function signOut() {
<NTabPane name="assignments" tab="作业">
<AssignmentsPanel :class-id="selected.id" />
</NTabPane>
<NTabPane name="grades" tab="成绩" display-directive="if">
<GradebookPanel :class-id="selected.id" :class-name="selected.name" />
</NTabPane>
</NTabs>
</section>
<section v-else class="students">
+51 -11
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
// 两种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分)。
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
import type { Assignment, SourceFile, Submission } from '@iot/shared'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
@@ -18,7 +19,7 @@ import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
const props = defineProps<{ assignmentId?: string }>()
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
const session = createRunSession()
const { state } = session
@@ -98,6 +99,10 @@ onMounted(async () => {
await loadAssignment()
return
}
if (props.submissionId) {
await loadSubmission()
return
}
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
@@ -128,14 +133,26 @@ async function loadSample(id: string, discardWork = false) {
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null)
const review = ref<Review | null>(null)
const resultOpen = ref(false)
const submitting = ref(false)
const pastDeadline = computed(() => !!assignment.value?.deadline && Date.now() > assignment.value.deadline)
const autoTotal = computed(() => assignment.value?.scoring.auto.reduce((a, b) => a + b, 0) ?? 0)
const manualTotal = computed(() => assignment.value?.scoring.manual.reduce((a, m) => a + m.points, 0) ?? 0)
/** 工具栏上的得分:老师评阅后显示总分,之前只显示自动评分 */
const scoreText = computed(() => {
const s = submission.value
if (!s) return ''
if (s.status === 'pending') return '评分中…'
if (s.status === 'error') return '评分出错'
if (!review.value) return `自动评分 ${s.autoScore} / ${autoTotal.value}`
const manual = review.value.scores.reduce<number>((a, x) => a + (x ?? 0), 0)
return `总分 ${(s.autoScore ?? 0) + manual} / ${autoTotal.value + manualTotal.value}`
})
let pollTimer: ReturnType<typeof setTimeout> | undefined
async function loadAssignment(discardWork = false) {
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null }>(
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null; review: Review | null }>(
'GET',
`/api/assignments/${props.assignmentId}`,
).catch((e: Error) => {
@@ -145,10 +162,28 @@ async function loadAssignment(discardWork = false) {
if (!detail) return
assignment.value = detail.assignment
submission.value = detail.latest
review.value = detail.review
if (detail.latest?.status === 'pending') pollSubmission()
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
}
// ---------- 老师运行学生提交的程序 ----------
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
async function loadSubmission() {
try {
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
'GET',
`/api/teacher/submissions/${props.submissionId}`,
)
viewing.value = result
files.value = result.files
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
} catch (e) {
message.error((e as Error).message)
}
}
async function submit() {
if (!assignment.value || submitting.value) return
submitting.value = true
@@ -227,7 +262,8 @@ function updateFile(path: string, content: string) {
function filesChanged() {
modified.value = true
scheduleSave()
// 老师试运行学生的提交时没有保存位置,不保存
if (workKey.value) scheduleSave()
}
// ---------- 新建、删除窗体 ----------
@@ -318,7 +354,11 @@ function run() {
<template>
<div class="page">
<header class="toolbar">
<template v-if="assignmentId">
<template v-if="viewing">
<strong class="assignment-title">{{ viewing.studentName }}({{ viewing.username }})的提交:{{ viewing.assignmentTitle }}</strong>
<span class="deadline">可以运行和试改,改动不会保存</span>
</template>
<template v-else-if="assignmentId">
<RouterLink to="/assignments" class="nav-link">← 我的作业</RouterLink>
<strong class="assignment-title">{{ assignment?.title }}</strong>
<span v-if="assignment?.deadline" class="deadline" :class="{ past: pastDeadline }">
@@ -348,18 +388,18 @@ function run() {
提交
</NButton>
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
{{ submission.status === 'pending' ? '评分中…' : submission.status === 'done' ? `得分 ${submission.autoScore} / ${autoTotal}` : '评分出错' }}
{{ scoreText }}
</NButton>
</template>
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
</template>
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
</NPopconfirm>
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span v-if="!assignmentId" class="metrics">
<span v-if="!assignmentId && !viewing" class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
</span>
@@ -379,7 +419,7 @@ function run() {
:title="`评分结果:${assignment.title}`"
style="width: 560px; max-width: calc(100vw - 32px)"
>
<GradeResultView :assignment="assignment" :submission="submission" />
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
</NModal>
<main class="workspace" :class="{ designing: activeForm }">
+13 -1
View File
@@ -1,4 +1,4 @@
// 页面路由:登录页、实验页(自由练习和做作业共用)、学生的作业列表、老师的班级管理页。
// 页面路由:登录页、实验页(自由练习、做作业、老师运行学生的提交共用)、学生的作业列表、老师的班级管理页和评阅页。
import { createRouter, createWebHistory } from 'vue-router'
import { apiHooks } from './api'
import { currentUser } from './auth'
@@ -11,6 +11,18 @@ export const router = createRouter({
{ path: '/assignments', component: () => import('./pages/AssignmentsPage.vue') },
{ path: '/assignments/:id', component: () => import('./pages/WorkbenchPage.vue'), props: (route) => ({ assignmentId: String(route.params.id) }) },
{ path: '/teacher', component: () => import('./pages/TeacherPage.vue'), meta: { teacher: true } },
{
path: '/teacher/assignments/:id',
component: () => import('./pages/ReviewPage.vue'),
props: (route) => ({ assignmentId: String(route.params.id) }),
meta: { teacher: true },
},
{
path: '/teacher/submissions/:id/run',
component: () => import('./pages/WorkbenchPage.vue'),
props: (route) => ({ submissionId: String(route.params.id) }),
meta: { teacher: true },
},
{ path: '/:rest(.*)*', redirect: '/' },
],
})
+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)
}