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

- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项);
  起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照
- 提交:后台排队评分(全站同时最多评 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

+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>