From 90b739a2c75efd5ab827734fe01747583d672fa9 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 26 Oct 2026 10:04:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=AF=B4=E6=98=8E=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E9=A1=B5"=E4=BB=BB=E5=8A=A1"=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E3=80=81=E5=B8=83=E7=BD=AE=E4=BD=9C=E4=B8=9A=E6=97=B6?= =?UTF-8?q?=E5=8F=AF=E6=94=B9=E5=86=99=EF=BC=9B=E5=85=88=E5=86=99=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=203=20=E4=BB=BB=E5=8A=A1=202=E3=80=81=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=206=20=E4=BB=BB=E5=8A=A1=202=20=E4=B8=A4=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每个实验一份 task.md(任务目标、控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试) - 工具栏"评价项"改成"任务":上面是任务说明,下面是评价项和自己检查;不遮挡页面,第一次打开实验时自动打开 - 自己写的 Markdown 子集渲染,先转义全部 HTML(老师可以改写说明) - 作业保存任务说明(新列 task),以前布置的作业用实验现在的说明 - 测试:任务说明表格里的控件名都在示例的 Designer.cs 里 - 修改作业时实验下拉框显示标题而不是编号 Co-Authored-By: Claude Opus 5.5 --- docs/设计方案.md | 2 + samples/p3-t2-人体红外ZigBee联动/task.md | 42 ++++ samples/p6-t2-存储环境数据/task.md | 57 +++++ server/src/app.ts | 31 ++- server/src/assignments.ts | 34 ++- server/src/db.ts | 4 + server/src/samples.ts | 8 + server/test/api.test.ts | 24 +- server/test/samples.test.ts | 38 +++ shared/grading.ts | 5 + web/src/grading/SelfCheckPanel.vue | 179 --------------- web/src/grading/TaskPanel.vue | 279 +++++++++++++++++++++++ web/src/grading/markdown.test.ts | 37 +++ web/src/grading/markdown.ts | 119 ++++++++++ web/src/pages/HelpPage.vue | 4 +- web/src/pages/WorkbenchPage.vue | 61 +++-- web/src/teacher/AssignmentDialog.vue | 54 ++++- 17 files changed, 759 insertions(+), 219 deletions(-) create mode 100644 samples/p3-t2-人体红外ZigBee联动/task.md create mode 100644 samples/p6-t2-存储环境数据/task.md create mode 100644 server/test/samples.test.ts delete mode 100644 web/src/grading/SelfCheckPanel.vue create mode 100644 web/src/grading/TaskPanel.vue create mode 100644 web/src/grading/markdown.test.ts create mode 100644 web/src/grading/markdown.ts diff --git a/docs/设计方案.md b/docs/设计方案.md index 25263e0..07e0d7d 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -328,6 +328,8 @@ flowchart TB - **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。 - **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。 +- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板。 + ## 技术风险与待确认事项 最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。 diff --git a/samples/p3-t2-人体红外ZigBee联动/task.md b/samples/p3-t2-人体红外ZigBee联动/task.md new file mode 100644 index 0000000..fda92cc --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/task.md @@ -0,0 +1,42 @@ +## 任务目标 + +做一个"楼梯灯":勾选**人体开关**后,程序每 5 秒检查一次人体红外传感器,有人就开路灯,没人就关灯;不勾选时用"开""关"两个按钮手动控制。 + +练习重点:复选框的 Click 事件、定时器(Timer)、控件的 Enabled 属性。详细步骤见书 p58–61。 + +## 界面和控件名 + +| 控件 | 名称(Name) | 文字(Text)和用途 | +|---|---|---| +| 窗体 | `Form1` | 人体红外ZigBee联动 | +| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 | +| 按钮 | `btn_lampOpen` | 开(手动开灯) | +| 按钮 | `btn_lampClose` | 关(手动关灯) | +| 分组框 | `groupBox1` | 配置 | +| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 | +| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 | +| 复选框 | `ch_bodyInfrared` | 人体开关 | +| 按钮 | `btn_open` | 打开(打开后变成"关闭") | +| 定时器 | `timer1` | Interval 设为 5000(5 秒),Enabled 保持 False | + +> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。 + +## 实现步骤 + +1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。 +2. **Form1_Load**:清空两个下拉框,用 `SerialPort.GetPortNames()` 把串口号加进去。 +3. 写一个方法 `LampOnOff(int RelayNum, bool IsOpen)`:调用 `DeviceCtrlHelper.zigBeeHelper.OnOff` 控制实训台的路灯,再调用 `lamp1.OnOff` 让窗体上的灯泡跟着变。 +4. **btn_open 的 Click**:和任务 1 一样,按钮文字是"打开"时打开 ZigBee 串口(`com_lamp.Text`)并改成"关闭",反之关闭串口。 +5. **btn_lampOpen、btn_lampClose 的 Click**:分别调用 `LampOnOff(1, true)`、`LampOnOff(1, false)`。 +6. **ch_bodyInfrared 的 Click**: + - 勾上时:打开 ADAM-4150 串口(`adam4150Helper.Open(com_environment.Text)`),启动 `timer1`,两个手动按钮设为不可用; + - 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。 +7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。 + +> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**:在设计器里双击 timer1 就会生成 `timer1_Tick`。 + +## 在平台上怎么试 + +1. 运行后,"路灯"选 **COM4**(ZigBee 协调器),"环境采集"选 **COM3**(ADAM-4150),点"打开"。 +2. 点"开""关",看窗体上的灯泡和实训台的**照明灯 1** 是否一起亮灭。 +3. 勾选"人体开关","开""关"按钮应变成灰色;在实训台上把**人体红外**点成"有人",5 秒内路灯应亮起,点回"无人"后 5 秒内熄灭。 diff --git a/samples/p6-t2-存储环境数据/task.md b/samples/p6-t2-存储环境数据/task.md new file mode 100644 index 0000000..e61793c --- /dev/null +++ b/samples/p6-t2-存储环境数据/task.md @@ -0,0 +1,57 @@ +## 任务目标 + +从 ADAM-4017+ 读出光照度、温度、湿度,点"保存"存进 SQL Server 数据库的 CampusEnvironment 表,在表格里显示全部记录;还能在表格里改数据后点"修改"保存,选中一行点"删除"删掉。 + +练习重点:ADO.NET 的 SqlConnection、SqlCommand、SqlDataAdapter,insert / update / delete 语句,DataGridView 绑定数据。详细步骤见书 p119–124。 + +## 先准备数据库 + +这个任务用任务 1 建的**环境数据表**库,并在库里新建 **CampusEnvironment** 表:ID(int,标识列,主键)、light、humidity、temperature(都是 varchar(50))、addtime(datetime)。 + +在右上角的**数据库**页面里建表;还没做任务 1,或者数据弄乱了,点**恢复实验初始数据**,选这个实验,库和表会自动建好。 + +## 界面和控件名 + +| 控件 | 名称(Name) | 文字(Text)和用途 | +|---|---|---| +| 窗体 | `Form1` | 校园灾情数据存储 | +| 分组框 | `groupBox2` | 配置 | +| 下拉框 | `com_port` | ADAM-4017+ 的串口 | +| 按钮 | `btn_Open` | 开启(打开后变成"关闭") | +| 分组框 | `groupBox1` | 数据显示 | +| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 | +| 按钮 | `btn_get` | 获取 | +| 按钮 | `btn_save` | 保存 | +| 按钮 | `btnUpdate` | 修改 | +| 按钮 | `btnDelete` | 删除 | +| 分组框 | `groupBox3` | 历史记录 | +| 数据表格 | `dataGridView1` | 显示数据库里的记录 | + +> 注意按钮名字的写法不统一:`btn_Open`、`btn_get`、`btn_save` 带下划线,`btnUpdate`、`btnDelete` 不带,照书上的写,自动评分按这些名字找按钮。 + +## 实现步骤 + +1. 引用 `System.Data.SqlClient`、`System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。 +2. 声明全局变量:连接字符串、SqlConnection、SqlCommand、SqlDataAdapter、DataSet、串口是否打开(bool)、ADAM4017ResModel。 +3. **Form1_Load**:打开数据库连接,把串口号加进 `com_port`。 +4. **btn_Open 的 Click**:串口没打开就打开(`adam4017Helper.Open`)并把按钮改成"关闭",否则关闭并改回"开启"。 +5. **btn_get 的 Click**:`adam4017Helper.SetData()` 读出数据,把 lightValue、temperatureValue、humidityValue 填进三个文本框。 +6. 写一个 **Databind()** 方法:用 select 语句(列名起中文别名:编号、温度、湿度、光照度、时间)查出全部记录,用 SqlDataAdapter 填进 DataSet,绑定到 `dataGridView1`。 +7. **btn_save 的 Click**:用 insert 语句把三个值和当前时间存进表,提示"保存成功!",再调用 Databind() 刷新表格。 +8. **btnUpdate 的 Click**:用 SqlCommandBuilder 生成更新语句,`sqldata.Update(...)` 把表格里改过的数据写回数据库。 +9. **btnDelete 的 Click**:按表格当前行第一列的编号,用 delete 语句删掉这一行,再刷新表格。 + +## 和书上不一样的地方 + +> **连接字符串**:平台只看 `Initial Catalog=环境数据表`,服务器名、用户名和密码照书上写或写成 `Data Source=.` 都可以。 + +> **insert 要写列名**:书上写的是 `insert into CampusEnvironment values (温度, 湿度, 光照度, 时间)`,没写列名时值按建表的列顺序(light、humidity、temperature)存进去,温度和光照度就存反了。要写成 `insert into CampusEnvironment (temperature, humidity, light, addtime) values (...)`。 + +> 书上的 `True` / `False` 大写、中文引号 `”关闭”`、`privatevoid` 都是印刷错误,照抄会编译不过。 + +## 在平台上怎么试 + +1. 运行后串口选 **COM2**(ADAM-4017+),点"开启"。 +2. 在实训台上拖动光照、温度、湿度的滑块,点"获取",三个文本框应显示对应的值。 +3. 点"保存",弹出"保存成功!",表格最后一行是刚存的数据——检查温度、湿度、光照度有没有存进对应的列。 +4. 在表格里改一个温度,点"修改";点中某一行,点"删除"。到**数据库**页面查询 CampusEnvironment 表,看数据是不是真的变了。 diff --git a/server/src/app.ts b/server/src/app.ts index a85a7a0..f6d8628 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -25,7 +25,7 @@ import { getReview, gradebook, manualScore, saveReview, studentDetail, submissio import type { GradingQueue } from './grading/queue' import { config } from './config' import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases' -import { listSamples, loadChecks, loadSample } from './samples' +import { listSamples, loadChecks, loadSample, loadTask } from './samples' import { SqlError, sqlService } from './sql-service' import { serveStatic } from './static' import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works' @@ -112,6 +112,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ return c.json(parseChecks(checks).map((check) => check.title)) }) + /** 实验的任务说明(Markdown);没有时 task 为 null */ + app.get('/api/samples/:id/task', async (c) => c.json({ task: await loadTask(c.req.param('id')) })) + app.post('/api/samples/:id/selfcheck', async (c) => { const sampleId = c.req.param('id') const yaml = (await listSamples()).some((s) => s.id === sampleId) ? await loadChecks(sampleId) : null @@ -202,11 +205,18 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ ) }) - app.get('/api/assignments/:id', (c) => { + app.get('/api/assignments/:id', async (c) => { const assignment = visibleAssignment(c) if (!assignment) return c.json({ message: '作业不存在' }, 404) const userId = c.get('user').id - return c.json({ assignment, template: assignmentTemplate(db, assignment.id), latest: latestSubmission(db, assignment.id, userId), review: getReview(db, assignment.id, userId) }) + // 以前布置的作业没有保存任务说明,用实验现在的说明 + const task = assignment.task ?? (await loadTask(assignment.sampleId)) + return c.json({ + assignment: { ...assignment, task }, + template: assignmentTemplate(db, assignment.id), + latest: latestSubmission(db, assignment.id, userId), + review: getReview(db, assignment.id, userId), + }) }) app.post('/api/assignments/:id/submit', async (c) => { @@ -298,7 +308,7 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ ) app.post('/api/teacher/assignments', async (c) => { - const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null }>(c) + const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c) if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404) const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400) @@ -313,13 +323,22 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ checks, scoring: a.scoring as Scoring, deadline: typeof a.deadline === 'number' ? a.deadline : null, + task: typeof a.task === 'string' ? a.task : null, }) return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result) }) app.patch('/api/teacher/assignments/:id', async (c) => { - const a = await body<{ title: string; deadline: number | null; scoring: Scoring }>(c) - return reply(c, updateAssignment(db, id(c), { title: String(a.title ?? ''), deadline: typeof a.deadline === 'number' ? a.deadline : null, scoring: a.scoring as Scoring })) + const a = await body<{ title: string; deadline: number | null; scoring: Scoring; task: string | null }>(c) + return reply( + c, + updateAssignment(db, id(c), { + title: String(a.title ?? ''), + deadline: typeof a.deadline === 'number' ? a.deadline : null, + scoring: a.scoring as Scoring, + task: typeof a.task === 'string' ? a.task : null, + }), + ) }) app.delete('/api/teacher/assignments/:id', (c) => { diff --git a/server/src/assignments.ts b/server/src/assignments.ts index 1b11e8a..ac24f07 100644 --- a/server/src/assignments.ts +++ b/server/src/assignments.ts @@ -15,10 +15,11 @@ interface AssignmentRow { scoring: string deadline: number | null created_at: number + task: string | null } const SELECT_ASSIGNMENT = ` - SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at + SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at, a.task FROM assignments a JOIN classes c ON c.id = a.class_id` function toAssignment(row: AssignmentRow): Assignment { @@ -33,6 +34,7 @@ function toAssignment(row: AssignmentRow): Assignment { createdAt: row.created_at, checkTitles: parseChecks(row.checks).map((c) => c.title), scoring: JSON.parse(row.scoring) as Scoring, + task: row.task, } } @@ -76,32 +78,46 @@ export interface NewAssignment { checks: string scoring: Scoring deadline: number | null + /** 任务说明(Markdown),null 表示用实验的说明 */ + task: string | null } +/** 任务说明最长多少字 */ +export const MAX_TASK_LENGTH = 20_000 + export function createAssignment(db: Database, a: NewAssignment): { id: number } | { error: string } { if (!a.title.trim()) return { error: '作业名称不能为空' } - const error = scoringError(a.scoring, parseChecks(a.checks).length) + const error = scoringError(a.scoring, parseChecks(a.checks).length) ?? taskError(a.task) if (error) return { error } const result = db .query( - `INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at, task) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) - .run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now()) + .run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now(), a.task) return { id: Number(result.lastInsertRowid) } } -/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */ -export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } { +function taskError(task: string | null) { + return task !== null && task.length > MAX_TASK_LENGTH ? `任务说明不能超过 ${MAX_TASK_LENGTH} 字` : null +} + +/** 修改名称、截止时间、分值、任务说明(起点工程和评分规则布置后不能改) */ +export function updateAssignment( + db: Database, + id: number, + changes: { title: string; deadline: number | null; scoring: Scoring; task: string | null }, +): { error?: string } { const checks = assignmentChecks(db, id) if (!checks) return { error: '作业不存在' } if (!changes.title.trim()) return { error: '作业名称不能为空' } - const error = scoringError(changes.scoring, checks.length) + const error = scoringError(changes.scoring, checks.length) ?? taskError(changes.task) if (error) return { error } - db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run( + db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ?, task = ? WHERE id = ?').run( changes.title.trim(), changes.deadline, JSON.stringify(changes.scoring), + changes.task, id, ) // 分值变了,已评分的提交重新算分 diff --git a/server/src/db.ts b/server/src/db.ts index c1c3ffd..23385c9 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -101,6 +101,10 @@ const MIGRATIONS = [ ALTER TABLE submissions ADD COLUMN screenshots TEXT; ALTER TABLE submissions ADD COLUMN ai_review TEXT; `, + // 作业的任务说明(Markdown);以前布置的作业为 NULL,用实验现在的说明 + ` + ALTER TABLE assignments ADD COLUMN task TEXT; + `, ] /** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */ diff --git a/server/src/samples.ts b/server/src/samples.ts index 2853777..b1fc828 100644 --- a/server/src/samples.ts +++ b/server/src/samples.ts @@ -33,6 +33,14 @@ export async function loadSample(id: string): Promise { return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null } +/** 示例的任务说明(task.md 原文,Markdown);没有说明返回 null */ +export async function loadTask(id: string): Promise { + const path = resolve(SAMPLES_DIR, id, 'task.md') + if (!path.startsWith(`${SAMPLES_DIR}/`)) return null + const file = Bun.file(path) + return (await file.exists()) ? file.text() : null +} + /** 示例的评分规则(checks.yaml 原文);没有规则返回 null */ export async function loadChecks(id: string): Promise { const path = resolve(SAMPLES_DIR, id, 'checks.yaml') diff --git a/server/test/api.test.ts b/server/test/api.test.ts index d6f8ccb..442dc3c 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -9,7 +9,7 @@ import { openDatabase } from '../src/db' import { createGradingQueue, type GradingQueue } from '../src/grading/queue' import { languageService } from '../src/language-service' import { sqlService } from '../src/sql-service' -import { loadSample } from '../src/samples' +import { loadSample, loadTask } from '../src/samples' let app: ReturnType let queue: GradingQueue @@ -287,6 +287,28 @@ describe('作业', () => { return { t, student, classId, assignmentId: created.body.id as number, files: template } } + test('任务说明:布置时保存,老师可以改;没有保存的作业用实验现在的说明', async () => { + const { t, student, classId, assignmentId } = await setup() + // setup 没传任务说明(像加这个功能之前布置的作业) + expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe(await loadTask(SAMPLE)) + + await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring, task: '## 老师要求\n只做前 3 步' }) + expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe('## 老师要求\n只做前 3 步') + + const tooLong = await t.post('/api/teacher/assignments', { + classId, + sampleId: SAMPLE, + title: '太长', + startMode: 'ui', + template: [], + scoring, + deadline: null, + task: 'x'.repeat(20_001), + }) + expect(tooLong.status).toBe(400) + expect(tooLong.body.message).toContain('任务说明') + }) + test('分值项数要和评分规则一致', async () => { const t = await teacher() const classId = await classWithStudents(t, []) diff --git a/server/test/samples.test.ts b/server/test/samples.test.ts new file mode 100644 index 0000000..10b5477 --- /dev/null +++ b/server/test/samples.test.ts @@ -0,0 +1,38 @@ +// 示例的任务说明(task.md):表格里写的控件名都要在示例的 Designer.cs 里有,学生照着改名不会被带错。 +import { expect, test } from 'bun:test' +import { listSamples, loadSample, loadTask } from '../src/samples' + +/** 控件表格(表头有"名称")里那一列用 `反引号` 写的名字 */ +function tableNames(task: string): string[] { + const names: string[] = [] + let column = -1 + for (const line of task.split('\n')) { + if (!line.trim().startsWith('|')) { + column = -1 + continue + } + const cells = line.trim().replace(/^\||\|$/g, '').split('|') + const header = cells.findIndex((c) => c.includes('名称')) + if (header >= 0) column = header + else if (column >= 0) names.push(...[...(cells[column] ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1])) + } + return names +} + +test('任务说明里的控件名都在示例的界面里', async () => { + let checked = 0 + for (const { id } of await listSamples()) { + const task = await loadTask(id) + if (!task) continue + const designer = (await loadSample(id))!.filter((f) => f.path.endsWith('.Designer.cs')) + const names = tableNames(task) + expect(names.length, `${id} 的任务说明没有控件表格`).toBeGreaterThan(0) + for (const name of names) { + // 窗体本身写的是类名,控件是 this.名字 + const found = designer.some((f) => f.content.includes(`this.${name} `) || f.content.includes(`partial class ${name}`)) + expect(found, `${id}:任务说明里的 ${name} 在 Designer.cs 里找不到`).toBe(true) + } + checked++ + } + expect(checked).toBeGreaterThan(0) +}) diff --git a/shared/grading.ts b/shared/grading.ts index 42cb269..dd35930 100644 --- a/shared/grading.ts +++ b/shared/grading.ts @@ -49,6 +49,11 @@ export interface Assignment { /** 自动评分项的名称,和 scoring.auto 一一对应 */ checkTitles: string[] scoring: Scoring + /** + * 任务说明(Markdown,见 web/src/grading/markdown.ts):布置时从实验的 task.md 复制,老师可以改写。 + * null 是加这个功能之前布置的作业,学生看到的是实验现在的说明 + */ + task: string | null } export interface Submission { diff --git a/web/src/grading/SelfCheckPanel.vue b/web/src/grading/SelfCheckPanel.vue deleted file mode 100644 index 8e89b9c..0000000 --- a/web/src/grading/SelfCheckPanel.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - diff --git a/web/src/grading/TaskPanel.vue b/web/src/grading/TaskPanel.vue new file mode 100644 index 0000000..174851e --- /dev/null +++ b/web/src/grading/TaskPanel.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/web/src/grading/markdown.test.ts b/web/src/grading/markdown.test.ts new file mode 100644 index 0000000..a28e0d6 --- /dev/null +++ b/web/src/grading/markdown.test.ts @@ -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( + '

