兼容机房的 Chrome 94:打包目标改成 Chrome / Edge 94;polyfills.ts 补 crypto.randomUUID(http 打开时没有)、structuredClone(只复制普通数据)、findLast / findLastIndex;vite.config.ts 加小插件,把代码块之间的 import("./X.js") 改成 /assets/ 开头的绝对路径(Chrome 94 有时相对网页地址解析,拿到首页 HTML 一片空白),改过的代码块换新文件名;容器查询改成 ResizeObserver 量宽度加 class(实训台三档布局、设计器窄时隐藏提示);:has() 改成 class(只读、禁用文本框灰底,没标题的分组框)。旧浏览器提示改成 94 以上,设计方案记录实测
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-10-28 12:58:25 +08:00
1 parent ab933d1896
commit 6a18bdf2e4
8 files changed
+130 -41

No files matched your search

+2 -1
View File
@@ -326,13 +326,14 @@ flowchart TB
- **手动备份与恢复**:`deploy/backup.sh` 把平台数据库和每个学生的库(SQLite 一致读取)导出到宿主机的 `backups/<日期时间>/`;`deploy/restore.sh` 先备份现在的数据,再停平台、替换(删掉旧库的 -wal 文件)、启动。本机 Docker 实测备份、改数据、恢复后数据回到备份时的样子。
- **WebSocket 心跳与断线重连**:线上前面的 Nginx Proxy Manager 默认 60 秒没数据就断开 WebSocket(本机用默认配置的 nginx 实测),学生的窗体开着一分钟不操作,程序就被结束、点了没反应。服务器改为 30 秒没收到数据就发 ping(Bun 的 idleTimeout),经 nginx 空闲 150 秒仍连着。连接意外断开时(部署重启、网络抖动),运行区显示"正在重新连接…",清掉已经失效的窗体,按 1、2、4…最长 10 秒的间隔自动重连;连上后提示"刚才的程序已经结束,请重新运行",实训台恢复。
- **登录失败的限制**:原来按 IP 计数、任何人登录成功就清零,学生用自己的账号登录一次就能接着猜同学的 6 位数字密码。改为同一个账号 10 分钟输错 10 次只锁这个账号(同班其他人不受影响),同一 IP 10 分钟 300 次(登录成功不清零)。
- **旧浏览器提示**:网页按 Chrome / Edge 107 以上打包,机房的旧浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
- **旧浏览器提示**:网页按 Chrome / Edge 94 以上打包(机房电脑是 Chrome 94,见 2026-10-28),更旧的浏览器或 360 浏览器的兼容模式会一片空白。index.html 里用老写法的一段脚本:网页程序有语法错误时说明浏览器太旧、怎么切换极速模式,并显示浏览器版本字符串方便老师排查;15 秒还没显示出来时提示正在加载。
- **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板,随后补齐其余 21 份(23 个实验全部有说明)。
- 2026-10-26:**窗体改名**。23 个示例里 11 个的主窗体不叫 Form1(Login、frm_analog、frm_LED、frm_control、Main),学生的空白工程却只有 Form1,以前属性窗口改不了窗体名(项目 8 任务 2 的评分还要找 Main 窗体)。现在选中窗体可以改"名称":和 VS 一样,类名、构造函数、各文件里的 `new X()` 和 X 类型的变量都改(跳过字符串和注释),Designer.cs 里窗体的块头注释和 `this.Name` 也改,两个文件跟着改名;事件处理方法名(如 Form1_Load)和窗体标题不变。改名不进设计器的撤销历史。任务说明里提示先改名再双击窗体生成 Load 事件。
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
## 技术风险与待确认事项
+3 -6
View File
@@ -529,7 +529,7 @@ const noop = () => {}
<div class="actions">
<button :disabled="!canUndo" title="撤销(Ctrl+Z)" @click="emit('undo')">↶ 撤销</button>
<button :disabled="!canRedo" title="重做(Ctrl+Y)" @click="emit('redo')">↷ 重做</button>
<span class="tips" title="拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除"
<span class="tips" :class="{ narrow: canvasWidth <= 560 }" title="拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除"
>拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span
>
<select
@@ -677,7 +677,6 @@ const noop = () => {}
}
.actions {
container-type: inline-size;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -725,10 +724,8 @@ const noop = () => {}
color: #7a838e;
}
@container (max-width: 560px) {
.tips {
visibility: hidden;
}
.tips.narrow {
visibility: hidden;
}
.window {
+1
View File
@@ -1,3 +1,4 @@
import './polyfills'
import { createApp } from 'vue'
import { loadCurrentUser } from './auth'
import Root from './Root.vue'
+47
View File
@@ -0,0 +1,47 @@
// 机房电脑的浏览器是 Chrome 94,补上网页用到、而 Chrome 94 没有的功能。打包目标见 vite.config.ts。
// crypto.randomUUID 只在 https 或 localhost 打开时才有,用 http 地址打开的学生也要能用。
if (typeof crypto.randomUUID !== 'function') {
crypto.randomUUID = function () {
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` as `${string}-${string}-${string}-${string}-${string}`
}
}
// Chrome 98 起才有。网页里只用来复制普通数据(对象、数组、字符串、数字等),这里只实现这些
if (typeof structuredClone !== 'function') {
const clone = (value: unknown): unknown => {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map(clone)
if (value instanceof Date) return new Date(value.getTime())
if (value instanceof Map) return new Map(Array.from(value, ([k, v]) => [clone(k), clone(v)]))
if (value instanceof Set) return new Set(Array.from(value, clone))
const result: Record<string, unknown> = {}
for (const key of Object.keys(value)) result[key] = clone((value as Record<string, unknown>)[key])
return result
}
globalThis.structuredClone = ((value: unknown) => clone(value)) as typeof structuredClone
}
// Chrome 97 起才有
if (!('findLastIndex' in Array.prototype)) {
Object.defineProperty(Array.prototype, 'findLastIndex', {
configurable: true,
writable: true,
value: function <T>(this: T[], predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: unknown) {
for (let i = this.length - 1; i >= 0; i--) if (predicate.call(thisArg, this[i], i, this)) return i
return -1
},
})
Object.defineProperty(Array.prototype, 'findLast', {
configurable: true,
writable: true,
value: function <T>(this: T[], predicate: (value: T, index: number, array: T[]) => unknown, thisArg?: unknown) {
for (let i = this.length - 1; i >= 0; i--) if (predicate.call(thisArg, this[i], i, this)) return this[i]
return undefined
},
})
}
+32 -25
View File
@@ -42,6 +42,16 @@ function setCollapsed(value: boolean) {
}
}
// 布局按实训台自身宽度调整(打开设计器时右栏会变窄)。Chrome 94 没有容器查询,用 ResizeObserver 量宽度
const boardEl = ref<HTMLElement>()
const boardWidth = ref(0)
const boardObserver = new ResizeObserver(([entry]) => (boardWidth.value = entry.contentRect.width))
watch(boardEl, (el) => {
boardObserver.disconnect()
if (el) boardObserver.observe(el)
})
onBeforeUnmount(() => boardObserver.disconnect())
// 拖动滑块时先显示本地值,避免服务器回传前滑块跳动;发送做节流
const dragging = ref<Partial<Record<AnalogKey, number>>>({})
let pending: Partial<Record<AnalogKey, number>> = {}
@@ -168,7 +178,12 @@ const cameraTitle = computed(() =>
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
</div>
<div v-else class="board" :class="{ disconnected: !board }">
<div
v-else
ref="boardEl"
class="board"
:class="{ disconnected: !board, 'board-wide': boardWidth >= 1100, 'board-narrow': boardWidth <= 760, 'board-tiny': boardWidth <= 560 }"
>
<div class="board-header">
<span class="board-name">虚拟实训台</span>
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
@@ -371,8 +386,6 @@ const cameraTitle = computed(() =>
color: var(--text);
font-size: 12px;
user-select: none;
/* 布局按实训台自身宽度调整(打开设计器时右栏会变窄) */
container-type: inline-size;
}
.board-header {
@@ -545,20 +558,16 @@ button {
order: 1;
}
@container (min-width: 1100px) {
.sensors {
grid-template-columns: repeat(8, minmax(0, 1fr));
}
.sensors .wide {
order: 0;
}
.board-wide .sensors {
grid-template-columns: repeat(8, minmax(0, 1fr));
}
@container (max-width: 560px) {
.sensors {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.board-wide .sensors .wide {
order: 0;
}
.board-tiny .sensors {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card {
@@ -770,18 +779,16 @@ button {
}
/* 窄时串口服务器单独占一行,四个串口横排 */
@container (max-width: 760px) {
.modules {
grid-template-columns: 1fr 1fr;
}
.board-narrow .modules {
grid-template-columns: 1fr 1fr;
}
.ports {
grid-column: 1 / -1;
}
.board-narrow .ports {
grid-column: 1 / -1;
}
.ports .port-list {
flex-direction: row;
}
.board-narrow .ports .port-list {
flex-direction: row;
}
.module {
+8 -3
View File
@@ -151,7 +151,12 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- TextBox -->
<div v-else-if="node.type === 'TextBox'" class="wf-control wf-textbox" :style="style">
<div
v-else-if="node.type === 'TextBox'"
class="wf-control wf-textbox"
:class="{ 'wf-readonly': p.ReadOnly === true, 'wf-input-disabled': !enabled }"
:style="style"
>
<textarea
v-if="p.Multiline"
:class="`wf-scrollbars-${p.ScrollBars ?? 'None'}`"
@@ -173,7 +178,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- ComboBox -->
<div v-else-if="node.type === 'ComboBox'" ref="combo" class="wf-control wf-combobox" :style="style">
<div v-else-if="node.type === 'ComboBox'" ref="combo" class="wf-control wf-combobox" :class="{ 'wf-input-disabled': !enabled }" :style="style">
<input
:value="String(p.Text ?? '')"
autocomplete="off"
@@ -269,7 +274,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<!-- GroupBox -->
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :class="{ 'wf-disabled': !enabled }" :style="style">
<!-- 标题处的边框断开、透出底下的背景(教材项目 8 的分组框放在背景图上,白色标题) -->
<fieldset class="wf-groupbox-frame">
<fieldset class="wf-groupbox-frame" :class="{ 'wf-no-caption': !p.Text }">
<legend v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</legend>
</fieldset>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
+4 -4
View File
@@ -139,13 +139,13 @@
background: #fff;
}
.wf-textbox:has(> [readonly]) {
.wf-textbox.wf-readonly {
background: #f0f0f0;
}
/* 禁用时和 WinForms 一样灰字(设了 ForeColor 也是灰字);没设背景色时是灰底 */
.wf-textbox:has(> :disabled),
.wf-combobox:has(> :disabled) {
.wf-textbox.wf-input-disabled,
.wf-combobox.wf-input-disabled {
background: #f0f0f0;
color: #6d6d6d !important;
}
@@ -555,7 +555,7 @@
}
/* 没有标题时边框的上沿和有标题时一样在 6px 处 */
.wf-groupbox-frame:not(:has(legend)) {
.wf-groupbox-frame.wf-no-caption {
top: 6px;
}
+33 -2
View File
@@ -1,8 +1,39 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { defineConfig, type Plugin } from 'vite'
/**
* 打包后的代码块之间按需加载写的是 import("./X.js"),应该相对于代码块自己(/assets/)解析。
* Chrome 94 在 Promise 回调里调用 import() 时(Vite 的预加载就是这样)有时会相对于网页地址解析,
* 去请求 /X.js、拿到首页 HTML,页面一片空白,时好时坏。改成 /assets/X.js 这样的绝对路径就不受影响。
*/
function absoluteDynamicImports(): Plugin {
let prefix = '/assets/'
return {
name: 'absolute-dynamic-imports',
apply: 'build',
configResolved(config) {
prefix = `${config.base}${config.build.assetsDir}/`
},
// 文件名的 hash 在 generateBundle 之前就算好了,这里让改过的代码块换个文件名,浏览器不会用缓存里没改过的旧文件
augmentChunkHash(chunk) {
if (chunk.dynamicImports.length > 0) return 'absolute-dynamic-imports'
},
// 等 Vite 按 import("./X.js") 找出每个代码块要预加载的 CSS 和依赖之后再改
generateBundle: {
order: 'post',
handler(_, bundle) {
for (const chunk of Object.values(bundle)) {
if (chunk.type === 'chunk') chunk.code = chunk.code.replace(/\bimport\((["'])\.\//g, `import($1${prefix}`)
}
},
},
}
}
export default defineConfig({
plugins: [vue()],
plugins: [vue(), absoluteDynamicImports()],
// 机房电脑的浏览器是 Chrome 94:语法和 CSS 按它转换,缺的功能在 src/polyfills.ts 里补
build: { target: ['chrome94', 'edge94'] },
server: {
port: 5173,
proxy: {