任务说明:实验页"任务"面板、布置作业时可改写;先写项目 3 任务 2、项目 6 任务 2 两份

- 每个实验一份 task.md(任务目标、控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试)
- 工具栏"评价项"改成"任务":上面是任务说明,下面是评价项和自己检查;不遮挡页面,第一次打开实验时自动打开
- 自己写的 Markdown 子集渲染,先转义全部 HTML(老师可以改写说明)
- 作业保存任务说明(新列 task),以前布置的作业用实验现在的说明
- 测试:任务说明表格里的控件名都在示例的 Designer.cs 里
- 修改作业时实验下拉框显示标题而不是编号

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 10:04:05 +08:00
1 parent 3b9f635c35
commit 90b739a2c7
17 files changed
+759 -219

No files matched your search

-179
View File
@@ -1,179 +0,0 @@
<script setup lang="ts">
// 评价项面板:列出这个实验(或作业)自动检查的功能项;"自己检查"在服务器上运行学生现在的程序逐项检查,
// 告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { SelfCheck, SourceFile } from '@iot/shared'
import { api } from '../api'
const props = defineProps<{
show: boolean
/** 检查项名称 */
titles: string[]
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
target: string
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
url: string
/** 学生现在的代码 */
files: SourceFile[]
/** 开始检查前调用(先把没保存的改动存好) */
beforeStart?: () => Promise<void>
/** 作业的各项分值(自由练习没有) */
points?: number[]
}>()
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
const state = ref<SelfCheck | null>(null)
const error = ref('')
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
const checkedFiles = ref<string>()
const current = computed(() => (state.value?.target === props.target ? state.value : null))
const running = computed(() => current.value?.status === 'running')
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
clearTimeout(timer)
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
}
onBeforeUnmount(() => clearTimeout(timer))
watch(
() => props.show,
(show) => {
if (show) void refresh()
},
{ immediate: true },
)
async function start() {
error.value = ''
try {
await props.beforeStart?.()
checkedFiles.value = JSON.stringify(props.files)
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
timer = setTimeout(refresh, 2000)
} catch (e) {
error.value = (e as Error).message
}
}
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
</script>
<template>
<NDrawer :show="show" :width="420" placement="right" @update:show="emit('update:show', $event)">
<NDrawerContent title="评价项" closable>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
<span v-else-if="current?.status === 'done' && current.result" class="status">
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
</span>
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
</div>
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
{{ current.result.results[i].message }}
</div>
</div>
</li>
</ul>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.note {
margin: 0 0 12px;
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #374151;
}
.error {
color: #b91c1c;
font-size: 13px;
}
.outdated {
margin: 4px 0;
color: #a16207;
font-size: 13px;
}
.items {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.items li {
display: flex;
gap: 8px;
}
.mark {
width: 1em;
flex: none;
font-weight: bold;
color: #9ca3af;
}
.passed .mark {
color: #16a34a;
}
.failed .mark {
color: #dc2626;
}
.points {
margin-left: 8px;
color: #6b7280;
}
.message {
margin-top: 2px;
color: #b91c1c;
}
</style>
+279
View File
@@ -0,0 +1,279 @@
<script setup lang="ts">
// 任务面板:上面是任务说明(task.md,作业则是布置时的说明),下面是评价项——这个实验(或作业)自动检查的功能项;
// "自己检查"在服务器上运行学生现在的程序逐项检查,告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
// 面板不遮挡页面,打开着也能接着搭界面、写代码。
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { SelfCheck, SourceFile } from '@iot/shared'
import { api } from '../api'
import { renderMarkdown } from './markdown'
const props = defineProps<{
show: boolean
/** 任务说明(Markdown),没有时为 null */
task: string | null
/** 检查项名称(没有评分规则时为空) */
titles: string[]
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
target: string
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
url: string
/** 学生现在的代码 */
files: SourceFile[]
/** 开始检查前调用(先把没保存的改动存好) */
beforeStart?: () => Promise<void>
/** 作业的各项分值(自由练习没有) */
points?: number[]
}>()
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
const state = ref<SelfCheck | null>(null)
const error = ref('')
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
const checkedFiles = ref<string>()
const current = computed(() => (state.value?.target === props.target ? state.value : null))
const running = computed(() => current.value?.status === 'running')
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
const taskHtml = computed(() => (props.task ? renderMarkdown(props.task) : ''))
let timer: ReturnType<typeof setTimeout> | undefined
async function refresh() {
clearTimeout(timer)
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
}
onBeforeUnmount(() => clearTimeout(timer))
watch(
() => props.show,
(show) => {
if (show && props.titles.length) void refresh()
},
{ immediate: true },
)
async function start() {
error.value = ''
try {
await props.beforeStart?.()
checkedFiles.value = JSON.stringify(props.files)
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
timer = setTimeout(refresh, 2000)
} catch (e) {
error.value = (e as Error).message
}
}
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
</script>
<template>
<NDrawer
:show="show"
:default-width="460"
resizable
placement="right"
:show-mask="false"
:trap-focus="false"
:block-scroll="false"
@update:show="emit('update:show', $event)"
>
<NDrawerContent title="任务" closable>
<!-- renderMarkdown 先转义全部 HTML,只输出它自己生成的标签 -->
<div v-if="taskHtml" class="task" v-html="taskHtml" />
<template v-if="titles.length">
<h4 class="section">评价项</h4>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
<span v-else-if="current?.status === 'done' && current.result" class="status">
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
</span>
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
</div>
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
{{ current.result.results[i].message }}
</div>
</div>
</li>
</ul>
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
.task {
font-size: 14px;
line-height: 1.7;
color: #1f2937;
}
.task :deep(h4),
.section {
margin: 16px 0 6px;
font-size: 15px;
}
.task :deep(h4:first-child) {
margin-top: 0;
}
.task :deep(h5) {
margin: 12px 0 4px;
font-size: 14px;
}
.task :deep(p),
.task :deep(ul),
.task :deep(ol) {
margin: 4px 0;
}
.task :deep(ul),
.task :deep(ol) {
padding-left: 20px;
}
.task :deep(code) {
padding: 0 3px;
border-radius: 3px;
background: #f3f4f6;
font-size: 13px;
}
.task :deep(table) {
width: 100%;
margin: 6px 0;
border-collapse: collapse;
font-size: 13px;
}
.task :deep(th),
.task :deep(td) {
padding: 3px 6px;
border: 1px solid #e5e7eb;
text-align: left;
}
.task :deep(th) {
background: #f9fafb;
}
/* 控件类型和控件名不折行,说明列让出宽度 */
.task :deep(th),
.task :deep(td:first-child),
.task :deep(td code) {
white-space: nowrap;
}
.task :deep(blockquote) {
margin: 8px 0;
padding: 6px 10px;
border-left: 3px solid #f59e0b;
background: #fffbeb;
font-size: 13px;
}
.section {
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.task + .section {
margin-top: 20px;
}
.note {
margin: 0 0 12px;
font-size: 13px;
color: #4b5563;
line-height: 1.6;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #374151;
}
.error {
color: #b91c1c;
font-size: 13px;
}
.outdated {
margin: 4px 0;
color: #a16207;
font-size: 13px;
}
.items {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
}
.items li {
display: flex;
gap: 8px;
}
.mark {
width: 1em;
flex: none;
font-weight: bold;
color: #9ca3af;
}
.passed .mark {
color: #16a34a;
}
.failed .mark {
color: #dc2626;
}
.points {
margin-left: 8px;
color: #6b7280;
}
.message {
margin-top: 2px;
color: #b91c1c;
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'
import { renderMarkdown } from './markdown'
describe('任务说明的 Markdown', () => {
test('标题、段落(段内换行保留)、加粗、代码', () => {
expect(renderMarkdown('## 任务目标\n\n勾选**人体开关**后,\n每 5 秒检查一次 `timer1`')).toBe(
'<h4>任务目标</h4><p>勾选<b>人体开关</b>后,<br>每 5 秒检查一次 <code>timer1</code></p>',
)
})
test('无序、有序列表,缩进的续行接在上一项', () => {
expect(renderMarkdown('- 甲\n- 乙\n 续行\n\n1. 一\n2. 二')).toBe('<ul><li>甲</li><li>乙<br>续行</li></ul><ol><li>一</li><li>二</li></ol>')
expect(renderMarkdown('3. 接着第 3 步')).toBe('<ol start="3"><li>接着第 3 步</li></ol>')
expect(renderMarkdown('1. 勾选的 Click:\n - 勾上时:启动\n - 取消时:停止\n 最后刷新\n2. 下一步')).toBe(
'<ol><li>勾选的 Click:<ul><li>勾上时:启动</li><li>取消时:停止</li></ul><br>最后刷新</li><li>下一步</li></ol>',
)
expect(renderMarkdown('- 甲\n - 甲1\n- 乙')).toBe('<ul><li>甲<ul><li>甲1</li></ul></li><li>乙</li></ul>')
})
test('表格:列数以表头为准', () => {
expect(renderMarkdown('| 控件 | 名称 |\n|---|---|\n| 按钮 | `btn_open` |\n| 复选框 |')).toBe(
'<table><thead><tr><th>控件</th><th>名称</th></tr></thead><tbody><tr><td>按钮</td><td><code>btn_open</code></td></tr><tr><td>复选框</td><td></td></tr></tbody></table>',
)
})
test('引用变成提示框', () => {
expect(renderMarkdown('> 书上写成了 `True`\n> 要改成小写')).toBe('<blockquote>书上写成了 <code>True</code><br>要改成小写</blockquote>')
})
test('HTML 一律转义,不会插入标签或脚本', () => {
const html = renderMarkdown('<script>alert(1)</script>\n- <img src=x onerror=alert(1)>\n| <b> |\n|---|\n| `<i>` |')
expect(html).not.toContain('<script')
expect(html).not.toContain('<img')
expect(html).toContain('&lt;script&gt;')
expect(html).toContain('<code>&lt;i&gt;</code>')
})
})
+119
View File
@@ -0,0 +1,119 @@
// 任务说明用的 Markdown 子集,转成 HTML:标题(# ## ###)、段落、无序 / 有序列表(缩进的 - 是下一级)、表格、引用(> 提示框)、**加粗**、`代码`。
// 老师可以改写任务说明,所以先把文字里的 HTML 全部转义,只输出这里生成的标签,不会被插入脚本。
const escapeHtml = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 行内:`代码` 里的内容原样显示,其余部分处理 **加粗** */
function inline(text: string): string {
return text
.split(/(`[^`]+`)/)
.map((part) =>
part.length > 2 && part.startsWith('`') && part.endsWith('`')
? `<code>${escapeHtml(part.slice(1, -1))}</code>`
: escapeHtml(part).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'),
)
.join('')
}
const cells = (line: string) =>
line
.trim()
.replace(/^\||\|$/g, '')
.split('|')
.map((cell) => cell.trim())
const isTableSeparator = (line: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line)
/** 列表项的内容:续行里以 - 开头的是下一级列表,其余续行换行接在后面 */
function listItemHtml(text: string): string {
const [head, ...rest] = text.split('\n')
let html = inline(head)
let sub: string[] = []
const flush = () => {
if (sub.length) html += `<ul>${sub.map((t) => `<li>${inline(t)}</li>`).join('')}</ul>`
sub = []
}
for (const line of rest) {
const bullet = /^[-*]\s+(.*)$/.exec(line)
if (bullet) sub.push(bullet[1])
else {
flush()
html += `<br>${inline(line)}`
}
}
flush()
return html
}
export function renderMarkdown(source: string): string {
const lines = source.replace(/\r\n?/g, '\n').split('\n')
const out: string[] = []
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) {
i++
continue
}
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
if (heading) {
// 面板里地方小:# 和 ## 都是小标题
const level = heading[1].length === 3 ? 'h5' : 'h4'
out.push(`<${level}>${inline(heading[2])}</${level}>`)
i++
continue
}
if (line.trim().startsWith('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
const head = cells(line)
i += 2
const rows: string[][] = []
while (i < lines.length && lines[i].trim().startsWith('|')) rows.push(cells(lines[i++]))
out.push(
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join('')}</tr></thead>` +
`<tbody>${rows.map((r) => `<tr>${head.map((_, k) => `<td>${inline(r[k] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`,
)
continue
}
const listItem = /^\s*(?:([-*])|(\d+)[.、])\s+(.*)$/
const first = listItem.exec(line)
if (first) {
const ordered = !first[1]
const items: string[] = []
while (i < lines.length) {
const item = listItem.exec(lines[i])
if (items.length && /^\s{2,}\S/.test(lines[i])) {
// 缩进的行(续行或下一级列表)接在上一项后面
items[items.length - 1] += `\n${lines[i].trim()}`
i++
} else if (item && !item[1] === ordered) {
items.push(item[3])
i++
} else break
}
const tag = ordered ? 'ol' : 'ul'
const start = ordered && first[2] !== '1' ? ` start="${Number(first[2])}"` : ''
out.push(`<${tag}${start}>${items.map((t) => `<li>${listItemHtml(t)}</li>`).join('')}</${tag}>`)
continue
}
if (line.startsWith('>')) {
const quoted: string[] = []
while (i < lines.length && lines[i].startsWith('>')) quoted.push(lines[i++].replace(/^>\s?/, ''))
out.push(`<blockquote>${quoted.map(inline).join('<br>')}</blockquote>`)
continue
}
// 段落:到空行、标题、列表、表格、引用为止,段内换行保留
const paragraph: string[] = []
while (
i < lines.length &&
lines[i].trim() &&
!/^#{1,3}\s/.test(lines[i]) &&
!listItem.test(lines[i]) &&
!lines[i].startsWith('>') &&
!(lines[i].trim().startsWith('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
) {
paragraph.push(lines[i++])
}
out.push(`<p>${paragraph.map(inline).join('<br>')}</p>`)
}
return out.join('')
}
+3 -1
View File
@@ -32,6 +32,7 @@ import { currentUser } from '../auth'
<ul>
<li>登录后进入<b>自由练习</b>:左上角的下拉框选教材上的任务,可以随便改、随便运行。</li>
<li>点右上角的<b>我的作业</b>,看老师布置的作业、截止时间和得分;点作业名开始做。</li>
<li>工具栏的<b>任务</b>:任务目标、控件名、实现步骤和在平台上怎么试,第一次打开一个实验时自动弹出。面板不挡住设计器和代码,可以开着做。</li>
<li>
作业的起点有两种:<b>界面已做好</b>(只写事件和代码)、<b>空白工程</b>(从搭界面做起)。控件名照书上的写,自动评分按书上的控件名操作你的程序。
</li>
@@ -65,7 +66,7 @@ import { currentUser } from '../auth'
<h3>6. 自己检查、提交</h3>
<ul>
<li>点<b>评价项</b>看这个任务要检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
<li><b>任务</b>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
<li>
做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
</li>
@@ -97,6 +98,7 @@ import { currentUser } from '../auth'
<ul>
<li><b>作业</b>标签页 → <b>布置作业</b>:选实验、起点(界面已做好 / 空白工程)、截止时间(可以不限)。</li>
<li>自动评分项默认每项 10 分;老师评分项(界面、注释、命名)由你在评阅时打分,可以增删、改分值。</li>
<li><b>任务说明</b>预填实验的说明,可以改写,比如在最前面加一段"这次只做前 3 步";布置后也能在<b>修改</b>里改。</li>
</ul>
<h3>3. 评阅、成绩</h3>
+44 -17
View File
@@ -2,7 +2,7 @@
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } 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, Review, SourceFile, Submission } from '@iot/shared'
@@ -18,7 +18,7 @@ import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import SelfCheckPanel from '../grading/SelfCheckPanel.vue'
import TaskPanel from '../grading/TaskPanel.vue'
import { captureForms } from '../grading/screenshots'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
@@ -136,27 +136,36 @@ async function loadSample(id: string, discardWork = false) {
sampleId.value = id
void router.replace({ query: { sample: id } })
sampleChecks.value = []
sampleTask.value = null
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
.then((titles) => {
if (sampleId.value === id) sampleChecks.value = titles
})
.catch(() => {})
void api<{ task: string | null }>('GET', `/api/samples/${encodeURIComponent(id)}/task`)
.then(({ task }) => {
if (sampleId.value === id) sampleTask.value = task
})
.catch(() => {})
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
}
// ---------- 评价项和自测 ----------
// ---------- 任务说明、评价项和自测 ----------
const sampleChecks = ref<string[]>([])
const selfCheckOpen = ref(false)
/** 作业用布置时的评分规则,自由练习用实验的评分规则 */
const selfCheck = computed(() => {
const sampleTask = ref<string | null>(null)
const taskOpen = ref(false)
/** 作业用布置时的任务说明和评分规则,自由练习用实验的 */
const taskPanel = computed(() => {
if (props.assignmentId) {
const a = assignment.value
return a ? { titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
return a ? { task: a.task, titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
}
const id = sampleId.value
return id && sampleChecks.value.length ? { titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` } : null
if (!id || (!sampleTask.value && !sampleChecks.value.length)) return null
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
})
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null)
@@ -197,6 +206,23 @@ async function loadAssignment(discardWork = false) {
// ---------- 老师运行学生提交的程序 ----------
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
// 第一次打开一个实验(或作业)时自动打开任务面板;记在浏览器里,之后不再自动打开
const TASK_SEEN_KEY = 'iot-sim:task-seen'
watch(
() => (taskPanel.value?.task && !viewing.value ? taskPanel.value.target : null),
(target: string | null) => {
if (!target) return
try {
const seen: string[] = JSON.parse(localStorage.getItem(TASK_SEEN_KEY) ?? '[]')
if (seen.includes(target)) return
localStorage.setItem(TASK_SEEN_KEY, JSON.stringify([...seen, target].slice(-200)))
} catch {
return
}
taskOpen.value = true
},
)
async function loadSubmission() {
try {
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
@@ -496,8 +522,8 @@ function run() {
{{ scoreText }}
</NButton>
</template>
<NButton v-if="selfCheck && !viewing" size="small" title="这个任务自动检查哪些功能;可以自己先检查一遍(不计分)" @click="selfCheckOpen = true">
评价项
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
@@ -532,13 +558,14 @@ function run() {
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
</NModal>
<SelfCheckPanel
v-if="selfCheck"
v-model:show="selfCheckOpen"
:titles="selfCheck.titles"
:target="selfCheck.target"
:url="selfCheck.url"
:points="selfCheck.points"
<TaskPanel
v-if="taskPanel"
v-model:show="taskOpen"
:task="taskPanel.task"
:titles="taskPanel.titles"
:target="taskPanel.target"
:url="taskPanel.url"
:points="taskPanel.points"
:files="files"
:before-start="flushSave"
/>
+48 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
// 布置作业 / 修改作业。布置时选实验、起点和截止时间,给每个评分项设分值;
// 起点工程由浏览器用设计器引擎从参考答案生成(界面已做好:去掉事件和代码;空白工程:只有空的 Form1)。
// 修改时只能改名称、截止时间和分值。
// 任务说明从实验的 task.md 预填,老师可以改写(比如在前面加一段要求);修改时只能改名称、截止时间、分值和任务说明。
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'
@@ -26,14 +26,25 @@ const deadline = ref<number | null>(null)
const checkTitles = ref<string[]>([])
const auto = ref<number[]>([])
const manual = ref<Scoring['manual']>([])
const task = ref('')
/** 实验原来的任务说明,"恢复成实验的说明"用 */
const sampleTask = ref('')
const saving = ref(false)
async function loadSampleTask(id: string) {
sampleTask.value = (await api<{ task: string | null }>('GET', `/api/samples/${encodeURIComponent(id)}/task`).catch(() => ({ task: null }))).task ?? ''
}
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
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 }))
}
const e = props.editing
if (e) {
sampleId.value = e.sampleId
@@ -43,6 +54,10 @@ watch(
checkTitles.value = e.checkTitles
auto.value = [...e.scoring.auto]
manual.value = e.scoring.manual.map((m) => ({ ...m }))
task.value = e.task ?? ''
await loadSampleTask(e.sampleId)
// 加这个功能之前布置的作业没有保存说明,学生看到的是实验的说明
if (e.task === null) task.value = sampleTask.value
return
}
sampleId.value = null
@@ -52,16 +67,17 @@ watch(
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 }))
}
task.value = ''
sampleTask.value = ''
},
)
async function chooseSample(id: string) {
sampleId.value = id
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
void loadSampleTask(id).then(() => {
if (sampleId.value === id) task.value = sampleTask.value
})
try {
checkTitles.value = await api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
auto.value = checkTitles.value.map(() => 10)
@@ -78,7 +94,7 @@ async function save() {
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 })
await api('PATCH', `/api/teacher/assignments/${props.editing.id}`, { title: title.value, deadline: deadline.value, scoring, task: task.value })
} else {
const files = await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(sampleId.value)}`)
await api('POST', '/api/teacher/assignments', {
@@ -89,6 +105,7 @@ async function save() {
template: assignmentTemplate(files, startMode.value),
scoring,
deadline: deadline.value,
task: task.value,
})
}
emit('saved')
@@ -136,6 +153,15 @@ async function save() {
</div>
</template>
<label>任务说明</label>
<div class="task">
<NInput v-model:value="task" type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" placeholder="这个实验还没有任务说明" />
<div class="hint">
学生在实验页的"任务"面板里看到。可以在最前面加一段要求;格式:<code>## 标题</code>、<code>- 列表</code>、<code>**加粗**</code>
<NButton v-if="sampleTask && task !== sampleTask" size="tiny" quaternary @click="task = sampleTask">恢复成实验的说明</NButton>
</div>
</div>
<label>老师评分</label>
<div class="points">
<div v-for="(m, i) in manual" :key="i" class="point-row">
@@ -174,6 +200,22 @@ async function save() {
padding-top: 6px;
}
.task {
display: flex;
flex-direction: column;
gap: 4px;
}
.hint {
color: #6b7280;
font-size: 12px;
}
.hint code {
padding: 0 3px;
background: #f3f4f6;
}
.points {
display: flex;
flex-direction: column;