导出 VS 工程:实验页工具栏的"导出 EXE"改成下拉按钮"导出 ▾",里面是"导出 EXE"和"导出 VS 工程"。导出的 zip 是教材那样的 VS 2012 窗体应用工程(.sln + .csproj,.NET Framework 4.5),在机房电脑上解压、双击 .sln 用 VS 打开接着改,按 F5 运行。导出前照机房环境编译一遍要放进 zip 的代码:C# 5(VS 2012 的编译器)、真机版设备类库,有错不导出;新版 C# 写法报 CS8026,按功能名给出改法(字符串插值改 string.Format、?. 改先判断 null、=> 改大括号等 12 种)。导出 EXE 仍按最新 C#。平台的 Properties/Resources.Designer.cs 换成 VS 生成的写法和 Resources.resx,图片放 Resources\;代码里按文件名用的素材(ring.mp3)复制到输出目录,运行时在 bin\Debug\Resources\;每个窗体带 .resx(媒体播放器 OcxState 用教材默认状态),SubType、DependentUpon 和 VS 一样;补上 AssemblyInfo、Settings、App.config。教材 dll 全放 lib\,只引用代码直接用到的(和教材工程一样),只带这些编译不过时引用全部;用到书上的 ledHelper.Close() 时带上 LedHelperCompat.cs。文本文件用带 BOM 的 UTF-8 和 CRLF(VS 2012 打开不带 BOM 的文件会把中文当 GBK);zip 文件名用 GBK,另附 Info-ZIP Unicode 文件名字段(0x7075),中文 Windows 自带解压和 7-Zip 都显示正确(ZipWriter.cs)。23 个示例都能导出,导出的工程在 mono 的 msbuild 里全部编译通过、输出目录和教材 bin\Debug 一样、程序能启动;VS 2012 还要在机房实测。接口 POST /api/export-project,代码有错时 422。export-exe.ts 改名 export.ts(服务器和网页各一个)。使用说明、设计方案、README、部署说明同步
Deploy / deploy (push) Failing after 1m8s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 12:42:13 +08:00
1 parent 4c3ce31fe1
commit 2aa267839f
17 files changed
+1209 -80

No files matched your search

