导出 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 同步
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46e0f40cce
commit
5e5d29b2aa
19 files changed
+601
-10
No files matched your search
@@ -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> = {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user