实验下拉菜单按项目分组;路线图更新到已上线、待一个班试用
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 21:39:40 +08:00
1 parent 4ce800c77f
commit 61a32e900d
5 files changed
+68 -13

No files matched your search

+5 -3
View File
@@ -235,9 +235,9 @@ IOT/
```mermaid
flowchart TB
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(进行中,约 2 个月)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔;一个班试用"]
P3["阶段 3 · 扩展(进行中)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔"]
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
@@ -247,6 +247,8 @@ flowchart TB
并行事项:尽早办理 ICP 备案(需数周)。
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
| 指标 | 结果 |
+2 -1
View File
@@ -10,6 +10,7 @@ import EditTab from '../database/EditTab.vue'
import ObjectExplorer, { type ExplorerAction } from '../database/ObjectExplorer.vue'
import QueryTab from '../database/QueryTab.vue'
import { execute, loadExplorer, quoteName, type ExplorerDatabase, type ExplorerTable } from '../database/sql'
import { renderSampleTag, sampleOptions } from '../samples'
type Tab =
| { key: string; kind: 'query'; title: string; database: string; sql?: string; run?: boolean }
@@ -217,7 +218,7 @@ onMounted(refresh)
</NModal>
<NModal :show="restoring !== null" preset="card" title="恢复实验初始数据" style="width: 460px" @update:show="restoring = null">
<NSelect v-model:value="restoring" :options="experiments.map((e) => ({ label: e.title, value: e.id }))" />
<NSelect v-model:value="restoring" :options="sampleOptions(experiments)" :render-tag="renderSampleTag" />
<p class="warning">会用实验的初始数据替换实验用到的数据库(如"环境数据表"),你在这些库里做的改动会丢失;其他数据库不受影响。</p>
<template #footer>
<div class="dialog-buttons">
+13 -4
View File
@@ -4,7 +4,7 @@
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
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 { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage, type SelectGroupOption } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
@@ -30,13 +30,14 @@ import { createRunSession } from '../run/session'
import GradeResultView from '../grading/GradeResultView.vue'
import TaskPanel from '../grading/TaskPanel.vue'
import { captureForms } from '../grading/screenshots'
import { renderSampleTag, sampleOptions } from '../samples'
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
const session = createRunSession()
const { state } = session
const samples = ref<{ label: string; value: string }[]>([])
const samples = ref<SelectGroupOption[]>([])
const sampleId = ref<string>()
const files = ref<SourceFile[]>([])
/** 当前标签页:文件路径,或 "设计:X.Designer.cs" 表示窗体 X 的设计器 */
@@ -126,7 +127,7 @@ onMounted(async () => {
return
}
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
samples.value = sampleOptions(list)
// 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生)
const initial = list.find((s) => s.id === route.query.sample) ?? list[0]
if (initial) await loadSample(initial.id)
@@ -522,7 +523,15 @@ function run() {
</template>
<template v-else>
<strong>物联网编程仿真平台</strong>
<NSelect class="sample-select" :value="sampleId" :options="samples" placeholder="选择示例工程" @update:value="loadSample" />
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
:render-tag="renderSampleTag"
:consistent-menu-width="false"
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>
+34
View File
@@ -0,0 +1,34 @@
// 实验下拉菜单:按教材的项目分组,组名是项目名,组里每项只写"任务N …";选中后框里显示完整标题
import { h } from 'vue'
import type { SelectGroupOption, SelectOption, SelectRenderTag } from 'naive-ui'
/** 教材 8 个项目的名称(去掉"智慧校园系统——"前缀) */
const PROJECTS: Record<string, string> = {
1: 'C#入门基础',
2: '环境监测模块',
3: '智慧路灯控制模块',
4: '入侵信息监测模块',
5: '灾情信息监测模块',
6: '数据存储模块',
7: '情景控制模块',
8: '智慧校园管理平台',
}
/** 实验标题形如"项目3 任务2 实现人体红外传感器和路灯联动",按项目分组;不是这种格式的放进"其他" */
export function sampleOptions(list: { id: string; title: string }[]): SelectGroupOption[] {
const groups = new Map<string, SelectGroupOption & { children: SelectOption[] }>()
for (const s of list) {
const m = /^项目(\d+)\s*(.*)$/.exec(s.title)
const key = m ? m[1]! : '其他'
let group = groups.get(key)
if (!group) {
group = { type: 'group', key, label: m ? `项目${key} ${PROJECTS[key] ?? ''}`.trim() : '其他', children: [] }
groups.set(key, group)
}
group.children.push({ label: m ? m[2] : s.title, value: s.id, title: s.title })
}
return [...groups.values()]
}
/** 选择框里显示完整标题(带项目号),下拉列表里仍是简短的"任务N …" */
export const renderSampleTag: SelectRenderTag = ({ option }) => h('span', { title: option.title as string }, option.title as string)
+14 -5
View File
@@ -7,6 +7,7 @@ import { NButton, NDatePicker, NInput, NInputNumber, NModal, NRadio, NRadioGroup
import type { Assignment, Scoring, SourceFile, StartMode } from '@iot/shared'
import { api } from '../api'
import { assignmentTemplate } from '../designer/templates'
import { renderSampleTag, sampleOptions } from '../samples'
const props = defineProps<{ show: boolean; classId: number; editing: Assignment | null }>()
const emit = defineEmits<{ 'update:show': [show: boolean]; saved: [] }>()
@@ -18,7 +19,8 @@ const DEFAULT_MANUAL = [
{ title: '变量与控件命名规范', points: 10 },
]
const samples = ref<{ label: string; value: string }[]>([])
const samples = ref<{ id: string; title: string }[]>([])
const sampleGroups = computed(() => sampleOptions(samples.value))
const sampleId = ref<string | null>(null)
const title = ref('')
const startMode = ref<StartMode>('ui')
@@ -42,8 +44,7 @@ watch(
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 }))
samples.value = await api<{ id: string; title: string }[]>('GET', '/api/samples')
}
const e = props.editing
if (e) {
@@ -74,7 +75,7 @@ watch(
async function chooseSample(id: string) {
sampleId.value = id
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
title.value = samples.value.find((s) => s.id === id)?.title ?? ''
void loadSampleTask(id).then(() => {
if (sampleId.value === id) task.value = sampleTask.value
})
@@ -128,7 +129,15 @@ async function save() {
>
<div class="form">
<label>实验</label>
<NSelect :value="sampleId" :options="samples" :disabled="!!editing" placeholder="选择实验" @update:value="chooseSample" />
<NSelect
:value="sampleId"
:options="sampleGroups"
:render-tag="renderSampleTag"
:consistent-menu-width="false"
:disabled="!!editing"
placeholder="选择实验"
@update:value="chooseSample"
/>
<label>作业名称</label>
<NInput v-model:value="title" placeholder="学生看到的作业名称" />