任务目标

勾选人体开关后,
每 5 秒检查一次 timer1

', + ) + }) + + test('无序、有序列表,缩进的续行接在上一项', () => { + expect(renderMarkdown('- 甲\n- 乙\n 续行\n\n1. 一\n2. 二')).toBe('
  • 甲
  • 乙
    续行
  1. 一
  2. 二
') + expect(renderMarkdown('3. 接着第 3 步')).toBe('
  1. 接着第 3 步
') + expect(renderMarkdown('1. 勾选的 Click:\n - 勾上时:启动\n - 取消时:停止\n 最后刷新\n2. 下一步')).toBe( + '
  1. 勾选的 Click:
    • 勾上时:启动
    • 取消时:停止

    最后刷新
  2. 下一步
', + ) + expect(renderMarkdown('- 甲\n - 甲1\n- 乙')).toBe('
  • 甲
    • 甲1
  • 乙
') + }) + + test('表格:列数以表头为准', () => { + expect(renderMarkdown('| 控件 | 名称 |\n|---|---|\n| 按钮 | `btn_open` |\n| 复选框 |')).toBe( + '
控件名称
按钮btn_open
复选框
', + ) + }) + + test('引用变成提示框', () => { + expect(renderMarkdown('> 书上写成了 `True`\n> 要改成小写')).toBe('
书上写成了 True
要改成小写
') + }) + + test('HTML 一律转义,不会插入标签或脚本', () => { + const html = renderMarkdown('\n- \n| |\n|---|\n| `` |') + expect(html).not.toContain('<i>') + }) +}) diff --git a/web/src/grading/markdown.ts b/web/src/grading/markdown.ts new file mode 100644 index 0000000..1d9c71e --- /dev/null +++ b/web/src/grading/markdown.ts @@ -0,0 +1,119 @@ +// 任务说明用的 Markdown 子集,转成 HTML:标题(# ## ###)、段落、无序 / 有序列表(缩进的 - 是下一级)、表格、引用(> 提示框)、**加粗**、`代码`。 +// 老师可以改写任务说明,所以先把文字里的 HTML 全部转义,只输出这里生成的标签,不会被插入脚本。 + +const escapeHtml = (text: string) => text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + +/** 行内:`代码` 里的内容原样显示,其余部分处理 **加粗** */ +function inline(text: string): string { + return text + .split(/(`[^`]+`)/) + .map((part) => + part.length > 2 && part.startsWith('`') && part.endsWith('`') + ? `${escapeHtml(part.slice(1, -1))}` + : escapeHtml(part).replace(/\*\*(.+?)\*\*/g, '$1'), + ) + .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 += `
    ${sub.map((t) => `
  • ${inline(t)}
  • `).join('')}
