导出 EXE:实验页工具栏"导出 EXE"把学生的程序编译成一个 exe,拷到机房电脑上双击就能控制真的实训台。编译服务照教材工程编译学生写的原始代码(.NET Framework 4.5 引用程序集,构建时只复制教材工程引用的 13 个到 netfx/;真机版设备类库),不做平台的仿真改写和 API 限制,和 VS 里一样。教材 dll(DeviceCtrlLibrary 及 API 下 7 个、WinFormsDeviceControlLibrary、媒体播放器的两个 Interop)作为资源嵌进 exe,模块初始化器注册 AssemblyResolve 从 exe 里加载,Program.cs 不用改;Properties.Resources 的图片、声音嵌进 exe,代码里按文件名用到的素材(项目 7 的 ring.mp3)启动时放到 exe 旁边的 Resources;媒体播放器的 OcxState 用教材 resx 的默认状态;书上的 ledHelper.Close() 在教材类库里没有,补一个空的扩展方法。23 个实验都能导出(约 0.9 MB),mono + Xvfb 里实测能启动、控件和图片正常。接口 POST /api/export-exe,代码有错时 422,实验页弹框列出报错、点击跳到代码。教材 dll 有版权、GitHub 仓库公开,不进仓库:本机放 textbook-libs/,服务器放 ~/IOT_SIM/textbook-libs/ 由 compose 只读挂载(deploy/README.md),没放时提示原因。使用说明、设计方案、README 同步
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-11-02 22:10:07 +08:00
1 parent 46e0f40cce
commit 5e5d29b2aa
19 files changed
+601 -10

No files matched your search

+21
View File
@@ -27,6 +27,7 @@ import { config } from './config'
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
import { SqlError, sqlService } from './sql-service'
import { exeName, exportExe } from './export-exe'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -129,6 +130,26 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
app.get('/api/selfcheck', (c) => c.json(selfCheck.get(c.get('user').id)))
/** 导出 EXE:成功时返回 exe 文件;代码有错误时 422 和报错(照真机版类库编译,和 VS 里一样) */
app.post('/api/export-exe', async (c) => {
const { files, name } = await body<{ files: unknown; name: unknown }>(c)
if (!validFiles(files)) return c.json({ message: '作品格式不对或太大' }, 400)
let result
try {
result = await exportExe(files, exeName(name))
} catch (error) {
return c.json({ message: error instanceof Error ? error.message : String(error) }, 503)
}
if (!result.ok) return c.json({ message: '代码有错误,不能导出', diagnostics: result.diagnostics }, 422)
return new Response(result.exe, {
headers: {
'Content-Type': 'application/vnd.microsoft.portable-executable',
'Content-Disposition': `attachment; filename="program.exe"; filename*=UTF-8''${encodeURIComponent(`${result.name}.exe`)}`,
'Cache-Control': 'no-store',
},
})
})
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
app.post('/api/language', async (c) => {
+17 -2
View File
@@ -9,6 +9,9 @@ export interface CompileResult {
ms: number
}
/** 导出的 exe 在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile) */
export const EXPORT_FILE = 'export.exe'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
/**
@@ -22,11 +25,23 @@ class CompileService {
private readonly pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void }>()
/** 编译工程目录;成功时程序集写到同一目录 */
async compile(dir: string): Promise<CompileResult> {
compile(dir: string): Promise<CompileResult> {
return this.request({ dir })
}
/**
* 导出 EXE:照教材工程编译成 .NET Framework 程序,嵌入教材的设备类库(config.textbookLibsDir),
* 成功时写到工程目录的 EXPORT_FILE。name 是 exe 的名字(不带 .exe)
*/
exportExe(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> {
await this.start()
const id = this.nextId++
const result = new Promise<CompileResult>((resolve, reject) => this.pending.set(id, { resolve, reject }))
this.proc!.stdin.write(`${JSON.stringify({ id, dir })}\n`)
this.proc!.stdin.write(`${JSON.stringify({ id, ...payload })}\n`)
this.proc!.stdin.flush()
return result
}
+5
View File
@@ -13,6 +13,11 @@ export const config = {
/** 每次运行的临时工程目录放在这里 */
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
/**
* 导出 EXE 用的教材设备类库(真机版 DeviceCtrlLibrary.dll 及其 API 目录下的 dll、WinFormsDeviceControlLibrary.dll、
* 媒体播放器的 AxInterop.WMPLib.dll / Interop.WMPLib.dll,放在同一个目录里)。有版权,不进仓库;没有时不能导出
*/
textbookLibsDir: resolve(ROOT, env.SIM_TEXTBOOK_LIBS ?? 'textbook-libs'),
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
+33
View File
@@ -0,0 +1,33 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { Diagnostic, SourceFile } from '@iot/shared'
import { compileService, EXPORT_FILE } from './compile-service'
import { config } from './config'
import { safeJoin } from './run-session'
export type ExportResult = { ok: true; exe: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
/** exe 的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function exeName(name: unknown) {
const cleaned = typeof name === 'string' ? name.replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40) : ''
return cleaned || '上位机程序'
}
/** 编译失败返回报错;服务器上没有教材 dll 等情况抛出异常(消息给学生看) */
export async function exportExe(files: SourceFile[], name: string): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-'))
try {
for (const file of files) {
const target = safeJoin(dir, file.path)
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
const result = await compileService.exportExe(dir, name)
if (!result.ok) return { ok: false, diagnostics: result.diagnostics }
return { ok: true, exe: await Bun.file(join(dir, EXPORT_FILE)).bytes(), name }
} finally {
await rm(dir, { recursive: true, force: true })
}
}
+51
View File
@@ -1,5 +1,6 @@
// HTTP 接口测试:内存数据库,直接调用 app.fetch。
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { createAiQueue, normalizeAiResult } from '../src/ai-review'
import { createApp } from '../src/app'
@@ -738,3 +739,53 @@ describe('AI 评阅', () => {
expect((await t.post(`/api/teacher/assignments/${assignmentId}/adopt-ai`)).body).toEqual({ adopted: 0 })
}, 60000)
})
describe('导出 EXE', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
async function exportExe(browser: Browser, json: unknown) {
const response = await app.fetch(
new Request('http://localhost/api/export-exe', {
method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json),
}),
)
return response
}
test.skipIf(!hasLibs)(
'照真机版类库编译成 exe;书上的 ledHelper.Close() 也能编译;代码有错时返回报错',
async () => {
const t = await teacher()
const files = (await loadSample('p2-t3-LED推送模拟量'))!
const response = await exportExe(t, { files, name: 'LED推送 模拟量/..' })
expect(response.status).toBe(200)
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('LED推送模拟量.exe')}`)
const exe = new Uint8Array(await response.arrayBuffer())
expect(String.fromCharCode(exe[0]!, exe[1]!)).toBe('MZ')
// 教材 dll 嵌在 exe 里
expect(Buffer.from(exe).includes('IotSimExport.lib.DeviceCtrlLibrary.dll')).toBe(true)
const broken = files.map((f) => (f.path === 'frm_LED.cs' ? { ...f, content: f.content.replace('IsOpen = true;', 'IsOpen = true') } : f))
const failed = await exportExe(t, { files: broken, name: 'x' })
expect(failed.status).toBe(422)
expect(((await failed.json()) as any).diagnostics[0]).toMatchObject({ file: 'frm_LED.cs', severity: 'error' })
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher()
const saved = config.textbookLibsDir
config.textbookLibsDir = '/nonexistent/textbook-libs'
try {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库')
} finally {
config.textbookLibsDir = saved
}
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
}, 60000)
})