第 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:
commit
98be8331bc
71 files changed
+7919
No files matched your search
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './winforms/winforms.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user