` + sub = [] + } + for (const line of rest) { + const bullet = /^[-*]\s+(.*)$/.exec(line) + if (bullet) sub.push(bullet[1]) + else { + flush() + html += `
${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])}`) + 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( + `${head.map((c) => ``).join('')}` + + `${rows.map((r) => `${head.map((_, k) => ``).join('')}`).join('')}
${inline(c)}
${inline(r[k] ?? '')}
`, + ) + 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) => `
  • ${listItemHtml(t)}
  • `).join('')}`) + continue + } + if (line.startsWith('>')) { + const quoted: string[] = [] + while (i < lines.length && lines[i].startsWith('>')) quoted.push(lines[i++].replace(/^>\s?/, '')) + out.push(`
    ${quoted.map(inline).join('
    ')}
    `) + 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(`

    ${paragraph.map(inline).join('
    ')}

    `) + } + return out.join('') +} diff --git a/web/src/pages/HelpPage.vue b/web/src/pages/HelpPage.vue index 44f1e5f..5430a8b 100644 --- a/web/src/pages/HelpPage.vue +++ b/web/src/pages/HelpPage.vue @@ -32,6 +32,7 @@ import { currentUser } from '../auth'
    • 登录后进入自由练习:左上角的下拉框选教材上的任务,可以随便改、随便运行。
    • 点右上角的我的作业,看老师布置的作业、截止时间和得分;点作业名开始做。
    • +
    • 工具栏的任务:任务目标、控件名、实现步骤和在平台上怎么试,第一次打开一个实验时自动弹出。面板不挡住设计器和代码,可以开着做。
    • 作业的起点有两种:界面已做好(只写事件和代码)、空白工程(从搭界面做起)。控件名照书上的写,自动评分按书上的控件名操作你的程序。
    • @@ -65,7 +66,7 @@ import { currentUser } from '../auth'

      6. 自己检查、提交

        -
      • 点评价项看这个任务要检查哪些功能;点自己检查,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
      • +
      • 任务面板下面是评价项:这个任务要自动检查哪些功能;点自己检查,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
      • 做作业时点提交,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
      • @@ -97,6 +98,7 @@ import { currentUser } from '../auth'
        • 作业标签页 → 布置作业:选实验、起点(界面已做好 / 空白工程)、截止时间(可以不限)。
        • 自动评分项默认每项 10 分;老师评分项(界面、注释、命名)由你在评阅时打分,可以增删、改分值。
        • +
        • 任务说明预填实验的说明,可以改写,比如在最前面加一段"这次只做前 3 步";布置后也能在修改里改。

        3. 评阅、成绩

        diff --git a/web/src/pages/WorkbenchPage.vue b/web/src/pages/WorkbenchPage.vue index 96afe0e..2e97308 100644 --- a/web/src/pages/WorkbenchPage.vue +++ b/web/src/pages/WorkbenchPage.vue @@ -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('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('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork) } -// ---------- 评价项和自测 ---------- +// ---------- 任务说明、评价项和自测 ---------- const sampleChecks = ref([]) -const selfCheckOpen = ref(false) -/** 作业用布置时的评分规则,自由练习用实验的评分规则 */ -const selfCheck = computed(() => { +const sampleTask = ref(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(null) const submission = ref(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 }} - - 评价项 + + 任务