第 0 阶段:教材项目 2 任务 1 在网页中原样运行

- runtime:仿真 WinForms 子集(Form、Button、Label、TextBox、ComboBox、
  GroupBox、Timer、MessageBox、界面线程消息循环、跨线程检查)、
  教材设备类库(DeviceCtrlHelper、LED 控件)、运行器(常驻编译服务 + 运行模式)
- server:Bun + Hono,WebSocket 会话,每次运行一个进程,消息转发
- web:Vue + Naive UI,WinForms 风格窗体渲染、MessageBox、实训台 LED 屏
- 端到端测试与 100 个程序并发的压力测试
- 教材逐任务摘要(docs/教材摘要)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:15:14 +08:00
commit 98be8331bc
71 files changed
+7919

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>物联网编程仿真平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@iot/web",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --bun vite",
"build": "vue-tsc --noEmit && bun --bun vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@iot/shared": "workspace:*",
"naive-ui": "^2.41.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/bun": "^1.2.0",
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "^5.8.0",
"vite": "^7.0.0",
"vue-tsc": "^3.0.0"
}
}
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
// 第 0 阶段的验证页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体。
import { computed, onMounted, ref } from 'vue'
import { NButton, NConfigProvider, NSelect, NTabPane, NTabs, NTag, zhCN } from 'naive-ui'
import type { SourceFile } from '@iot/shared'
import { createRunSession } from './run/session'
import TrainingBoard from './scene/TrainingBoard.vue'
import FormWindow from './winforms/FormWindow.vue'
import MessageBoxDialog from './winforms/MessageBoxDialog.vue'
const session = createRunSession()
const { state } = session
const samples = ref<{ label: string; value: string }[]>([])
const sampleId = ref<string>()
const files = ref<SourceFile[]>([])
const activeFile = ref<string>()
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
const running = computed(() => state.status === 'compiling' || state.status === 'running')
onMounted(async () => {
const list: { id: string; title: string }[] = await (await fetch('/api/samples')).json()
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
if (list[0]) await loadSample(list[0].id)
})
async function loadSample(id: string) {
sampleId.value = id
files.value = await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json()
activeFile.value = files.value.find((f) => f.path.endsWith('Form1.cs'))?.path ?? files.value[0]?.path
}
function updateFile(path: string, content: string) {
const file = files.value.find((f) => f.path === path)
if (file) file.content = content
}
function run() {
session.run(files.value.map((f) => ({ ...f })))
}
</script>
<template>
<NConfigProvider :locale="zhCN">
<div class="page">
<header class="toolbar">
<strong>物联网编程仿真平台 · 第 0 阶段</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
<NButton type="primary" :disabled="running" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" @click="session.stop">■ 停止</NButton>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
</span>
</header>
<main class="workspace">
<section class="editor">
<NTabs v-model:value="activeFile" type="card" size="small">
<NTabPane v-for="file in files" :key="file.path" :name="file.path" :tab="file.path">
<textarea
class="code"
spellcheck="false"
:value="file.content"
@input="updateFile(file.path, ($event.target as HTMLTextAreaElement).value)"
/>
</NTabPane>
</NTabs>
</section>
<section class="run-area">
<div class="desktop">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
:form="form.root"
:modal="form.modal"
:offset="index"
:act="session.act"
/>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
</div>
<TrainingBoard :led-text="state.devices.ledText" />
<div class="output">
<div v-for="(d, i) in state.diagnostics" :key="`d${i}`" :class="['diag', d.severity]">
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
</div>
<pre v-for="(e, i) in state.errors" :key="`e${i}`" class="error">{{ e }}</pre>
<pre v-if="state.console" class="console">{{ state.console }}</pre>
</div>
</section>
</main>
</div>
</NConfigProvider>
</template>
<style>
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
</style>
<style scoped>
.page {
display: flex;
flex-direction: column;
height: 100vh;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid #e5e7eb;
}
.sample-select {
width: 240px;
}
.metrics {
margin-left: auto;
color: #6b7280;
font-size: 12px;
}
.workspace {
flex: 1;
display: grid;
grid-template-columns: minmax(360px, 2fr) 3fr;
min-height: 0;
}
.editor {
padding: 8px;
border-right: 1px solid #e5e7eb;
overflow: hidden;
}
.code {
width: 100%;
height: calc(100vh - 120px);
box-sizing: border-box;
font-family: "JetBrains Mono", "Cascadia Code", Consolas, "Noto Sans Mono CJK SC", monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 4;
/* 关闭连字:避免 >= 显示成 ≥,学生照着输入时出错 */
font-variant-ligatures: none;
border: 1px solid #e5e7eb;
padding: 8px;
resize: none;
}
.run-area {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
min-width: 0;
overflow: auto;
}
.desktop {
position: relative;
flex: 1;
min-height: 380px;
background: #2d6aa3;
border-radius: 4px;
/* 窗体比桌面区域大时可以滚动查看 */
overflow: auto;
}
.desktop-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.7);
}
.output {
max-height: 180px;
overflow: auto;
font-size: 12px;
}
.diag.error,
.error {
color: #b91c1c;
}
.diag.warning {
color: #a16207;
}
.hint {
color: #2563eb;
padding-left: 1em;
}
.console {
background: #111827;
color: #e5e7eb;
padding: 6px;
margin: 0;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './winforms/winforms.css'
createApp(App).mount('#app')
+170
View File
@@ -0,0 +1,170 @@
import { reactive, shallowReactive } from 'vue'
import type {
ClientMessage,
ControlNode,
Diagnostic,
RunnerInput,
ServerMessage,
SourceFile,
UserAction,
} from '@iot/shared'
export interface OpenForm {
root: ControlNode
modal: boolean
}
export interface PendingMessageBox {
requestId: number
text: string
caption: string
buttons: string
icon: string
}
/** 浏览器端的运行状态:WebSocket 连接、打开的窗体、控件树、实训台设备状态 */
export function createRunSession() {
const state = reactive({
status: 'idle' as 'idle' | 'compiling' | 'running' | 'stopped',
diagnostics: [] as Diagnostic[],
console: '',
errors: [] as string[],
forms: [] as OpenForm[],
messageBoxes: [] as PendingMessageBox[],
devices: { ledText: '' },
metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 },
})
/** id → 控件节点,patch 按 id 修改节点属性 */
const nodes = shallowReactive(new Map<string, ControlNode>())
let socket: WebSocket | undefined
let actionSentAt = 0
function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
socket = new WebSocket(`${protocol}://${location.host}/ws`)
socket.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage)
socket.onclose = () => {
state.status = 'stopped'
socket = undefined
}
return new Promise<void>((resolve) => socket!.addEventListener('open', () => resolve(), { once: true }))
}
function send(message: ClientMessage) {
socket?.send(JSON.stringify(message))
}
async function run(files: SourceFile[]) {
if (!socket) await connect()
state.diagnostics = []
state.console = ''
state.errors = []
state.forms = []
state.messageBoxes = []
state.devices.ledText = ''
nodes.clear()
send({ type: 'run', files })
}
function stop() {
send({ type: 'stop' })
}
function act(id: string, action: UserAction) {
actionSentAt = performance.now()
send({ type: 'action', id, ...action } as RunnerInput)
}
function replyMessageBox(requestId: number, result: string) {
state.messageBoxes = state.messageBoxes.filter((box) => box.requestId !== requestId)
send({ type: 'reply', requestId, result })
}
function register(node: ControlNode) {
nodes.set(node.id, node)
for (const child of node.children) register(child)
}
function unregister(node: ControlNode) {
nodes.delete(node.id)
for (const child of node.children) unregister(child)
}
function handle(message: ServerMessage) {
if (actionSentAt && message.type !== 'status') {
state.metrics.lastRoundTripMs = Math.round(performance.now() - actionSentAt)
actionSentAt = 0
}
switch (message.type) {
case 'status':
state.status = message.state
break
case 'compiled':
state.diagnostics = message.diagnostics
state.metrics.compileMs = message.ms
break
case 'started':
state.metrics.startMs = message.ms
break
case 'formShown': {
const root = reactive(message.form) as ControlNode
register(root)
state.forms.push({ root, modal: message.modal })
break
}
case 'formClosed': {
const form = state.forms.find((f) => f.root.id === message.formId)
if (form) unregister(form.root)
state.forms = state.forms.filter((f) => f.root.id !== message.formId)
break
}
case 'patch':
for (const change of message.changes) {
const node = nodes.get(change.id)
if (node) node.props[change.prop] = change.value
}
break
case 'controlAdded': {
const parent = nodes.get(message.parentId)
if (!parent) break
const node = reactive(message.node) as ControlNode
parent.children.splice(message.index, 0, node)
register(node)
break
}
case 'controlRemoved': {
const node = nodes.get(message.id)
if (!node) break
for (const parent of nodes.values()) {
const index = parent.children.indexOf(node)
if (index >= 0) parent.children.splice(index, 1)
}
unregister(node)
break
}
case 'messageBox':
state.messageBoxes.push(message)
break
case 'device':
if (message.device === 'led' && message.action === 'text') state.devices.ledText = String(message.data)
break
case 'console':
state.console += message.text
break
case 'exception':
state.errors.push(`${message.message}\n${message.detail}`)
break
case 'serverError':
state.errors.push(message.message)
break
case 'exited':
state.forms = []
state.messageBoxes = []
break
}
}
return { state, run, stop, act, replyMessageBox }
}
export type RunSession = ReturnType<typeof createRunSession>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
// 虚拟实训台。第 0 阶段只有 LED 显示屏,其余设备按布局图逐步加入。
defineProps<{ ledText: string }>()
</script>
<template>
<div class="board">
<div class="board-label">LED 显示屏(COM5)</div>
<div class="led-screen">
<span v-if="ledText" :key="ledText" class="led-screen-text">{{ ledText }}</span>
</div>
</div>
</template>
<style scoped>
.board {
padding: 12px;
background: #3b4252;
border-radius: 6px;
}
.board-label {
margin-bottom: 6px;
color: #d8dee9;
font-size: 12px;
}
.led-screen {
position: relative;
height: 56px;
overflow: hidden;
background:
radial-gradient(circle, rgb(255 255 255 / 0.06) 1px, transparent 1.5px) 0 0 / 4px 4px,
#0b0b0b;
border: 3px solid #222;
}
.led-screen-text {
position: absolute;
top: 8px;
white-space: pre;
color: #ff2a2a;
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
font-size: 34px;
line-height: 40px;
animation: led-scroll 12s linear infinite;
}
@keyframes led-scroll {
from {
left: 100%;
transform: translateX(0);
}
to {
left: 0;
transform: translateX(-100%);
}
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
// 按控件类型渲染一个 WinForms 控件及其子控件。
// 用户操作只通过 act() 上报,由运行器按真实 WinForms 的顺序触发事件。
import { computed, ref } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import { boxCss, displayText } from './format'
import LedControl from './LedControl.vue'
const props = defineProps<{
node: ControlNode
act: (id: string, action: UserAction) => void
}>()
const p = computed(() => props.node.props)
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染(后渲染的 DOM 元素在上面)
const childrenTopLast = computed(() => [...props.node.children].reverse())
const enabled = computed(() => p.value.Enabled !== false)
const style = computed(() => boxCss(p.value, props.node.type === 'Label' && p.value.AutoSize === true))
function act(action: UserAction) {
if (enabled.value) props.act(props.node.id, action)
}
// TextBox
const passwordChar = computed(() => {
if (p.value.UseSystemPasswordChar) return '●'
const ch = p.value.PasswordChar as string | undefined
return ch && ch !== '\0' ? ch : ''
})
// ComboBox
const dropDownOpen = ref(false)
const items = computed(() => (p.value.Items as string[] | undefined) ?? [])
const editable = computed(() => p.value.DropDownStyle !== 'DropDownList')
function openDropDown() {
if (!enabled.value) return
if (dropDownOpen.value) {
dropDownOpen.value = false
return
}
// 先触发 Click(教材在 Click 里填充串口列表),列表随后按最新的 Items 显示
act({ action: 'dropDown' })
dropDownOpen.value = true
}
function selectItem(index: number) {
dropDownOpen.value = false
act({ action: 'select', index })
}
</script>
<template>
<!-- Button -->
<button
v-if="node.type === 'Button'"
class="wf-control wf-button"
:style="style"
:disabled="!enabled"
@click="act({ action: 'click' })"
>
{{ displayText(p.Text) }}
</button>
<!-- Label -->
<div
v-else-if="node.type === 'Label'"
class="wf-control wf-label"
:class="{ 'wf-autosize': p.AutoSize === true, 'wf-disabled': !enabled }"
:style="style"
@click="act({ action: 'click' })"
>
{{ displayText(p.Text) }}
</div>
<!-- TextBox -->
<div v-else-if="node.type === 'TextBox'" class="wf-control wf-textbox" :style="style">
<textarea
v-if="p.Multiline"
:value="String(p.Text ?? '')"
:readonly="p.ReadOnly === true"
:disabled="!enabled"
@input="act({ action: 'text', value: ($event.target as HTMLTextAreaElement).value })"
/>
<input
v-else
:type="passwordChar ? 'password' : 'text'"
:value="String(p.Text ?? '')"
:readonly="p.ReadOnly === true"
:disabled="!enabled"
@input="act({ action: 'text', value: ($event.target as HTMLInputElement).value })"
/>
</div>
<!-- ComboBox -->
<div v-else-if="node.type === 'ComboBox'" class="wf-control wf-combobox" :style="style">
<input
:value="String(p.Text ?? '')"
:readonly="!editable"
:disabled="!enabled"
@mousedown="!editable && openDropDown()"
@input="act({ action: 'text', value: ($event.target as HTMLInputElement).value })"
/>
<span class="wf-combobox-arrow" @mousedown.prevent="openDropDown">▾</span>
<ul v-if="dropDownOpen" class="wf-combobox-list">
<li v-for="(item, index) in items" :key="index" @mousedown.prevent="selectItem(index)">{{ item }}</li>
<li v-if="items.length === 0" class="wf-combobox-empty">&nbsp;</li>
</ul>
</div>
<!-- GroupBox -->
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
<div class="wf-groupbox-frame" />
<span v-if="p.Text" class="wf-groupbox-caption">{{ displayText(p.Text) }}</span>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
<!-- 教材控件库:LED 显示屏 -->
<LedControl
v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'"
:node="node"
:style="style"
/>
<!-- 其他容器(Panel、UserControl 等):按普通容器渲染 -->
<div v-else class="wf-control wf-panel" :style="style" :title="node.type">
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="props.act" />
</div>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
// 一个 WinForms 窗体:标题栏 + 客户区。窗体可以拖动标题栏移动。
import { computed, ref } from 'vue'
import type { ControlNode, UserAction } from '@iot/shared'
import ControlView from './ControlView.vue'
import { displayText, fontCss, type FontValue, type SizeValue } from './format'
const props = defineProps<{
form: ControlNode
modal: boolean
offset: number
act: (id: string, action: UserAction) => void
}>()
const p = computed(() => props.form.props)
const borderless = computed(() => p.value.FormBorderStyle === 'None')
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
const childrenTopLast = computed(() => [...props.form.children].reverse())
const clientSize = computed(() => (p.value.ClientSize as SizeValue | undefined) ?? { width: 300, height: 300 })
const position = ref({ x: 24 + props.offset * 32, y: 24 + props.offset * 32 })
let dragStart: { x: number; y: number; px: number; py: number } | undefined
function startDrag(event: PointerEvent) {
dragStart = { x: event.clientX, y: event.clientY, px: position.value.x, py: position.value.y }
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function drag(event: PointerEvent) {
if (!dragStart) return
position.value = { x: dragStart.px + event.clientX - dragStart.x, y: dragStart.py + event.clientY - dragStart.y }
}
function close() {
props.act(props.form.id, { action: 'close' })
}
</script>
<template>
<div
v-show="p.Visible !== false"
class="wf-window"
:class="{ 'wf-window-modal': modal }"
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
>
<div v-if="!borderless" class="wf-titlebar" @pointerdown="startDrag" @pointermove="drag" @pointerup="dragStart = undefined">
<span class="wf-title">{{ displayText(p.Text) }}</span>
<span v-if="p.MinimizeBox !== false" class="wf-titlebar-button">─</span>
<span v-if="p.MaximizeBox !== false" class="wf-titlebar-button">☐</span>
<span class="wf-titlebar-button wf-titlebar-close" @pointerdown.stop @click="close">✕</span>
</div>
<div
class="wf-client"
:style="{
width: `${clientSize.width}px`,
height: `${clientSize.height}px`,
color: (p.ForeColor as string | null) ?? undefined,
backgroundColor: (p.BackColor as string | null) ?? undefined,
...fontCss(p.Font as FontValue | null),
}"
>
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="act" />
</div>
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
// 教材控件库里的 LED 控件:黑底红字,文字从右向左滚动。
// 教材原控件每 40ms 左移 2px(约 50px/s),这里用 CSS 动画按相同速度滚动。
import { computed, nextTick, ref, watch } from 'vue'
import type { ControlNode } from '@iot/shared'
const props = defineProps<{ node: ControlNode }>()
const SPEED_PX_PER_SEC = 50
const text = computed(() => String(props.node.props.LedText ?? ''))
const textEl = ref<HTMLElement>()
const containerEl = ref<HTMLElement>()
const scroll = ref({ from: 0, to: 0, seconds: 1 })
const runKey = ref(0)
watch(
text,
async () => {
await nextTick()
const from = containerEl.value?.clientWidth ?? 800
const to = -(textEl.value?.scrollWidth ?? 0)
scroll.value = { from, to, seconds: Math.max((from - to) / SPEED_PX_PER_SEC, 1) }
runKey.value++ // 文字变化时从右侧重新开始滚动
},
{ immediate: true },
)
</script>
<template>
<div ref="containerEl" class="wf-control wf-led">
<span
v-if="text"
ref="textEl"
:key="runKey"
class="wf-led-text"
:style="{
'--wf-led-from': `${scroll.from}px`,
'--wf-led-to': `${scroll.to}px`,
animationDuration: `${scroll.seconds}s`,
}"
>{{ text }}</span
>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
// MessageBox.Show 弹出的对话框。按钮结果回传给运行器,学生代码里的 Show() 才会返回。
import { computed } from 'vue'
import type { PendingMessageBox } from '../run/session'
const props = defineProps<{ box: PendingMessageBox }>()
const emit = defineEmits<{ reply: [result: string] }>()
const BUTTONS: Record<string, [string, string][]> = {
OK: [['OK', '确定']],
OKCancel: [['OK', '确定'], ['Cancel', '取消']],
YesNo: [['Yes', '是(Y)'], ['No', '否(N)']],
YesNoCancel: [['Yes', '是(Y)'], ['No', '否(N)'], ['Cancel', '取消']],
RetryCancel: [['Retry', '重试(R)'], ['Cancel', '取消']],
AbortRetryIgnore: [['Abort', '中止(A)'], ['Retry', '重试(R)'], ['Ignore', '忽略(I)']],
}
const ICONS: Record<string, string> = { Error: '⛔', Hand: '⛔', Stop: '⛔', Warning: '⚠️', Exclamation: '⚠️', Information: 'ℹ️', Asterisk: 'ℹ️', Question: '❓' }
const buttons = computed(() => BUTTONS[props.box.buttons] ?? BUTTONS.OK)
const icon = computed(() => ICONS[props.box.icon])
</script>
<template>
<div class="wf-modal-backdrop">
<div class="wf-window wf-messagebox">
<div class="wf-titlebar">
<span class="wf-title">{{ box.caption }}</span>
<span class="wf-titlebar-button wf-titlebar-close" @click="emit('reply', 'Cancel')">✕</span>
</div>
<div class="wf-messagebox-body">
<span v-if="icon" class="wf-messagebox-icon">{{ icon }}</span>
<span class="wf-messagebox-text">{{ box.text }}</span>
</div>
<div class="wf-messagebox-buttons">
<button v-for="[result, label] in buttons" :key="result" class="wf-button" @click="emit('reply', result)">
{{ label }}
</button>
</div>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
// 把运行器发来的 WinForms 属性值转换成 CSS。
// 运行器的序列化格式:Point → {x,y},Size → {width,height},
// Color → CSS 颜色字符串(未设置时为 null,表示继承父控件),Font → FontValue。
import type { CSSProperties } from 'vue'
export interface PointValue {
x: number
y: number
}
export interface SizeValue {
width: number
height: number
}
export interface FontValue {
family: string
size: number
bold: boolean
italic: boolean
underline: boolean
strikeout: boolean
}
/** Windows 中文字体在网页上的替代字体 */
const FONT_FALLBACKS: Record<string, string> = {
宋体: '"SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif',
SimSun: '"SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif',
黑体: '"SimHei", "Heiti SC", "Noto Sans CJK SC", "Noto Sans SC", sans-serif',
楷体: '"KaiTi", "Kaiti SC", "AR PL UKai CN", serif',
微软雅黑: '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", sans-serif',
'Microsoft YaHei': '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", sans-serif',
}
export function fontCss(font: FontValue | null | undefined): CSSProperties {
if (!font) return {}
const decorations = [font.underline && 'underline', font.strikeout && 'line-through'].filter(Boolean)
return {
fontFamily: FONT_FALLBACKS[font.family] ?? `"${font.family}", ${FONT_FALLBACKS.宋体}`,
// WinForms 字号单位是磅,96 DPI 下 1pt = 4/3 px
fontSize: `${(font.size * 4) / 3}px`,
fontWeight: font.bold ? 'bold' : undefined,
fontStyle: font.italic ? 'italic' : undefined,
textDecoration: decorations.length ? decorations.join(' ') : undefined,
}
}
/** 控件外框:绝对定位,坐标相对父控件的客户区 */
export function boxCss(props: Record<string, unknown>, autoSize = false): CSSProperties {
const location = (props.Location as PointValue | undefined) ?? { x: 0, y: 0 }
const size = props.Size as SizeValue | undefined
return {
left: `${location.x}px`,
top: `${location.y}px`,
width: autoSize || !size ? undefined : `${size.width}px`,
height: autoSize || !size ? undefined : `${size.height}px`,
color: (props.ForeColor as string | null) ?? undefined,
backgroundColor: (props.BackColor as string | null) ?? undefined,
display: props.Visible === false ? 'none' : undefined,
...fontCss(props.Font as FontValue | null),
}
}
/** 去掉助记符:WinForms 里 "用户名称(&U)" 显示为 "用户名称(U)","&&" 显示为 "&" */
export function displayText(text: unknown): string {
return String(text ?? '').replace(/&(&?)/g, (_, amp) => amp)
}
+238
View File
@@ -0,0 +1,238 @@
/* 仿 Windows 10 经典 WinForms 外观。默认字体对应中文 Windows 上的宋体 9pt。 */
.wf-window {
position: absolute;
background: #f0f0f0;
border: 1px solid #1883d7;
box-shadow: 0 6px 24px rgb(0 0 0 / 0.25);
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-size: 12px;
color: #000;
user-select: none;
}
.wf-window-modal {
z-index: 10;
}
.wf-titlebar {
display: flex;
align-items: center;
height: 30px;
background: #fff;
cursor: default;
}
.wf-title {
flex: 1;
padding-left: 8px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
}
.wf-titlebar-button {
width: 46px;
line-height: 30px;
text-align: center;
font-size: 11px;
color: #333;
}
.wf-titlebar-close:hover {
background: #e81123;
color: #fff;
}
.wf-client {
position: relative;
overflow: hidden;
}
.wf-control {
position: absolute;
box-sizing: border-box;
font: inherit;
color: inherit;
}
.wf-button {
background: #e1e1e1;
border: 1px solid #adadad;
padding: 0;
cursor: default;
}
.wf-button:hover:not(:disabled) {
background: #e5f1fb;
border-color: #0078d7;
}
.wf-button:active:not(:disabled) {
background: #cce4f7;
border-color: #005499;
}
.wf-button:disabled,
.wf-disabled {
color: #a0a0a0;
}
.wf-label {
white-space: pre;
overflow: hidden;
}
.wf-textbox input,
.wf-textbox textarea,
.wf-combobox input {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid #7a7a7a;
background: #fff;
font: inherit;
color: inherit;
padding: 1px 2px;
outline: none;
resize: none;
}
.wf-textbox input:focus,
.wf-textbox textarea:focus,
.wf-combobox input:focus {
border-color: #0078d7;
}
.wf-textbox input[readonly],
.wf-textbox textarea[readonly] {
background: #f0f0f0;
}
.wf-combobox input {
padding-right: 18px;
}
.wf-combobox-arrow {
position: absolute;
right: 1px;
top: 1px;
bottom: 1px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
background: #fff;
}
.wf-combobox-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 5;
margin: 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #646464;
max-height: 160px;
overflow-y: auto;
}
.wf-combobox-list li {
padding: 1px 3px;
}
.wf-combobox-list li:hover {
background: #0078d7;
color: #fff;
}
.wf-groupbox-frame {
position: absolute;
inset: 6px 0 0 0;
border: 1px solid #dcdcdc;
border-radius: 2px;
}
.wf-groupbox-caption {
position: absolute;
left: 6px;
top: 0;
padding: 0 2px;
background: #f0f0f0;
line-height: 12px;
white-space: pre;
}
.wf-led {
overflow: hidden;
background: #000;
color: red;
}
.wf-led-text {
position: absolute;
top: 20px;
left: 0;
line-height: 1;
white-space: pre;
animation: wf-led-scroll linear infinite;
}
@keyframes wf-led-scroll {
from {
transform: translateX(var(--wf-led-from));
}
to {
transform: translateX(var(--wf-led-to));
}
}
.wf-modal-backdrop {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 0.08);
}
.wf-messagebox {
position: static;
min-width: 240px;
max-width: 480px;
}
.wf-messagebox-body {
display: flex;
gap: 12px;
align-items: center;
padding: 24px 28px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
}
.wf-messagebox-icon {
font-size: 28px;
}
.wf-messagebox-text {
white-space: pre-wrap;
}
.wf-messagebox-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 12px;
}
.wf-messagebox-buttons .wf-button {
min-width: 75px;
height: 23px;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../shared"]
}
+13
View File
@@ -0,0 +1,13 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000',
'/ws': { target: 'ws://localhost:3000', ws: true },
},
},
})