+29 -19
View File
@@ -27,7 +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 { type ExportKind, exportProgram, programName } from './export'
import { serveStatic } from './static'
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
@@ -130,25 +130,35 @@ 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',
},
/**
* 导出 EXE、VS 工程:成功时返回 exe 文件或工程的 zip;代码有错误时 422 和报错
* (照真机版类库编译,和 VS 里一样;导出工程还照机房的 VS 2012 只能用 C# 5)
*/
const EXPORTS: Record<ExportKind, { ext: string; type: string }> = {
exe: { ext: 'exe', type: 'application/vnd.microsoft.portable-executable' },
project: { ext: 'zip', type: 'application/zip' },
}
for (const kind of ['exe', 'project'] as const) {
app.post(`/api/export-${kind}`, 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 exportProgram(files, programName(name), kind)
} 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)
const { ext, type } = EXPORTS[kind]
return new Response(result.file, {
headers: {
'Content-Type': type,
'Content-Disposition': `attachment; filename="program.${ext}"; filename*=UTF-8''${encodeURIComponent(`${result.name}.${ext}`)}`,
'Cache-Control': 'no-store',
},
})
})
})
}
// 代码编辑器的语言服务:补全、悬停提示、参数提示、边写边检查
const LANGUAGE_REQUESTS = new Set(['complete', 'completionDetail', 'hover', 'signature', 'diagnostics'])
+10 -1
View File
@@ -9,8 +9,9 @@ export interface CompileResult {
ms: number
}
/** 导出的 exe 在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile) */
/** 导出的 exe、VS 工程(zip)在工程目录里的文件名(Sim.Runner 的 CompileServer.ExportFile、ExportProjectFile) */
export const EXPORT_FILE = 'export.exe'
export const EXPORT_PROJECT_FILE = 'export.zip'
type Reply = ({ type: 'compiled' } & CompileResult) | { type: 'compileFailed'; message: string }
@@ -37,6 +38,14 @@ class CompileService {
return this.request({ dir, export: { libs: config.textbookLibsDir, name } })
}
/**
* 导出 VS 工程:照机房的 VS 2012(C# 5、.NET Framework 4.5、真机版设备类库)检查,
* 打包成 zip 写到工程目录的 EXPORT_PROJECT_FILE。name 是工程名
*/
exportProject(dir: string, name: string): Promise<CompileResult> {
return this.request({ dir, export: { kind: 'project', libs: config.textbookLibsDir, name } })
}
private async request(payload: object): Promise<CompileResult> {
await this.start()
const id = this.nextId++
@@ -1,21 +1,24 @@
// 导出 EXE:学生工程照教材工程编译成 Windows 程序(嵌入教材的真机版设备类库),在机房双击就能控制真的实训台。
// 导出工程:打包成教材那样的 VS 2012 工程(zip),在机房电脑上用 VS 打开接着改。
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 { compileService, EXPORT_FILE, EXPORT_PROJECT_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[] }
export type ExportResult = { ok: true; file: Uint8Array; name: string } | { ok: false; diagnostics: Diagnostic[] }
/** exe 的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function exeName(name: unknown) {
export type ExportKind = 'exe' | 'project'
/** exe、工程的名字:只留文字、数字、下划线和减号(也是程序集名),空了就叫"上位机程序" */
export function programName(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> {
export async function exportProgram(files: SourceFile[], name: string, kind: ExportKind): Promise<ExportResult> {
await mkdir(config.runsDir, { recursive: true })
const dir = await mkdtemp(join(config.runsDir, 'export-'))
try {
@@ -24,9 +27,9 @@ export async function exportExe(files: SourceFile[], name: string): Promise<Expo
await mkdir(dirname(target), { recursive: true })
await Bun.write(target, file.content)
}
const result = await compileService.exportExe(dir, name)
const result = kind === 'project' ? await compileService.exportProject(dir, name) : 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 }
return { ok: true, file: await Bun.file(join(dir, kind === 'project' ? EXPORT_PROJECT_FILE : EXPORT_FILE)).bytes(), name }
} finally {
await rm(dir, { recursive: true, force: true })
}
+107 -3
View File
@@ -740,12 +740,14 @@ describe('AI 评阅', () => {
}, 60000)
})
describe('导出 EXE', () => {
describe('导出 EXE、VS 工程', () => {
const hasLibs = existsSync(`${config.textbookLibsDir}/DeviceCtrlLibrary.dll`)
async function exportExe(browser: Browser, json: unknown) {
const exportExe = (browser: Browser, json: unknown) => exportRequest(browser, 'exe', json)
async function exportRequest(browser: Browser, kind: 'exe' | 'project', json: unknown) {
const response = await app.fetch(
new Request('http://localhost/api/export-exe', {
new Request(`http://localhost/api/export-${kind}`, {
method: 'POST',
headers: { authorization: `Bearer ${browser.accessToken}`, 'content-type': 'application/json', 'x-real-ip': browser.ip },
body: JSON.stringify(json),
@@ -775,6 +777,104 @@ describe('导出 EXE', () => {
60000,
)
/** 读出 zip 里的文件:文件名是 GBK 编码,0x7075 扩展字段里是 UTF-8 文件名,两者要一致 */
function unzip(zip: Uint8Array<ArrayBuffer>) {
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength)
let end = zip.length - 22
while (view.getUint32(end, true) !== 0x06054b50) end--
const count = view.getUint16(end + 10, true)
let p = view.getUint32(end + 16, true)
const files = new Map<string, Uint8Array<ArrayBuffer>>()
for (let i = 0; i < count; i++) {
expect(view.getUint32(p, true)).toBe(0x02014b50)
const flags = view.getUint16(p + 8, true)
const method = view.getUint16(p + 10, true)
const compressedSize = view.getUint32(p + 20, true)
const nameLength = view.getUint16(p + 28, true)
const extraLength = view.getUint16(p + 30, true)
const commentLength = view.getUint16(p + 32, true)
const offset = view.getUint32(p + 42, true)
const nameBytes = zip.subarray(p + 46, p + 46 + nameLength)
const extra = zip.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength)
expect(flags & 0x800).toBe(0)
const name = new TextDecoder('gbk').decode(nameBytes)
if (extra.length > 0) {
expect(extra[0]! | (extra[1]! << 8)).toBe(0x7075)
expect(new TextDecoder().decode(extra.subarray(9))).toBe(name)
} else {
expect(/^[\x20-\x7e]+$/.test(name)).toBe(true)
}
const local = offset + 30 + view.getUint16(offset + 26, true) + view.getUint16(offset + 28, true)
const data = zip.subarray(local, local + compressedSize)
files.set(name, method === 8 ? Bun.inflateSync(data) : data)
p += 46 + nameLength + extraLength + commentLength
}
return files
}
test.skipIf(!hasLibs)(
'导出 VS 2012 工程:资源换成 resx、素材复制到输出目录、教材 dll 放在 lib;照 C# 5 检查',
async () => {
const t = await teacher()
const response = await exportRequest(t, 'project', { files: (await loadSample('p7-t2-智慧寝室'))!, name: '寝室' })
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('application/zip')
expect(response.headers.get('content-disposition')).toContain(`filename*=UTF-8''${encodeURIComponent('寝室.zip')}`)
const files = unzip(new Uint8Array(await response.arrayBuffer()))
const text = (name: string) => {
const bytes = files.get(name)
expect(bytes, name).toBeDefined()
// VS 2012 要带 BOM 才把文件当成 UTF-8;Windows 的换行
expect([...bytes!.subarray(0, 3)]).toEqual([0xef, 0xbb, 0xbf])
const content = new TextDecoder().decode(bytes!.subarray(3))
expect(content.replace(/\r\n/g, '')).not.toContain('\n')
return content
}
expect(text('寝室/寝室.sln')).toContain('# Visual Studio 2012\r\nProject("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "寝室", "寝室\\寝室.csproj"')
const csproj = text('寝室/寝室/寝室.csproj')
expect(csproj).toContain('<RootNamespace>寝室情景模式</RootNamespace>')
expect(csproj).toContain('<AssemblyName>寝室</AssemblyName>')
expect(csproj).toContain('<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>')
// 和教材工程一样只引用直接用到的教材 dll,其他的作为依赖复制到输出目录
expect(csproj).toContain('<HintPath>lib\\DeviceCtrlLibrary.dll</HintPath>')
expect(csproj).toContain('<HintPath>lib\\AxInterop.WMPLib.dll</HintPath>')
expect(csproj).not.toContain('ICS.Models')
expect(files.has('寝室/寝室/lib/ICS.Models.dll')).toBe(true)
expect(csproj).toContain('<Compile Include="Form1.cs">\r\n <SubType>Form</SubType>')
expect(csproj).toContain('<Content Include="Resources\\ring.mp3">\r\n <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>')
expect(csproj).toContain('<None Include="Resources\\switch_on.png" />')
expect(files.has('寝室/寝室/Resources/ring.mp3')).toBe(true)
expect(text('寝室/寝室/Properties/Resources.resx')).toContain('<value>..\\Resources\\switch_on.png;System.Drawing.Bitmap, System.Drawing')
const resources = text('寝室/寝室/Properties/Resources.Designer.cs')
expect(resources).toContain('new global::System.Resources.ResourceManager("寝室情景模式.Properties.Resources"')
expect(resources).not.toContain('Sim.Host')
expect(text('寝室/寝室/Form1.resx')).toContain('<data name="axWindowsMediaPlayer1.OcxState" mimetype="application/x-microsoft.net.object.binary.base64">')
expect(text('寝室/寝室/Form1.cs')).toContain('namespace 寝室情景模式')
expect(files.has('寝室/寝室/LedHelperCompat.cs')).toBe(false)
// 书上的 ledHelper.Close() 教材类库里没有,用到时带上补的方法
const led = unzip(
new Uint8Array(await (await exportRequest(t, 'project', { files: (await loadSample('p2-t3-LED推送模拟量'))!, name: 'LED' })).arrayBuffer()),
)
expect(new TextDecoder().decode(led.get('LED/LED/LedHelperCompat.cs'))).toContain('Close(this global::DeviceCtrlLibrary.LEDHelper helper)')
expect(new TextDecoder().decode(led.get('LED/LED/LED.csproj'))).toContain('<Compile Include="LedHelperCompat.cs" />')
// 机房的 VS 2012 只支持 C# 5
const login = (await loadSample('p1-t1-登录'))!
const newer = login.map((f) =>
f.path === 'Program.cs' ? { ...f, content: f.content.replace('[STAThread]', 'static string S => $"{1}";\n [STAThread]') } : f,
)
const failed = await exportRequest(t, 'project', { files: newer, name: '登录' })
expect(failed.status).toBe(422)
const diagnostics = ((await failed.json()) as any).diagnostics
expect(diagnostics.map((d: any) => d.code)).toEqual(['CS8026', 'CS8026'])
expect(diagnostics[1]).toMatchObject({ file: 'Program.cs', hint: expect.stringContaining('string.Format') })
// 导出 EXE 不受这个限制
expect((await exportExe(t, { files: newer, name: '登录' })).status).toBe(200)
},
60000,
)
test('服务器上没有教材类库时说明原因;格式不对拒绝', async () => {
const t = await teacher()
const saved = config.textbookLibsDir
@@ -783,9 +883,13 @@ describe('导出 EXE', () => {
const response = await exportExe(t, { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(response.status).toBe(503)
expect(((await response.json()) as any).message).toContain('设备类库')
const project = await exportRequest(t, 'project', { files: (await loadSample('p1-t1-登录'))!, name: '登录' })
expect(project.status).toBe(503)
expect(((await project.json()) as any).message).toContain('不能导出工程')
} finally {
config.textbookLibsDir = saved
}
expect((await exportExe(t, { files: 'x' })).status).toBe(400)
expect((await exportRequest(t, 'project', { files: 'x' })).status).toBe(400)
}, 60000)
})