导出 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

+11
View File
@@ -6,6 +6,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
/** 服务器回复里的其他内容(如导出 EXE 时的编译报错) */
readonly data: Record<string, unknown> = {},
) {
super(message)
}
@@ -22,6 +24,15 @@ export async function api<T>(method: string, path: string, json?: unknown, init?
return data
}
/** 回复不是 JSON 的接口(如下载文件):成功时返回 Response,出错时和 api 一样抛出 ApiError */
export async function apiResponse(method: string, path: string, json?: unknown): Promise<Response> {
const response = await send(method, path, json, undefined)
if (response.ok) return response
const data = (await response.json().catch(() => ({}))) as Record<string, unknown> & { message?: string }
if (response.status === 401) apiHooks.onUnauthorized()
throw new ApiError(data.message ?? `请求失败(${response.status})`, response.status, data)
}
/** 发请求并带上访问令牌;令牌被拒绝(401)时刷新一次再重发 */
async function send(method: string, path: string, json: unknown, init: RequestInit | undefined, retried = false): Promise<Response> {
const headers: Record<string, string> = {}
+31
View File
@@ -0,0 +1,31 @@
// 导出 EXE:服务器照教材工程(真机版设备类库)编译,下载一个 exe,在机房电脑上双击就能控制真的实训台。
import type { Diagnostic, SourceFile } from '@iot/shared'
import { apiResponse, ApiError } from '../api'
import { projectNamespace } from '../designer/templates'
/** 下载成功返回 null;代码有错误返回报错(照真机版类库编译,和 VS 里一样);其他问题抛出 ApiError */
export async function downloadExe(files: SourceFile[]): Promise<Diagnostic[] | null> {
let response: Response
try {
response = await apiResponse('POST', '/api/export-exe', { files, name: projectNamespace(files) })
} catch (error) {
if (error instanceof ApiError && error.status === 422 && Array.isArray(error.data.diagnostics)) return error.data.diagnostics as Diagnostic[]
throw error
}
const name = fileName(response.headers.get('Content-Disposition')) ?? `${projectNamespace(files)}.exe`
const url = URL.createObjectURL(await response.blob())
const link = document.createElement('a')
link.href = url
link.download = name
document.body.append(link)
link.click()
link.remove()
setTimeout(() => URL.revokeObjectURL(url), 60_000)
return null
}
/** Content-Disposition 里的中文文件名(filename*=UTF-8''…) */
function fileName(header: string | null) {
const m = header && /filename\*=UTF-8''([^;]+)/i.exec(header)
return m ? decodeURIComponent(m[1]) : null
}
+12
View File
@@ -167,6 +167,18 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<a href="/help-img/db.webp" title="点击放大" @click.prevent="zoom"><img src="/help-img/db.webp" alt="" loading="lazy" /></a>
<figcaption>数据库页面:左边是你的库和表(点表名编辑数据),右边写 SQL、按 F5 执行,下面是结果</figcaption>
</figure>
<h3>8. 导出 EXE,在机房电脑上运行</h3>
<ul>
<li>
点工具栏的<b>导出 EXE</b>,下载一个
exe(名字是工程名,比如“教室情景模式.exe”)。拷到接着实训台的机房电脑上双击运行,选对串口、点打开,就能控制<b>真的</b>设备。
</li>
<li>导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。代码有错误时弹出报错,点它跳到出错的地方。</li>
<li>设备类库已经装在 exe 里,不用另外拷 dll。项目 7 寝室的音乐第一次运行时会放到 exe 旁边的 Resources 文件夹。</li>
<li>项目 6、8 的程序连的是机房电脑上真的 SQL Server,那台电脑上要有书上建的库。</li>
<li>第一次运行时 Windows 可能提示“Windows 已保护你的电脑”:点<b>更多信息</b>→<b>仍要运行</b>。</li>
</ul>
</section>
<section v-if="currentUser?.role === 'teacher'" id="teacher">
+65 -1
View File
@@ -5,7 +5,7 @@
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, type SelectGroupOption } from 'naive-ui'
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
import type { Assignment, Diagnostic, Review, SourceFile, Submission } from '@iot/shared'
import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
@@ -25,6 +25,7 @@ import CodeEditor from '../editor/CodeEditor.vue'
import { checkProject } from '../editor/language'
import { api } from '../api'
import { currentUser, logout } from '../auth'
import { downloadExe } from '../editor/export-exe'
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
import RunArea from '../run/RunArea.vue'
import { createRunSession } from '../run/session'
@@ -478,6 +479,35 @@ async function showDiagnostic(file: string, line: number, column: number) {
editorRef.value?.reveal(file, line, column)
}
// ---------- 导出 EXE:照真机版类库编译,在机房电脑上双击运行 ----------
const exporting = ref(false)
/** 导出时的编译报错(照真机版类库编译,和平台上运行时可能不同) */
const exportErrors = ref<Diagnostic[]>([])
const exportErrorsOpen = ref(false)
async function exportExe() {
if (exporting.value) return
exporting.value = true
try {
const errors = await downloadExe(files.value.map((f) => ({ ...f })))
if (errors) {
exportErrors.value = errors
exportErrorsOpen.value = true
} else {
message.success('已导出,在机房电脑上双击运行(要接真的实训台)')
}
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
} finally {
exporting.value = false
}
}
function jumpToExportError(d: Diagnostic) {
exportErrorsOpen.value = false
void showDiagnostic(d.file, d.line, d.column)
}
function restoreOriginal() {
if (props.assignmentId) void loadAssignment(true)
else if (sampleId.value) void loadSample(sampleId.value, true)
@@ -600,6 +630,9 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
任务
</NButton>
<NButton size="small" :loading="exporting" title="编译成 Windows 程序(.exe),在机房电脑上双击运行,控制真的实训台" @click="exportExe">
导出 EXE
</NButton>
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
<template #trigger>
<NButton quaternary size="small">{{ assignmentId ? '恢复到作业起点' : '恢复原始代码' }}</NButton>
@@ -623,6 +656,15 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
<NButton quaternary size="small" @click="signOut">退出</NButton>
</header>
<NModal v-model:show="exportErrorsOpen" preset="card" title="代码有错误,不能导出" style="width: 640px; max-width: calc(100vw - 32px)">
<p class="export-errors-note">导出时照机房电脑上的设备类库编译,和在 VS 里编译一样。点击报错跳到出错的代码。</p>
<div v-for="(d, i) in exportErrors.slice(0, 20)" :key="i" class="export-error" @click="jumpToExportError(d)">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<p v-if="exportErrors.length > 20" class="export-errors-note">还有 {{ exportErrors.length - 20 }} 个错误,先改上面的,后面的往往跟着就没了。</p>
</NModal>
<NModal
v-if="assignment && submission"
v-model:show="resultOpen"
@@ -794,6 +836,28 @@ onBeforeUnmount(() => clearTimeout(rerunTimer))
color: #dc2626;
}
.export-errors-note {
margin: 0 0 8px;
color: #6b7280;
font-size: 13px;
}
.export-error {
color: #b91c1c;
font-size: 13px;
cursor: pointer;
padding: 2px 0;
}
.export-error:hover {
text-decoration: underline;
}
.export-error .hint {
color: #2563eb;
padding-left: 1em;
}
.rerun-blocked {
color: #b45309;
font-size: 12px;