任务说明:实验页"任务"面板、布置作业时可改写;先写项目 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:
1 parent
3b9f635c35
commit
90b739a2c7
17 files changed
+759
-219
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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('<script>')
|
||||
expect(html).toContain('<code><i></code>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// 任务说明用的 Markdown 子集,转成 HTML:标题(# ## ###)、段落、无序 / 有序列表(缩进的 - 是下一级)、表格、引用(> 提示框)、**加粗**、`代码`。
|
||||
// 老师可以改写任务说明,所以先把文字里的 HTML 全部转义,只输出这里生成的标签,不会被插入脚本。
|
||||
|
||||
const escapeHtml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
/** 行内:`代码` 里的内容原样显示,其余部分处理 **加粗** */
|
||||
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('')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user