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

- 作业:老师选实验、起点(界面已做好 / 空白工程)、截止时间、每项分值(自动评分项 + 老师评分项);
  起点工程由设计器引擎从参考答案生成(去掉事件绑定和事件处理方法),布置时保存起点和评分规则快照
- 提交:后台排队评分(全站同时最多评 3 份、跑 6 个检查项),重启后继续评;评分中不能再交,截止后不能交
- 学生:"我的作业"列表;实验页的作业模式(作业名、截止时间、提交、得分),评分结果弹出逐项显示
- 老师:班级管理页加"作业"标签页,布置、修改、删除作业,查看全班提交情况;改分值后重新算分
- 串口列表为空时提示在 Load 或 Click 事件里填充
- 测试:作业接口(可见范围、排队评分、部分得分、改分值、截止、删除);10 个示例两种起点都能编译

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 19:02:22 +08:00
1 parent 37fc747b49
commit 4e6b0d5cb2
23 files changed
+1439 -50

No files matched your search

+34 -1
View File
@@ -3,7 +3,7 @@ import { expect, test } from 'bun:test'
import { generateDesigner, parseDesigner } from './document'
import { addControl } from './edit'
import { controlDef } from './catalog'
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
import { assignmentTemplate, formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
const files = [
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
@@ -27,3 +27,36 @@ test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正
expect(text).toContain(' this.Controls.Add(this.button1);\n this.Name = "AddUser";')
expect(text).toContain(' #endregion\n\n private System.Windows.Forms.Button button1;\n }')
})
/** 读取 samples/ 下的示例工程 */
async function sample(id: string) {
const dir = `${import.meta.dir}/../../../samples/${id}`
const paths = await Array.fromAsync(new Bun.Glob('*.cs').scan({ cwd: dir }))
return Promise.all(paths.sort().map(async (path) => ({ path, content: await Bun.file(`${dir}/${path}`).text() })))
}
test('作业起点"界面已做好":保留控件和属性,去掉事件绑定和事件处理方法', async () => {
const files = assignmentTemplate(await sample('p2-t1-LED显示屏'), 'ui')
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
const designer = files.find((f) => f.path === 'Form1.Designer.cs')!.content
expect(designer).toContain('this.btn_Open = new System.Windows.Forms.Button();')
expect(designer).toContain('this.btn_Open.Text = "打 开";')
expect(designer).not.toContain('+=')
const code = files.find((f) => f.path === 'Form1.cs')!.content
expect(code).toContain('namespace LED显示屏')
expect(code).not.toContain('btn_Open_Click')
})
test('作业起点"界面已做好":多个窗体都保留,其他类(PublicData)由学生自己写', async () => {
const files = assignmentTemplate(await sample('p1-t1-登录'), 'ui')
expect(files.map((f) => f.path).sort()).toEqual(['AddUser.Designer.cs', 'AddUser.cs', 'Login.Designer.cs', 'Login.cs', 'Program.cs'])
expect(files.find((f) => f.path === 'AddUser.cs')!.content).toContain('public partial class AddUser : Form')
})
test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
const files = assignmentTemplate(await sample('p2-t2-模拟量采集'), 'blank')
expect(files.map((f) => f.path).sort()).toEqual(['Form1.Designer.cs', 'Form1.cs', 'Program.cs'])
const program = files.find((f) => f.path === 'Program.cs')!.content
expect(program).toContain('Application.Run(new Form1())')
expect(files.find((f) => f.path === 'Form1.Designer.cs')!.content).not.toContain('com_port')
})
+28 -1
View File
@@ -1,5 +1,6 @@
// 新建窗体:生成与 VS(中文版、.NET Framework 4.5)新建窗体并保存一次后相同的两个文件。
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。
import type { SourceFile } from '@iot/shared'
import { generateDesigner, parseDesigner } from './document'
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
export function projectNamespace(files: SourceFile[]): string {
@@ -104,3 +105,29 @@ ${blockHeader(name)}
{ path: `${name}.Designer.cs`, content: designer },
]
}
export type StartMode = 'blank' | 'ui'
/**
* 布置作业时学生的起点工程(由参考答案生成):
* - blank:空白工程,只有 Program.cs 和一个空的 Form1,学生从搭界面做起;
* - ui:界面已做好,保留每个窗体的控件和属性,去掉事件绑定,窗体代码只剩构造函数,
* 学生在设计器里双击控件生成事件处理方法、写代码。其他类(如 PublicData)也要学生自己写。
*/
export function assignmentTemplate(files: SourceFile[], mode: StartMode): SourceFile[] {
const namespace = projectNamespace(files)
const program = files.find((f) => f.path === 'Program.cs')
if (mode === 'blank') {
const startup = startupForm(files)
const programText = program && startup ? program.content.replace(new RegExp(`new\\s+${startup}\\s*\\(`, 'u'), 'new Form1(') : undefined
return [...(programText ? [{ path: 'Program.cs', content: programText }] : []), ...newFormFiles(namespace, 'Form1')]
}
const result: SourceFile[] = program ? [{ ...program }] : []
for (const designer of files.filter((f) => f.path.endsWith('.Designer.cs'))) {
const doc = parseDesigner(designer.content)
for (const block of doc.blocks) block.stmts = block.stmts.filter((s) => s.kind !== 'event')
const [code] = newFormFiles(namespace, doc.className)
result.push({ path: designer.path.replace(/\.Designer\.cs$/, '.cs'), content: code.content }, { path: designer.path, content: generateDesigner(doc) })
}
return result
}