布置作业、提交与自动评分

- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项);
  起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照
- 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交
- 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示
- 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分
- 串口列表为空时提示在 Load 或 Click 事件里填充
- 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:02:22 +08:00
1 parent 37fc747b49
commit 4e6b0d5cb2
23 files changed
+1439 -50

No files matched your search

+2 -1
View File
@@ -6,7 +6,8 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
<NConfigProvider :locale="zhCN">
<NMessageProvider>
<NDialogProvider>
<RouterView />
<!-- 换一个作业时重新创建页面(同一页面里只换参数时 Vue 默认会复用组件) -->
<RouterView :key="$route.path" />
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>
+34 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from 'bun:test'
import { generateDesigner, parseDesigner } from './document'
import { addControl } from './edit'
import { controlDef } from './catalog'
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
import { assignmentTemplate, formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
const files = [
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
@@ -27,3 +27,36 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正
expect(text).toContain(' this.Controls.Add(this.button1);\n this.Name = "AddUser";')
expect(text).toContain(' #endregion\n\n private System.Windows.Forms.Button button1;\n }')
})
/** 读取 samples/ 下的示例工程 */
async function sample(id: string) {
const dir = `${import.meta.dir}/../../../samples/${id}`
const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir }))
return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() })))
}
test('作业起点"界面已做好":保留控件和属性,去掉事件绑定和事件处理方法', async () => {
const files = assignmentTemplate(await sample('p2-t1-LED显示屏'), 'ui')
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
const designer = files.find((f) => f.path === 'Form1.Designer.cs')!.content
expect(designer).toContain('this.btn_Open = new System.Windows.Forms.Button();')
expect(designer).toContain('this.btn_Open.Text = "打 开";')
expect(designer).not.toContain('+=')
const code = files.find((f) => f.path === 'Form1.cs')!.content
expect(code).toContain('namespace LED显示屏')
expect(code).not.toContain('btn_Open_Click')
})
test('作业起点"界面已做好":多个窗体都保留,其他类(PublicData)由学生自己写', async () => {
const files = assignmentTemplate(await sample('p1-t1-登录'), 'ui')
expect(files.map((f) => f.path).sort()).toEqual(['AddUser.Designer.cs', 'AddUser.cs', 'Login.Designer.cs', 'Login.cs', 'Program.cs'])
expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form')
})
test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank')
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
const program = files.find((f) => f.path === 'Program.cs')!.content
expect(program).toContain('Application.Run(new Form1())')
expect(files.find((f) => f.path === 'Form1.Designer.cs')!.content).not.toContain('com_port')
})
+28 -1
View File
@@ -1,5 +1,6 @@
// 新建窗体:生成与 VS(中文版、.NET Framework 4.5)新建窗体并保存一次后相同的两个文件。
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。
import type { SourceFile } from '@iot/shared'
import { generateDesigner, parseDesigner } from './document'
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
export function projectNamespace(files: SourceFile[]): string {
@@ -104,3 +105,29 @@ ${blockHeader(name)}
{ path: `${name}.Designer.cs`, content: designer },
]
}
export type StartMode = 'blank' | 'ui'
/**
* 布置作业时学生的起点工程(由参考答案生成):
* - blank:空白工程,只有 Program.cs 和一个空的 Form1,学生从搭界面做起;
* - ui:界面已做好,保留每个窗体的控件和属性,去掉事件绑定,窗体代码只剩构造函数,
* 学生在设计器里双击控件生成事件处理方法、写代码。其他类(如 PublicData)也要学生自己写。
*/
export function assignmentTemplate(files: SourceFile[], mode: StartMode): SourceFile[] {
const namespace = projectNamespace(files)
const program = files.find((f) => f.path === 'Program.cs')
if (mode === 'blank') {
const startup = startupForm(files)
const programText = program && startup ? program.content.replace(new RegExp(`new\\s+${startup}\\s*\\(`, 'u'), 'new Form1(') : undefined
return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')]
}
const result: SourceFile[] = program ? [{ ...program }] : []
for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) {
const doc = parseDesigner(designer.content)
for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event')
const [code] = newFormFiles(namespace, doc.className)
result.push({ path: designer.path.replace(/\.Designer\.cs$/, '.cs'), content: code.content }, { path: designer.path, content: generateDesigner(doc) })
}
return result
}
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
// 一次提交的自动评分结果:每项通过与否、得分和不通过的原因;老师评分项只列出名称和分值。
import { computed } from 'vue'
import { NSpin } from 'naive-ui'
import type { Assignment, Submission } from '@iot/shared'
const props = defineProps<{ assignment: Assignment; submission: Submission }>()
const autoTotal = computed(() => props.assignment.scoring.auto.reduce((a, b) => a + b, 0))
const manual = computed(() => props.assignment.scoring.manual)
</script>
<template>
<div class="grade">
<div v-if="submission.status === 'pending'" class="pending">
<NSpin size="small" />
<span>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span>
</div>
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
<template v-else-if="submission.result">
<div class="summary">
自动评分 <b>{{ submission.autoScore }}</b> / {{ autoTotal }} 分
<span v-if="!submission.result.compiled" class="error">(编译没有通过,先改正编译错误)</span>
</div>
<ul class="items">
<li v-for="(r, i) in submission.result.results" :key="i" :class="r.passed ? 'passed' : 'failed'">
<span class="mark">{{ r.passed ? '✓' : '✗' }}</span>
<div>
<div class="title">
{{ r.title }}<span class="points">{{ r.passed ? assignment.scoring.auto[i] : 0 }} / {{ assignment.scoring.auto[i] }} 分</span>
</div>
<div v-if="r.message" class="message">{{ r.message }}</div>
</div>
</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>
</div>
</template>
<style scoped>
.grade {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 13px;
}
.pending {
display: flex;
align-items: center;
gap: 8px;
}
.summary b {
font-size: 18px;
}
.items {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.items li {
display: flex;
gap: 8px;
}
.mark {
font-weight: bold;
}
.passed .mark {
color: #16a34a;
}
.failed .mark,
.error {
color: #dc2626;
}
.title {
display: flex;
gap: 12px;
}
.points {
color: #6b7280;
}
.message {
margin-top: 2px;
color: #b91c1c;
}
.manual {
color: #6b7280;
}
.manual span + span::before {
content: "、";
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
// 学生的作业列表:自己班的作业、截止时间、提交和得分情况。
import { h, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { NButton, NDataTable, NEmpty, NTag, type DataTableColumns } from 'naive-ui'
import type { Assignment, Submission } from '@iot/shared'
import { api } from '../api'
import { currentUser, logout } from '../auth'
type Row = Assignment & { latest: Submission | null }
const router = useRouter()
const rows = ref<Row[]>([])
const loaded = ref(false)
onMounted(async () => {
rows.value = await api<Row[]>('GET', '/api/assignments').catch(() => [])
loaded.value = true
})
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
function status(row: Row) {
const s = row.latest
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' }, () => '评分出错')
const full = s.autoScore === total(row)
return h(NTag, { type: full ? 'success' : 'info', size: 'small' }, () => `已提交 · 自动评分 ${s.autoScore} / ${total(row)}`)
}
const columns: DataTableColumns<Row> = [
{ title: '作业', key: 'title', render: (r) => h(RouterLink, { to: `/assignments/${r.id}`, class: 'link' }, () => r.title) },
{ title: '起点', key: 'startMode', width: 120, render: (r) => (r.startMode === 'ui' ? '界面已做好' : '空白工程') },
{ title: '截止时间', key: 'deadline', width: 140, render: (r) => (r.deadline ? formatTime(r.deadline) : '不限') },
{ title: '状态', key: 'status', width: 220, render: status },
]
async function signOut() {
await logout()
await router.push('/login')
}
</script>
<template>
<div class="page">
<header class="toolbar">
<strong>物联网编程仿真平台</strong>
<span class="title">我的作业</span>
<RouterLink to="/" class="nav-link">自由练习</RouterLink>
<span class="spacer" />
<span class="user">
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
</span>
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<main class="content">
<NDataTable v-if="rows.length" :columns="columns" :data="rows" :row-key="(r: Row) => r.id" :bordered="false" />
<NEmpty v-else-if="loaded" description="老师还没有布置作业" class="empty" />
</main>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid #e5e7eb;
}
.title {
color: #374151;
}
.spacer {
flex: 1;
}
.nav-link,
:deep(.link) {
color: #2563eb;
font-size: 14px;
text-decoration: none;
}
.user {
font-size: 13px;
}
.content {
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 16px;
box-sizing: border-box;
}
.empty {
margin-top: 80px;
}
</style>
+26 -11
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生。
// 老师的班级管理:建班、导入学生名单(CSV)、查看和重置密码、导出名单发给学生;布置作业、查看提交情况。
import { computed, h, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NDataTable, NEmpty, NInput, NModal, NPopconfirm, NPopover, 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 { currentUser, logout } from '../auth'
import AssignmentsPanel from '../teacher/AssignmentsPanel.vue'
import { decodeCsv, parseCsv, toRoster, type RosterRow } from '../teacher/roster'
interface ClassInfo {
@@ -264,16 +265,24 @@ async function signOut() {
</template>
只能删除没有学生的班级。确定删除"{{ selected.name }}"吗?
</NPopconfirm>
<span class="spacer" />
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
<NTabs type="line" default-value="students">
<NTabPane name="students" :tab="`学生(${students.length})`">
<div class="students-actions">
<NButton size="small" type="primary" @click="fileInput?.click()">导入名单(CSV)</NButton>
<NButton size="small" :disabled="students.length === 0" @click="exportRoster">导出名单(含密码)</NButton>
<NButton size="small" quaternary @click="downloadTemplate">下载名单模板</NButton>
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</NTabPane>
<NTabPane name="assignments" tab="作业">
<AssignmentsPanel :class-id="selected.id" />
</NTabPane>
</NTabs>
</section>
<section v-else class="students">
<NEmpty description="选择或新建一个班级" />
@@ -425,6 +434,12 @@ async function signOut() {
font-size: 18px;
}
.students-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tip {
color: #6b7280;
font-size: 12px;
+145 -22
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
// 两种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag } from 'naive-ui'
import type { SourceFile } from '@iot/shared'
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
import type { Assignment, 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'
@@ -12,10 +13,13 @@ import { api } from '../api'
import { currentUser, logout } from '../auth'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import TrainingBoard from '../scene/TrainingBoard.vue'
import FormWindow from '../winforms/FormWindow.vue'
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
const props = defineProps<{ assignmentId?: string }>()
const session = createRunSession()
const { state } = session
@@ -64,6 +68,9 @@ const modified = ref(false)
const editorRef = ref<InstanceType<typeof CodeEditor>>()
const route = useRoute()
const router = useRouter()
const message = useMessage()
/** 当前工程的作品保存在服务器上的哪个 key */
const workKey = ref<string>()
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
@@ -87,6 +94,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture
onMounted(async () => {
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
void session.connect()
if (props.assignmentId) {
await loadAssignment()
return
}
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
@@ -94,21 +105,79 @@ onMounted(async () => {
if (initial) await loadSample(initial.id)
})
async function loadSample(id: string, discardWork = false) {
/** 打开一个工程:服务器上有保存的作品就用作品,没有就用原始代码;discardWork 表示丢弃作品恢复原始代码 */
async function openProject(key: string, original: () => Promise<SourceFile[]>, discardWork: boolean) {
await flushSave()
sampleId.value = id
void router.replace({ query: { sample: id } })
workKey.value = key
designerHistory.clear()
const key = sampleWorkKey(id)
if (discardWork) await deleteWork(key).catch(() => {})
const work = discardWork ? null : await loadWork(key).catch(() => null)
modified.value = work !== null
saveState.value = work ? 'saved' : 'idle'
files.value = work ?? (await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`))
files.value = work ?? (await original())
// 和 VS 一样,默认打开主窗体的设计器
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.cs')?.path ?? files.value[0]?.path
}
async function loadSample(id: string, discardWork = false) {
sampleId.value = id
void router.replace({ query: { sample: id } })
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
}
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | 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)
let pollTimer: ReturnType<typeof setTimeout> | undefined
async function loadAssignment(discardWork = false) {
const detail = await api<{ assignment: Assignment; template: SourceFile[]; latest: Submission | null }>(
'GET',
`/api/assignments/${props.assignmentId}`,
).catch((e: Error) => {
message.error(e.message)
return null
})
if (!detail) return
assignment.value = detail.assignment
submission.value = detail.latest
if (detail.latest?.status === 'pending') pollSubmission()
await openProject(`assignment:${props.assignmentId}`, async () => detail.template.map((f) => ({ ...f })), discardWork)
}
async function submit() {
if (!assignment.value || submitting.value) return
submitting.value = true
try {
await flushSave()
submission.value = await api<Submission>('POST', `/api/assignments/${assignment.value.id}/submit`, { files: files.value })
resultOpen.value = true
pollSubmission()
} catch (e) {
message.error((e as Error).message)
} finally {
submitting.value = false
}
}
/** 评分中时每 2 秒查一次结果 */
function pollSubmission() {
clearTimeout(pollTimer)
pollTimer = setTimeout(async () => {
const latest = await api<Submission | null>('GET', `/api/assignments/${props.assignmentId}/submission`).catch(() => submission.value)
submission.value = latest
if (latest?.status === 'pending') pollSubmission()
}, 2000)
}
onBeforeUnmount(() => clearTimeout(pollTimer))
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
// ---------- 自动保存到服务器 ----------
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
const saveState = ref<'idle' | 'pending' | 'saving' | 'saved' | 'error'>('idle')
@@ -124,11 +193,11 @@ function scheduleSave() {
/** 立即保存还没保存的改动(切换示例、离开页面之前) */
async function flushSave(keepalive = false) {
clearTimeout(saveTimer)
if (saveState.value !== 'pending' || !sampleId.value) return
if (saveState.value !== 'pending' || !workKey.value) return
saveState.value = 'saving'
const snapshot = files.value.map((f) => ({ ...f }))
try {
await saveWork(sampleWorkKey(sampleId.value), snapshot, keepalive)
await saveWork(workKey.value, snapshot, keepalive)
// 保存期间又有改动时保持 pending,等下一次保存
if (saveState.value === 'saving') saveState.value = 'saved'
} catch {
@@ -236,7 +305,8 @@ async function showDiagnostic(file: string, line: number, column: number) {
}
function restoreOriginal() {
if (sampleId.value) void loadSample(sampleId.value, true)
if (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
}
function run() {
@@ -248,28 +318,53 @@ function run() {
<template>
<div class="page">
<header class="toolbar">
<strong>物联网编程仿真平台</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
<template v-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 }">
{{ pastDeadline ? '已截止' : '截止' }} {{ formatTime(assignment.deadline) }}
</span>
</template>
<template v-else>
<strong>物联网编程仿真平台</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
</template>
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
<template v-if="assignment">
<NButton
type="warning"
:loading="submitting"
:disabled="submission?.status === 'pending' || (pastDeadline && currentUser?.role === 'student')"
title="提交后自动评分,截止前可以改了再交"
@click="submit"
>
提交
</NButton>
<NButton v-if="submission" size="small" quaternary @click="resultOpen = true">
{{ submission.status === 'pending' ? '评分中…' : submission.status === 'done' ? `得分 ${submission.autoScore} / ${autoTotal}` : '评分出错' }}
</NButton>
</template>
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">恢复原始代码</NButton>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
</template>
你对这个示例的修改将全部丢弃,确定恢复吗?
{{ assignmentId ? '你在这个作业上写的代码将全部丢弃(已提交的不受影响),确定恢复吗?' : '你对这个示例的修改将全部丢弃,确定恢复吗?' }}
</NPopconfirm>
<span class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span class="metrics">
<span v-if="!assignmentId" class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
</span>
<span v-else class="spacer" />
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
<span class="user">
{{ currentUser?.name }}<template v-if="currentUser?.className">({{ currentUser.className }})</template>
</span>
@@ -277,6 +372,16 @@ function run() {
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
preset="card"
:title="`评分结果:${assignment.title}`"
style="width: 560px; max-width: calc(100vw - 32px)"
>
<GradeResultView :assignment="assignment" :submission="submission" />
</NModal>
<main class="workspace" :class="{ designing: activeForm }">
<section class="editor">
<div class="tab-row">
@@ -388,6 +493,24 @@ function run() {
font-size: 12px;
}
.spacer {
margin-left: auto;
}
.assignment-title {
white-space: nowrap;
}
.deadline {
color: #6b7280;
font-size: 12px;
white-space: nowrap;
}
.deadline.past {
color: #dc2626;
}
.save-state {
color: #6b7280;
font-size: 12px;
+3 -1
View File
@@ -1,4 +1,4 @@
// 页面路由:登录页、实验页(学生和老师都用)、老师的班级管理页。
// 页面路由:登录页、实验页(自由练习和做作业共用)、学生的作业列表、老师的班级管理页。
import { createRouter, createWebHistory } from 'vue-router'
import { apiHooks } from './api'
import { currentUser } from './auth'
@@ -8,6 +8,8 @@ export const router = createRouter({
routes: [
{ path: '/login', component: () => import('./pages/LoginPage.vue'), meta: { public: true } },
{ path: '/', component: () => import('./pages/WorkbenchPage.vue') },
{ 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: '/:rest(.*)*', redirect: '/' },
],
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
// 布置作业 / 修改作业。布置时选实验、起点和截止时间,给每个评分项设分值;
// 起点工程由浏览器用设计器引擎从参考答案生成(界面已做好:去掉事件和代码;空白工程:只有空的 Form1)。
// 修改时只能改名称、截止时间和分值。
import { computed, ref, watch } from 'vue'
import { NButton, NDatePicker, NInput, NInputNumber, NModal, NRadio, NRadioGroup, NSelect, useMessage } from 'naive-ui'
import type { Assignment, Scoring, SourceFile, StartMode } from '@iot/shared'
import { api } from '../api'
import { assignmentTemplate } from '../designer/templates'
const props = defineProps<{ show: boolean; classId: number; editing: Assignment | null }>()
const emit = defineEmits<{ 'update:show': [show: boolean]; saved: [] }>()
const message = useMessage()
const DEFAULT_MANUAL = [
{ title: '窗体界面(布局合理、美观)', points: 10 },
{ title: '注释书写规范', points: 10 },
{ title: '变量与控件命名规范', points: 10 },
]
const samples = ref<{ label: string; value: string }[]>([])
const sampleId = ref<string | null>(null)
const title = ref('')
const startMode = ref<StartMode>('ui')
const deadline = ref<number | null>(null)
const checkTitles = ref<string[]>([])
const auto = ref<number[]>([])
const manual = ref<Scoring['manual']>([])
const saving = ref(false)
const total = computed(() => [...auto.value, ...manual.value.map((m) => m.points)].reduce((a, b) => a + (b || 0), 0))
watch(
() => props.show,
async (show) => {
if (!show) return
const e = props.editing
if (e) {
sampleId.value = e.sampleId
title.value = e.title
startMode.value = e.startMode
deadline.value = e.deadline
checkTitles.value = e.checkTitles
auto.value = [...e.scoring.auto]
manual.value = e.scoring.manual.map((m) => ({ ...m }))
return
}
sampleId.value = null
title.value = ''
startMode.value = 'ui'
deadline.value = null
checkTitles.value = []
auto.value = []
manual.value = DEFAULT_MANUAL.map((m) => ({ ...m }))
if (samples.value.length === 0) {
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
}
},
)
async function chooseSample(id: string) {
sampleId.value = id
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
try {
checkTitles.value = await api<string[]>('GET', `/api/teacher/samples/${encodeURIComponent(id)}/checks`)
auto.value = checkTitles.value.map(() => 10)
} catch (e) {
checkTitles.value = []
auto.value = []
message.error((e as Error).message)
}
}
async function save() {
if (!sampleId.value || saving.value) return
saving.value = true
const scoring: Scoring = { auto: auto.value.map((p) => p || 0), manual: manual.value.map((m) => ({ title: m.title.trim(), points: m.points || 0 })) }
try {
if (props.editing) {
await api('PATCH', `/api/teacher/assignments/${props.editing.id}`, { title: title.value, deadline: deadline.value, scoring })
} else {
const files = await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(sampleId.value)}`)
await api('POST', '/api/teacher/assignments', {
classId: props.classId,
sampleId: sampleId.value,
title: title.value,
startMode: startMode.value,
template: assignmentTemplate(files, startMode.value),
scoring,
deadline: deadline.value,
})
}
emit('saved')
emit('update:show', false)
} catch (e) {
message.error((e as Error).message)
} finally {
saving.value = false
}
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="editing ? '修改作业' : '布置作业'"
style="width: 620px; max-width: calc(100vw - 32px)"
@update:show="emit('update:show', $event)"
>
<div class="form">
<label>实验</label>
<NSelect :value="sampleId" :options="samples" :disabled="!!editing" placeholder="选择实验" @update:value="chooseSample" />
<label>作业名称</label>
<NInput v-model:value="title" placeholder="学生看到的作业名称" />
<label>学生的起点</label>
<NRadioGroup v-model:value="startMode" :disabled="!!editing">
<NRadio value="ui">界面已做好(只写事件和代码)</NRadio>
<NRadio value="blank">空白工程(从搭界面做起)</NRadio>
</NRadioGroup>
<label>截止时间</label>
<NDatePicker v-model:value="deadline" type="datetime" clearable placeholder="不限" format="yyyy-MM-dd HH:mm" />
<template v-if="checkTitles.length">
<label>自动评分</label>
<div class="points">
<div v-for="(t, i) in checkTitles" :key="i" class="point-row">
<span class="point-title">{{ t }}</span>
<NInputNumber v-model:value="auto[i]" :min="0" size="small" class="point-input" />
<span>分</span>
</div>
</div>
</template>
<label>老师评分</label>
<div class="points">
<div v-for="(m, i) in manual" :key="i" class="point-row">
<NInput v-model:value="m.title" size="small" class="point-title" />
<NInputNumber v-model:value="m.points" :min="0" size="small" class="point-input" />
<span>分</span>
<NButton size="tiny" quaternary @click="manual.splice(i, 1)">删除</NButton>
</div>
<NButton size="small" dashed @click="manual.push({ title: '', points: 10 })">+ 添加老师评分项</NButton>
</div>
</div>
<template #footer>
<div class="footer">
<span class="total">总分 {{ total }} 分</span>
<NButton @click="emit('update:show', false)">取消</NButton>
<NButton type="primary" :loading="saving" :disabled="!sampleId || !title.trim()" @click="save">
{{ editing ? '保存' : '布置' }}
</NButton>
</div>
</template>
</NModal>
</template>
<style scoped>
.form {
display: grid;
grid-template-columns: 80px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.form label {
color: #4b5563;
font-size: 13px;
align-self: start;
padding-top: 6px;
}
.points {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.point-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.point-title {
flex: 1;
font-size: 13px;
}
.point-input {
width: 90px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.total {
margin-right: auto;
color: #4b5563;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
// 一个班的作业列表:布置、修改、删除作业,查看提交情况。
import { h, ref, watch } from 'vue'
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 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) => {
message.error(e.message)
return []
})
}
watch(() => props.classId, load, { immediate: true })
function create() {
editing.value = null
editorOpen.value = true
}
function edit(row: Row) {
editing.value = row
editorOpen.value = true
}
function remove(row: Row) {
dialog.warning({
title: '删除作业',
content: `将删除"${row.title}"和全班的 ${row.submittedCount} 份提交,不能恢复。确定删除吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await api('DELETE', `/api/teacher/assignments/${row.id}`).catch((e: Error) => message.error(e.message))
await load()
},
})
}
function showSubmissions(row: Row) {
viewing.value = row
submissionsOpen.value = true
}
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const columns: DataTableColumns<Row> = [
{ title: '作业', key: 'title' },
{ title: '起点', key: 'startMode', width: 110, render: (r) => (r.startMode === 'ui' ? '界面已做好' : '空白工程') },
{ title: '截止时间', key: 'deadline', width: 130, render: (r) => (r.deadline ? formatTime(r.deadline) : '不限') },
{
title: '已提交',
key: 'submitted',
width: 120,
render: (r) => h(NButton, { text: true, type: 'primary', onClick: () => showSubmissions(r) }, () => `${r.submittedCount} / ${r.studentCount} 人`),
},
{
title: '操作',
key: 'actions',
width: 130,
render: (r) =>
h('div', { class: 'row-actions' }, [
h(NButton, { size: 'tiny', onClick: () => edit(r) }, () => '修改'),
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => remove(r) }, () => '删除'),
]),
},
]
</script>
<template>
<div class="panel">
<div class="head">
<NButton size="small" type="primary" @click="create">布置作业</NButton>
</div>
<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>
<style scoped>
.head {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.empty {
margin-top: 40px;
}
:deep(.row-actions) {
display: flex;
gap: 6px;
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<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>