兼容机房的 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 以上,设计方案记录实测
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab933d1896
commit
6a18bdf2e4
8 files changed
+130
-41
No files matched your search
@@ -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,3 +1,4 @@
|
||||
import './polyfills'
|
||||
import { createApp } from 'vue'
|
||||
import { loadCurrentUser } from './auth'
|
||||
import Root from './Root.vue'
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user