兼容机房的 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

+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: {