第 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

+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;
}