Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5bec7c8af | ||
|
|
697f31e9ce | ||
|
|
081ac6f924 | ||
|
|
4c53caf7fa | ||
|
|
8feda289de | ||
|
|
02d2f0eabc | ||
|
|
ea5a25af06 | ||
|
|
03e80ce2e2 | ||
|
|
fbb8a334c3 | ||
|
|
942c319bab |
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# 只把构建需要的源码送进 Docker
|
||||
**/node_modules
|
||||
**/bin
|
||||
**/obj
|
||||
**/dist
|
||||
.runs
|
||||
.git
|
||||
docs
|
||||
*.pdf
|
||||
.env
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env 后修改。.env 不进 git。
|
||||
# 学生进入平台要输入的口令,换口令后所有人要重新输入
|
||||
ACCESS_PASSWORD=请改成不容易猜到的口令
|
||||
# 平台最多使用的内存,默认 4g
|
||||
# APP_MEM_LIMIT=4g
|
||||
@@ -16,3 +16,6 @@ obj/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# 部署配置(含访问口令)
|
||||
.env
|
||||
@@ -0,0 +1,46 @@
|
||||
# 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。
|
||||
# 构建:docker compose build;部署说明见 deploy/README.md
|
||||
|
||||
# ---- C# 运行时(编译服务 + 仿真库) ----
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build
|
||||
WORKDIR /src
|
||||
COPY runtime/ runtime/
|
||||
RUN dotnet build runtime/Runtime.slnx -c Release
|
||||
|
||||
# ---- 网页和服务端 ----
|
||||
FROM oven/bun:1.4.3 AS web-build
|
||||
WORKDIR /src
|
||||
COPY package.json bun.lock ./
|
||||
COPY shared/package.json shared/
|
||||
COPY server/package.json server/
|
||||
COPY web/package.json web/
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY shared/ shared/
|
||||
COPY server/ server/
|
||||
COPY web/ web/
|
||||
RUN cd web && bun run build
|
||||
# 服务端打包成单个文件,运行镜像里不需要 node_modules
|
||||
RUN bun build server/src/index.ts --target=bun --outfile=server.js
|
||||
|
||||
# ---- 运行镜像 ----
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends bubblewrap tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=oven/bun:1.4.3 /usr/local/bin/bun /usr/local/bin/bun
|
||||
WORKDIR /app
|
||||
COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/
|
||||
COPY --from=web-build /src/web/dist/ web/dist/
|
||||
COPY --from=web-build /src/server.js ./
|
||||
COPY samples/ samples/
|
||||
ENV APP_ROOT=/app \
|
||||
RUNNER_DLL=runtime/Sim.Runner.dll \
|
||||
RUNS_DIR=/tmp/runs \
|
||||
SIM_SANDBOX=bwrap \
|
||||
TZ=Asia/Shanghai \
|
||||
PORT=3000 \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
# 镜像自带的非 root 用户
|
||||
USER $APP_UID
|
||||
EXPOSE 3000
|
||||
CMD ["bun", "server.js"]
|
||||
@@ -1,7 +1,7 @@
|
||||
# 物联网编程仿真平台
|
||||
|
||||
面向中职《物联网编程与应用(第 2 版)》的在线仿真平台:学生在浏览器里写 C# WinForms 程序,
|
||||
程序在服务器上运行,界面和虚拟实训台的效果在网页上实时显示。设计方案见 Claude Docs 中的《物联网 C# 虚拟仿真教学平台设计方案》。
|
||||
程序在服务器上运行,界面和虚拟实训台的效果在网页上实时显示。设计方案见 [docs/设计方案.md](docs/设计方案.md)。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、运行器(`Sim.Runner`) |
|
||||
| `samples/` | 示例工程(按教材任务整理) |
|
||||
| `docs/教材摘要/` | 教材逐任务摘要:控件属性表、扩展题原文、评价表 |
|
||||
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
|
||||
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
|
||||
|
||||
## 本地开发
|
||||
|
||||
需要 Bun 和 .NET 10 SDK。
|
||||
需要 Bun 和 .NET 10 SDK;装了 bubblewrap(`bwrap`)时,学生程序在本机也会放进沙箱运行,和服务器一致。
|
||||
|
||||
```bash
|
||||
bun install # 安装前端和后端依赖
|
||||
@@ -30,9 +31,14 @@ bun run dev:web # 启动前端(端口 5173),浏览器打开 ht
|
||||
```bash
|
||||
cd server
|
||||
bun test # 端到端测试:编译并运行示例,模拟浏览器操作
|
||||
cd ../web && bun test src # 窗体设计器:Designer.cs 读写、编辑操作、插入事件处理方法
|
||||
bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。在开发机执行 `deploy/deploy.sh 用户@服务器`,详见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 运行器的两种模式
|
||||
|
||||
`Sim.Runner` 通过标准输入输出与 Bun 服务器交换 JSON 消息(每行一条):
|
||||
@@ -60,9 +66,12 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序
|
||||
|
||||
- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。
|
||||
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。
|
||||
- 示例工程(按书上代码整理):项目 2 任务 1–3、项目 3 任务 1–2。网址带 `?sample=示例编号` 可直接打开指定示例。
|
||||
- 示例工程(按书上代码整理):项目 1 任务 1–2、项目 2 任务 1–3、项目 3 任务 1–2、项目 4 任务 1 和 3、项目 5 任务 1,共 10 个。网址带 `?sample=示例编号` 可直接打开指定示例。
|
||||
- 代码编辑器(Monaco):C# 高亮、编译错误波浪线、点错误跳到对应行、F5 运行 / Shift+F5 停止、草稿自动保存在浏览器本地。
|
||||
- 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。
|
||||
- 多窗体:新建、删除窗体;运行时多个窗口可切换前后。
|
||||
- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、访问口令、Docker Compose 部署脚本。
|
||||
|
||||
尚未完成:
|
||||
|
||||
- 学生代码目前直接在本机进程里运行,**还没有沙箱**,只能在本机开发时使用;部署前要改为在 Docker 容器中运行。
|
||||
- 浏览器内的窗体设计器、代码编辑器(Monaco)、账号和班级、自动评测。
|
||||
- 教材项目 4–8 的其余任务(需要摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)、账号和班级、自动评测。
|
||||
@@ -23,6 +23,7 @@
|
||||
"name": "@iot/web",
|
||||
"dependencies": {
|
||||
"@iot/shared": "workspace:*",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"vue": "^3.5.0",
|
||||
},
|
||||
@@ -36,71 +37,71 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.9", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.9.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
"@babel/types": ["@babel/types@7.29.8", "https://registry.npmjs.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||
"@css-render/plugin-bem": ["@css-render/plugin-bem@0.15.14", "https://registry.npmjs.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", { "peerDependencies": { "css-render": "~0.15.14" } }, "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg=="],
|
||||
|
||||
"@css-render/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="],
|
||||
"@css-render/vue3-ssr": ["@css-render/vue3-ssr@0.15.14", "https://registry.npmjs.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", { "peerDependencies": { "vue": "^3.0.11" } }, "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g=="],
|
||||
|
||||
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||
"@emotion/hash": ["@emotion/hash@0.8.0", "https://registry.npmjs.com/@emotion/hash/-/hash-0.8.0.tgz", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "https://registry.npmjs.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "https://registry.npmjs.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "https://registry.npmjs.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "https://registry.npmjs.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "https://registry.npmjs.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "https://registry.npmjs.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "https://registry.npmjs.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "https://registry.npmjs.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "https://registry.npmjs.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "https://registry.npmjs.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "https://registry.npmjs.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "https://registry.npmjs.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "https://registry.npmjs.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "https://registry.npmjs.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
|
||||
"@iot/server": ["@iot/server@workspace:server"],
|
||||
|
||||
@@ -108,182 +109,190 @@
|
||||
|
||||
"@iot/web": ["@iot/web@workspace:web"],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||
"@juggle/resize-observer": ["@juggle/resize-observer@3.4.0", "https://registry.npmjs.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", {}, "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "https://registry.npmjs.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "https://registry.npmjs.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.12", "", { "os": "android", "cpu": "arm" }, "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw=="],
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.64.12", "https://registry.npmjs.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.12.tgz", { "os": "android", "cpu": "arm" }, "sha512-Z8lz+5UOiCJLonjeqwJlhUtHA/Sr6R1AidkccQxdWF0nigNzP81aLoU+udgOaGfL5s1uzFXzcux/pCwGAMu0lw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.12", "", { "os": "android", "cpu": "arm64" }, "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg=="],
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.12.tgz", { "os": "android", "cpu": "arm64" }, "sha512-+tKxNa2psFmiTb0KfXlXs4Yj7vkEhzAvZ0DrQ5+0gWHQJM10jSleQ3l1FIVm5uvD53YM8lHfM4mgCLDDN9f1Gg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ=="],
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.12.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-iiripdfvyRBugV55/Dk7GplNXQtN/PG85OcKeveU1z2UkTg0MofbBF1qk//qgPWiqJbVvQcXAVCfE56oDGO4EQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw=="],
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.12.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-BmATib8swSMAagWNIfDZMnb+k34RXR4bbGOUfZgK8lLlniuMyVoSDDXCOGkdhtUmBXL4aygDh7bhjQ+TmhTvIw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ=="],
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.12.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-GzrO0CiUq8vcAI/hYJuIcp6wNm3T3YhOpumYtf9Z3Fz+cfN1VESCVfy7EPcM3/m2K8l+Gn2U+4XtNZjwhUXctQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ=="],
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.12.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-Dpta6gigm4o6SYioXtTkZBlGLA/Sl6OpzgLqY+51uafJE2blPejAtR1w82CVLPN+F9cvo1+/em92x/zcOgY0uQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w=="],
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-YpeDwQ5uFaogRvytNC8RK0keDO65kI5wNEhGSs+4GiJc3KHhxj4j5cnQh2nCu9ybJj6RP0/ycB6bkN0yFJ9I+w=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.12", "", { "os": "linux", "cpu": "arm" }, "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA=="],
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.12.tgz", { "os": "linux", "cpu": "arm" }, "sha512-LQrB3/sImIr1ycrlEyaApOLH1rAy8JV4/dH4eoK817aWATdcs4fESXz53/w72NwDnwPM5zEUpoL7socH4ZDnwA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A=="],
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-iAF2iYu9S4SBfNsb4k0ewNBjO83WxHEltIuKQBGMzEKFgrYfmJn+gatBDXR2t/0zJN0UW0kzR4rfvQ1UHzkZ1A=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA=="],
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.12.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0MQdRtdbirJCXPIVeW/hd/LXLR8Qz96pc+2EBptuuV5q/zSGOzND7SZt8gBTE58NC9cUG/8UxcUXQiKRZ2WzzA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ=="],
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-yGDEwn0AGnrY/KCz2oY8R9KKNiQnTcwNuCWXeSXF0VLxXzlqoCgaS5QHsf1lbQvKWwPuN2Czkjk1wXRCBONTOQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q=="],
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-AjlbD0efZEhHaJ3bsNjxSFDoguNq6VZvC+FKGJGBCA+ZN7BrZq2TuuSWFa6AI8IpOeuvhp2LoYcP7jiZwmtU0Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ=="],
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-sDW0CiPoUCEEUV25uBG5sJ2gKe75DJqLpj91m1eK1vKffywyY7v4i6q1450ZuR6QW4NArXg7JpbMGgKU8di3HQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg=="],
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.12.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VTQYUhMjl8kv999VuYqScgv7P9cR9KuxI/6bkUsEa3bsswqG0+otFjuLZmJ1U+ffEP4IAz0jxOeV0DX6NRBhjg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A=="],
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-ppsKwpSjNrzJ8hge3Z3uiCcoRgAAQk2tU7auy3MFF348ffWRldFPnWIT/a/enK7huijlTfnwn4DYpDoapEb45A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.12", "", { "os": "linux", "cpu": "none" }, "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A=="],
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.12.tgz", { "os": "linux", "cpu": "none" }, "sha512-Gc3cVRC1trFH+GFWWTsJz6YuD4u75fzr/q99jqQGimonN/zvN6Wecck5WGzllOHj+XYXHcq0Ibor0tanGjfI6A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A=="],
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.12.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-pjmDTFStvwFE3shTeUDvSDiSp8/qAf5bQXejcEUDDavWz4jEhTIpCdpS06G0PEwSCSNK0Lngjz2v9l4hk3Z74A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q=="],
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-mkbjxPqpR91u1JD7xY4aLZhr/Mqka4/RqGcG4WNGgN/lC1o7TzyCyqJoSJXp9qVVxMwpJGFlEF1VlERb+Ghw1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.12", "", { "os": "linux", "cpu": "x64" }, "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw=="],
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.64.12", "https://registry.npmjs.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.12.tgz", { "os": "linux", "cpu": "x64" }, "sha512-vIeut2LkuVc3gcsawLJPahNAzvzBcFRhu2O+yVb//BuQKz7PIItMF0piXRbN/lIDO3d68TSDd368Vg6YwgTybw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg=="],
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.12.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-3Sx+D3r9oPkTdNUcPrIRM2Am+vzEV6VGWoImHG4ZVK2jJeOW+eDjLeTm6nvu/z/m0fiX2YmGLXIypblc336MXg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.12", "", { "os": "none", "cpu": "arm64" }, "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw=="],
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.64.12", "https://registry.npmjs.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.12.tgz", { "os": "none", "cpu": "arm64" }, "sha512-Nn2xh0teFnl71EiiUrKvECWcdGXtWF8q0uGclYwXGVw6QClGAcME0/bJSGdK+xIvd6MLEKYmjkTUvCBV+7m5Kw=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.12.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-n1/0H9TS+kBa4xp6n9aV7yiynBNfmerjkj2nAMIfY0ZHv1zShWRXnsAuAPAH0sfEK4ZSMK+z24q2RjAPFLCN1w=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw=="],
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.12.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-AdlDbQpsLfGlsHr1NfLZFtdnvxCKNp9TwlYVYExs8JJLWjLafUQEKJk0r9zUbmU57mw9nglMEvzqPHuLWYmaKw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg=="],
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-u5SSUJBHod51UamjkigSr+JPre6xMy5vaWTTV2K0SnFG4RuKyYpf66dTSLFKX2cwaldpwyBurXPUCxq8riQMEg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.12", "", { "os": "win32", "cpu": "x64" }, "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw=="],
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.64.12", "https://registry.npmjs.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.12.tgz", { "os": "win32", "cpu": "x64" }, "sha512-j5rXD4R/4DM5+V991AA9xOnG2Rwu2g3p5TPgu1Eu7apju0NUrLl+Bn/KpWc6wYJtlXqzwz1r+LNV1JeBU4VGvw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
|
||||
"@types/bun": ["@types/bun@1.4.3", "https://registry.npmjs.com/@types/bun/-/bun-1.4.3.tgz", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
"@types/estree": ["@types/estree@1.0.9", "https://registry.npmjs.com/@types/estree/-/estree-1.0.9.tgz", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/lodash": ["@types/lodash@4.17.26", "", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="],
|
||||
"@types/lodash": ["@types/lodash@4.17.26", "https://registry.npmjs.com/@types/lodash/-/lodash-4.17.26.tgz", {}, "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA=="],
|
||||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "https://registry.npmjs.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
"@types/node": ["@types/node@26.6.5", "https://registry.npmjs.com/@types/node/-/node-26.6.5.tgz", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.10", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.10.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25 || ^3.6.0-rc.10" } }, "sha512-b80aUQh3n+qEzzZm5oQp3rT2QImeCx58K6eFl/RkJ3d65KW0kTuv5lNsLRVAjbW9YRUpo/wzmvzMgV34bTAItA=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "https://registry.npmjs.com/@volar/language-core/-/language-core-2.4.28.tgz", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "https://registry.npmjs.com/@volar/source-map/-/source-map-2.4.28.tgz", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "https://registry.npmjs.com/@volar/typescript/-/typescript-2.4.28.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.44", "https://registry.npmjs.com/@vue/compiler-core/-/compiler-core-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.2" } }, "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.44", "https://registry.npmjs.com/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", { "dependencies": { "@vue/compiler-core": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.44", "https://registry.npmjs.com/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", { "dependencies": { "@babel/parser": "^7.29.9", "@vue/compiler-core": "3.5.44", "@vue/compiler-dom": "3.5.44", "@vue/compiler-ssr": "3.5.44", "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.29", "source-map-js": "^1.2.2" } }, "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.3.13", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.44", "https://registry.npmjs.com/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||
"@vue/language-core": ["@vue/language-core@3.3.13", "https://registry.npmjs.com/@vue/language-core/-/language-core-3.3.13.tgz", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-axA0Xemn5LuUKuKWob0/kVq7xQ2mJJM6VqA9Exvt35nM9yRC31goWVve/G/Zb2ZCLsXC8FHYjjfXlnshknSCew=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.44", "https://registry.npmjs.com/@vue/reactivity/-/reactivity-3.5.44.tgz", { "dependencies": { "@vue/shared": "3.5.44" } }, "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.44", "https://registry.npmjs.com/@vue/runtime-core/-/runtime-core-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.44", "https://registry.npmjs.com/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", { "dependencies": { "@vue/reactivity": "3.5.44", "@vue/runtime-core": "3.5.44", "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.44", "", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.44", "https://registry.npmjs.com/@vue/server-renderer/-/server-renderer-3.5.44.tgz", { "dependencies": { "@vue/compiler-ssr": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/shared": "3.5.44" } }, "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
"@vue/shared": ["@vue/shared@3.5.44", "https://registry.npmjs.com/@vue/shared/-/shared-3.5.44.tgz", {}, "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg=="],
|
||||
|
||||
"async-validator": ["async-validator@4.2.5", "", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
|
||||
"alien-signals": ["alien-signals@3.2.1", "https://registry.npmjs.com/alien-signals/-/alien-signals-3.2.1.tgz", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
||||
"async-validator": ["async-validator@4.2.5", "https://registry.npmjs.com/async-validator/-/async-validator-4.2.5.tgz", {}, "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="],
|
||||
|
||||
"css-render": ["css-render@0.15.14", "", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
||||
"bun-types": ["bun-types@1.4.3", "https://registry.npmjs.com/bun-types/-/bun-types-1.4.3.tgz", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
"css-render": ["css-render@0.15.14", "https://registry.npmjs.com/css-render/-/css-render-0.15.14.tgz", { "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" } }, "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg=="],
|
||||
|
||||
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||
"csstype": ["csstype@3.2.3", "https://registry.npmjs.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns-tz": ["date-fns-tz@3.2.0", "", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
||||
"date-fns": ["date-fns@4.4.0", "https://registry.npmjs.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
"date-fns-tz": ["date-fns-tz@3.2.0", "https://registry.npmjs.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz", { "peerDependencies": { "date-fns": "^3.0.0 || ^4.0.0" } }, "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
"dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"entities": ["entities@7.0.1", "https://registry.npmjs.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"evtd": ["evtd@0.2.4", "", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="],
|
||||
"esbuild": ["esbuild@0.28.2", "https://registry.npmjs.com/esbuild/-/esbuild-0.28.2.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
"estree-walker": ["estree-walker@2.0.2", "https://registry.npmjs.com/estree-walker/-/estree-walker-2.0.2.tgz", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"evtd": ["evtd@0.2.4", "https://registry.npmjs.com/evtd/-/evtd-0.2.4.tgz", {}, "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||
"fdir": ["fdir@6.5.0", "https://registry.npmjs.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"hono": ["hono@4.13.13", "", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmjs.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
"highlight.js": ["highlight.js@11.12.0", "https://registry.npmjs.com/highlight.js/-/highlight.js-11.12.0.tgz", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
"hono": ["hono@4.13.13", "https://registry.npmjs.com/hono/-/hono-4.13.13.tgz", {}, "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
"lodash": ["lodash@4.18.1", "https://registry.npmjs.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
"lodash-es": ["lodash-es@4.18.1", "https://registry.npmjs.com/lodash-es/-/lodash-es-4.18.1.tgz", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
|
||||
|
||||
"naive-ui": ["naive-ui@2.45.3", "", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
|
||||
"magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
"monaco-editor": ["monaco-editor@0.57.0", "", { "dependencies": { "dompurify": "3.4.15", "marked": "14.0.0" } }, "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
"muggle-string": ["muggle-string@0.4.1", "https://registry.npmjs.com/muggle-string/-/muggle-string-0.4.1.tgz", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
"naive-ui": ["naive-ui@2.45.3", "https://registry.npmjs.com/naive-ui/-/naive-ui-2.45.3.tgz", { "dependencies": { "@css-render/plugin-bem": "^0.15.14", "@css-render/vue3-ssr": "^0.15.14", "@types/lodash": "^4.17.20", "@types/lodash-es": "^4.17.12", "async-validator": "^4.2.5", "css-render": "^0.15.14", "csstype": "^3.1.3", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "evtd": "^0.2.4", "highlight.js": "^11.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "seemly": "^0.3.10", "treemate": "^0.3.11", "vdirs": "^0.1.8", "vooks": "^0.2.12", "vueuc": "^0.4.66" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
|
||||
"nanoid": ["nanoid@3.3.20", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.20.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"rollup": ["rollup@4.64.12", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.12", "@rollup/rollup-android-arm64": "4.64.12", "@rollup/rollup-darwin-arm64": "4.64.12", "@rollup/rollup-darwin-x64": "4.64.12", "@rollup/rollup-freebsd-arm64": "4.64.12", "@rollup/rollup-freebsd-x64": "4.64.12", "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", "@rollup/rollup-linux-arm-musleabihf": "4.64.12", "@rollup/rollup-linux-arm64-gnu": "4.64.12", "@rollup/rollup-linux-arm64-musl": "4.64.12", "@rollup/rollup-linux-loong64-gnu": "4.64.12", "@rollup/rollup-linux-loong64-musl": "4.64.12", "@rollup/rollup-linux-ppc64-gnu": "4.64.12", "@rollup/rollup-linux-ppc64-musl": "4.64.12", "@rollup/rollup-linux-riscv64-gnu": "4.64.12", "@rollup/rollup-linux-riscv64-musl": "4.64.12", "@rollup/rollup-linux-s390x-gnu": "4.64.12", "@rollup/rollup-linux-x64-gnu": "4.64.12", "@rollup/rollup-linux-x64-musl": "4.64.12", "@rollup/rollup-openbsd-x64": "4.64.12", "@rollup/rollup-openharmony-arm64": "4.64.12", "@rollup/rollup-win32-arm64-msvc": "4.64.12", "@rollup/rollup-win32-ia32-msvc": "4.64.12", "@rollup/rollup-win32-x64-gnu": "4.64.12", "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ=="],
|
||||
"path-browserify": ["path-browserify@1.0.1", "https://registry.npmjs.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"seemly": ["seemly@0.3.10", "", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
|
||||
"picocolors": ["picocolors@1.1.1", "https://registry.npmjs.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
"picomatch": ["picomatch@4.0.7", "https://registry.npmjs.com/picomatch/-/picomatch-4.0.7.tgz", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
"postcss": ["postcss@8.5.31", "https://registry.npmjs.com/postcss/-/postcss-8.5.31.tgz", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="],
|
||||
|
||||
"treemate": ["treemate@0.3.11", "", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||
"rollup": ["rollup@4.64.12", "https://registry.npmjs.com/rollup/-/rollup-4.64.12.tgz", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.64.12", "@rollup/rollup-android-arm64": "4.64.12", "@rollup/rollup-darwin-arm64": "4.64.12", "@rollup/rollup-darwin-x64": "4.64.12", "@rollup/rollup-freebsd-arm64": "4.64.12", "@rollup/rollup-freebsd-x64": "4.64.12", "@rollup/rollup-linux-arm-gnueabihf": "4.64.12", "@rollup/rollup-linux-arm-musleabihf": "4.64.12", "@rollup/rollup-linux-arm64-gnu": "4.64.12", "@rollup/rollup-linux-arm64-musl": "4.64.12", "@rollup/rollup-linux-loong64-gnu": "4.64.12", "@rollup/rollup-linux-loong64-musl": "4.64.12", "@rollup/rollup-linux-ppc64-gnu": "4.64.12", "@rollup/rollup-linux-ppc64-musl": "4.64.12", "@rollup/rollup-linux-riscv64-gnu": "4.64.12", "@rollup/rollup-linux-riscv64-musl": "4.64.12", "@rollup/rollup-linux-s390x-gnu": "4.64.12", "@rollup/rollup-linux-x64-gnu": "4.64.12", "@rollup/rollup-linux-x64-musl": "4.64.12", "@rollup/rollup-openbsd-x64": "4.64.12", "@rollup/rollup-openharmony-arm64": "4.64.12", "@rollup/rollup-win32-arm64-msvc": "4.64.12", "@rollup/rollup-win32-ia32-msvc": "4.64.12", "@rollup/rollup-win32-x64-gnu": "4.64.12", "@rollup/rollup-win32-x64-msvc": "4.64.12", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GhAu8ZglZ61VWSX/ZW3K/qkazgURBIQL0/Yy51QsS8DPyebR5Cf/Z2PANJFpTU0aVZ9byJcyqR6xvBbkXhdGYQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
"seemly": ["seemly@0.3.10", "https://registry.npmjs.com/seemly/-/seemly-0.3.10.tgz", {}, "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q=="],
|
||||
|
||||
"undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||
"source-map-js": ["source-map-js@1.2.2", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.2.tgz", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"vdirs": ["vdirs@0.1.8", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "https://registry.npmjs.com/tinyglobby/-/tinyglobby-0.2.17.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"vite": ["vite@7.3.7", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
|
||||
"treemate": ["treemate@0.3.11", "https://registry.npmjs.com/treemate/-/treemate-0.3.11.tgz", {}, "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg=="],
|
||||
|
||||
"vooks": ["vooks@0.2.12", "", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmjs.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||
"undici-types": ["undici-types@8.12.3", "https://registry.npmjs.com/undici-types/-/undici-types-8.12.3.tgz", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||
|
||||
"vue": ["vue@3.5.44", "", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||
"vdirs": ["vdirs@0.1.8", "https://registry.npmjs.com/vdirs/-/vdirs-0.1.8.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.3.13", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||
"vite": ["vite@7.3.7", "https://registry.npmjs.com/vite/-/vite-7.3.7.tgz", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA=="],
|
||||
|
||||
"vueuc": ["vueuc@0.4.66", "", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
||||
"vooks": ["vooks@0.2.12", "https://registry.npmjs.com/vooks/-/vooks-0.2.12.tgz", { "dependencies": { "evtd": "^0.2.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q=="],
|
||||
|
||||
"@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
"vscode-uri": ["vscode-uri@3.2.0", "https://registry.npmjs.com/vscode-uri/-/vscode-uri-3.2.0.tgz", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="],
|
||||
|
||||
"css-render/csstype": ["csstype@3.0.11", "", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
|
||||
"vue": ["vue@3.5.44", "https://registry.npmjs.com/vue/-/vue-3.5.44.tgz", { "dependencies": { "@vue/compiler-dom": "3.5.44", "@vue/compiler-sfc": "3.5.44", "@vue/runtime-dom": "3.5.44", "@vue/server-renderer": "3.5.44", "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.3.13", "https://registry.npmjs.com/vue-tsc/-/vue-tsc-3.3.13.tgz", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.13" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-jE4/bXdOMSHVxEGgQCqNllJy+9N3Gx6tfSAsg0yhFK/MAN6eXqISihupnDEKSCt12VVnjN2gI3AqLYcqoh5x/A=="],
|
||||
|
||||
"vueuc": ["vueuc@0.4.66", "https://registry.npmjs.com/vueuc/-/vueuc-0.4.66.tgz", { "dependencies": { "@css-render/vue3-ssr": "^0.15.10", "@juggle/resize-observer": "^3.3.1", "css-render": "^0.15.10", "evtd": "^0.2.4", "seemly": "^0.3.6", "vdirs": "^0.1.4", "vooks": "^0.2.4" }, "peerDependencies": { "vue": "^3.0.11" } }, "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg=="],
|
||||
|
||||
"@types/node/undici-types": ["undici-types@8.9.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.9.0.tgz", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"css-render/csstype": ["csstype@3.0.11", "https://registry.npmjs.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# 部署:先在 .env 里写好 ACCESS_PASSWORD(参考 .env.example),再 docker compose up -d
|
||||
# HTTPS 由 Nginx Proxy Manager 负责,它在 npm_proxy 网络里转发到 iot-sim:80;步骤见 deploy/README.md
|
||||
services:
|
||||
app:
|
||||
image: iot-sim:latest
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACCESS_PASSWORD: ${ACCESS_PASSWORD:?请在 .env 里设置 ACCESS_PASSWORD}
|
||||
# 以下可在 .env 里调整,说明见 deploy/README.md
|
||||
SIM_MAX_RUNS: ${SIM_MAX_RUNS:-150}
|
||||
SIM_HEAP_LIMIT_MB: ${SIM_HEAP_LIMIT_MB:-128}
|
||||
SIM_CPU_LIMIT_SECONDS: ${SIM_CPU_LIMIT_SECONDS:-120}
|
||||
SIM_MAX_RUN_MINUTES: ${SIM_MAX_RUN_MINUTES:-60}
|
||||
# 容器文件系统只读,只有 /tmp(学生工程的临时目录)可写
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=1g
|
||||
cap_drop: [ALL]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
# bubblewrap 要创建用户命名空间、挂载新的 /proc,Docker 默认的 seccomp 规则和 /proc 遮罩会拦住它。
|
||||
# 放开后,学生程序仍然在 bubblewrap 里:断网、看不到其他进程和文件、不是 root。
|
||||
- seccomp=unconfined
|
||||
- apparmor=unconfined
|
||||
- systempaths=unconfined
|
||||
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||
pids_limit: 4096
|
||||
expose: ["3000"]
|
||||
networks: [default]
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
depends_on: [app]
|
||||
expose:
|
||||
- 80
|
||||
networks:
|
||||
default:
|
||||
npm_proxy:
|
||||
# NPM 里转发到 http://iot-sim:80
|
||||
aliases: [iot-sim]
|
||||
volumes:
|
||||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
npm_proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,17 @@
|
||||
# HTTPS 由 Nginx Proxy Manager 负责,Caddy 只在 80 端口收 HTTP,转发给应用(包括 WebSocket /ws)。
|
||||
{
|
||||
auto_https off
|
||||
servers {
|
||||
# NPM 在 Docker 内网里,信任它带来的 X-Forwarded-For,取到学生的真实 IP。
|
||||
# 如果 NPM 前面还有公网上的 CDN,把 CDN 的 IP 段加在 private_ranges 后面。
|
||||
trusted_proxies static private_ranges
|
||||
trusted_proxies_strict
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
encode zstd gzip
|
||||
reverse_proxy app:3000 {
|
||||
header_up X-Real-IP {client_ip}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# 部署
|
||||
|
||||
服务器上的结构:
|
||||
|
||||
```
|
||||
学生浏览器 ──HTTPS──▶ Nginx Proxy Manager(443) ──npm_proxy 网络──▶ caddy(iot-sim:80) ──▶ app(Bun + .NET + bubblewrap)
|
||||
```
|
||||
|
||||
- **app**:Bun 服务端、打包好的网页、C# 编译服务。学生程序每次运行都放进 bubblewrap 沙箱:断网,看不到服务器文件和其他进程,工程目录只读,限制内存(128 MB)、CPU 时间(120 秒)和运行时长(60 分钟)。
|
||||
- **caddy**:只收 HTTP,转发给 app,压缩网页;从 NPM 转发来的 `X-Forwarded-For` 里取学生的真实 IP。
|
||||
- 不映射宿主机端口,NPM 通过外部网络 `npm_proxy` 访问 `iot-sim:80`。
|
||||
|
||||
## 服务器要求
|
||||
|
||||
- Linux,Docker 和 Docker Compose v2,已有外部网络 `npm_proxy`(NPM 所在的网络)。
|
||||
- 2 核 8G 足够两个班同时使用(实测 100 个程序同时运行约 1.1 GB)。
|
||||
- 沙箱需要宿主机允许非特权用户命名空间。**Debian 12(本平台的服务器)默认允许**,不用设置,可用 `sysctl kernel.unprivileged_userns_clone` 确认输出为 1。
|
||||
- **Ubuntu 23.10 及以上**默认禁止,沙箱会起不来,需要放开:
|
||||
|
||||
```bash
|
||||
echo 'kernel.apparmor_restrict_unprivileged_userns=0' | sudo tee /etc/sysctl.d/60-userns.conf
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
其他发行版一般不需要。服务器启动时会自检沙箱,不通过会在日志里打印原因,并拒绝运行学生代码(不会在无隔离的情况下运行)。
|
||||
|
||||
## 第一次部署
|
||||
|
||||
1. 在服务器上准备目录和口令:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/IOT_SIM && cd ~/IOT_SIM
|
||||
# 部署脚本会上传 .env.example;也可以直接新建 .env:
|
||||
echo 'ACCESS_PASSWORD=换成你的口令' > .env
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
2. 在本机(开发机)执行:
|
||||
|
||||
```bash
|
||||
deploy/deploy.sh 用户@服务器
|
||||
```
|
||||
|
||||
脚本在本机构建镜像,通过 ssh 传到服务器并启动。日志最后应看到"C# 编译服务已就绪",且没有"沙箱无法启动"。
|
||||
|
||||
3. 在 Nginx Proxy Manager 里添加 Proxy Host:
|
||||
- Domain Names:已备案的域名
|
||||
- Scheme:`http`,Forward Hostname / IP:`iot-sim`,Forward Port:`80`
|
||||
- **打开 Websockets Support**(运行程序靠 WebSocket,不打开会一直连不上)
|
||||
- SSL:申请证书,打开 Force SSL
|
||||
|
||||
4. 浏览器打开域名,输入口令进入。
|
||||
|
||||
## 日常操作
|
||||
|
||||
在服务器的 `~/IOT_SIM` 目录下:
|
||||
|
||||
| 做什么 | 命令 |
|
||||
|---|---|
|
||||
| 更新到新版本 | 在本机再执行一次 `deploy/deploy.sh 用户@服务器` |
|
||||
| 换口令 | 修改 `.env` 后 `docker compose up -d`(所有人要重新输入口令) |
|
||||
| 看日志 | `docker compose logs -f app` |
|
||||
| 看资源占用 | `docker stats` |
|
||||
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
|
||||
|
||||
## 可调参数
|
||||
|
||||
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ACCESS_PASSWORD` | (必填) | 访问口令 |
|
||||
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
|
||||
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
|
||||
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
|
||||
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
|
||||
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在本机构建镜像,传到服务器后启动(服务器不需要访问 Docker Hub,也不用自己编译)。
|
||||
# 用法:deploy/deploy.sh 用户@服务器 [服务器上的目录,默认 ~/IOT_SIM]
|
||||
# 第一次部署前,先按 deploy/README.md 在服务器上准备好 .env
|
||||
set -euo pipefail
|
||||
|
||||
host=${1:?用法:deploy/deploy.sh 用户@服务器 [目录]}
|
||||
dir=${2:-IOT_SIM}
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "== 构建镜像"
|
||||
docker compose build app
|
||||
docker pull -q caddy:2-alpine
|
||||
|
||||
echo "== 上传配置文件到 $host:$dir"
|
||||
ssh "$host" "mkdir -p $dir/deploy"
|
||||
scp -q compose.yaml .env.example "$host:$dir/"
|
||||
scp -q deploy/Caddyfile "$host:$dir/deploy/"
|
||||
|
||||
echo "== 上传镜像(约 130 MB)"
|
||||
docker save iot-sim:latest | gzip -1 | ssh "$host" 'gunzip | docker load'
|
||||
if ! ssh "$host" 'docker image inspect caddy:2-alpine >/dev/null 2>&1'; then
|
||||
docker save caddy:2-alpine | gzip -1 | ssh "$host" 'gunzip | docker load'
|
||||
fi
|
||||
|
||||
echo "== 启动"
|
||||
ssh "$host" "cd $dir && test -f .env || { echo '服务器上还没有 .env:cp .env.example .env 后设置 ACCESS_PASSWORD'; exit 1; }
|
||||
docker compose up -d --no-build --remove-orphans && docker image prune -f >/dev/null
|
||||
sleep 5 && docker compose logs --tail 5 app"
|
||||
@@ -0,0 +1,293 @@
|
||||
# 物联网 C# 虚拟仿真教学平台设计方案
|
||||
|
||||
> 本文件是设计方案的正式版本,随代码一起维护。最初在 Claude Docs 中讨论定稿(2026-10-20),2026-10-22 迁入仓库,并按第 0、1 阶段的实际实现更新。
|
||||
|
||||
## 项目定位与教学目标
|
||||
|
||||
这是一门面向中职学生的 **C# WinForms 界面与事件编程课**的在线仿真平台,物联网场景是让学生程序"看得见效果"的舞台。
|
||||
|
||||
- **学什么**:窗体、控件、属性、事件、Timer、条件判断、方法与类等界面编程知识。核心是"事件驱动"的思维:程序等待事情发生(点按钮、定时器到点、传感器报警),再做出反应。
|
||||
- **教材**:《物联网编程与应用(第 2 版)》,以智慧校园为主线的 8 个项目。平台兼容教材的设备类库,教材代码原样可运行。
|
||||
- **不学什么**:通信协议、报文、CRC,以及 ADAM-4150 / 4017+ 等硬件模块的底层细节。
|
||||
- **效果**:学生写的程序能控制一个生动的智慧校园场景,比如 LED 屏显示文字、路灯亮起、有人闯入报警、烟雾告警,并能实时看到反馈。
|
||||
- **使用方式**:纯网页,学生不安装任何软件;只给本校使用,同时在线约一两个班。
|
||||
|
||||
## 已确定的关键决策
|
||||
|
||||
| 事项 | 决定 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 学生编程语言 | C# | 课程要求 |
|
||||
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
|
||||
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
|
||||
| 设备写法 | 兼容教材类库(DeviceCtrlHelper、Lamp / LED / Alarm 等控件) | 教材代码原样运行,老师不用改教案 |
|
||||
| 场景 | 智慧校园;先做虚拟实训台(照实训台布局图),校园街景后加 | 和机房实物一一对应 |
|
||||
| 数据库(项目 6、8) | 用 SQLite 模拟 SQL Server(仿冒 SqlClient) | 不增加服务器负担 |
|
||||
| 学生是否学协议 | 不学 | 协议只是仿真内部实现 |
|
||||
| 使用形式 | 纯网页,免安装 | 学生在机房和家里都能用 |
|
||||
| 学生代码在哪运行 | 服务器(沙箱隔离) | 只供本校一两个班同时使用;原生 .NET 多线程行为真实;浏览器轻量 |
|
||||
| 前端 | Vue 3 + Naive UI + Chart.js | 开发者熟悉 TypeScript 生态 |
|
||||
| 后端 | Bun + SQLite | 一人开发,单机足够 |
|
||||
| 部署 | 公网云服务器(2 核 8G,已备案);HTTPS 由服务器上已有的 Nginx Proxy Manager 负责 | 本平台只在 Docker 网络里暴露 HTTP |
|
||||
| 开发人力 | 一人 | 范围和路线图按此控制 |
|
||||
|
||||
## 学生使用体验
|
||||
|
||||
页面分为编辑和运行两种模式。编辑模式照着 Visual Studio 来做,学生以后换到真的 VS 上操作一致。
|
||||
|
||||
**编辑模式**
|
||||
|
||||
- 左侧工具箱:常用控件(Button、Label、TextBox、CheckBox、ComboBox、Timer 等),以及"物联网"分组(教材的 Lamp、LED、Alarm、ShowMsg 等设备控件)。
|
||||
- 中间「设计」和「代码」两个标签页:设计页拖放控件,窗体下方有组件栏(存放 Timer 和设备组件);代码页用 Monaco 编辑 `Form1.cs`。
|
||||
- 右侧属性窗口和事件窗口:双击控件或事件,自动生成处理方法并跳到代码页。
|
||||
- `Form1.Designer.cs` 由设计器自动生成,可以打开查看。
|
||||
|
||||
**运行模式**
|
||||
|
||||
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
||||
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
||||
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
||||
|
||||
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
||||
|
||||
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM1–COM5,保留"选串口、点打开"的操作步骤)。
|
||||
|
||||
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
|
||||
|
||||
## 设备类库设计(兼容教材)
|
||||
|
||||
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
||||
|
||||
| 对象 | 主要成员 | 平台仿真行为 |
|
||||
| --- | --- | --- |
|
||||
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
||||
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
||||
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
||||
| `zigBeeHelper` | `Open(串口)`、`OnOff(继电器, 开关)`、`Close()` | 控制 ZigBee 继电器;节点 1 接实训台照明灯 1(教材项目 3 的路灯) |
|
||||
| `ipCameraHelper`(未实现) | `Open(IP, 用户, 密码)`、`Start` / `Stop`、`PanUp` / `PanDown` / `PanLeft` / `PanRight`、`ImageRead` 事件 | 在后台线程定时推送画面;云台转动即在全景图上移动取景 |
|
||||
| 控件 `Lamp` / `Alarm` / `LED` / `ShowMsg` | `OnOff(bool)`、`setLedText(文本)`、`AddText(文本)` | 窗体上的图示控件(自绘图片,尺寸与教材控件一致);LED 文字滚动。ShowMsg 尚未实现 |
|
||||
|
||||
教材代码示例(项目 2 任务 1,向 LED 屏发送信息):
|
||||
|
||||
```csharp
|
||||
private void btn_Open_Click(object sender, EventArgs e)
|
||||
{
|
||||
DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text);
|
||||
}
|
||||
|
||||
private void btn_Send_Click(object sender, EventArgs e)
|
||||
{
|
||||
string res = DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text);
|
||||
if (res.IndexOf("Error") >= 0)
|
||||
{
|
||||
MessageBox.Show(res);
|
||||
}
|
||||
else
|
||||
{
|
||||
led.setLedText(res);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容细节:
|
||||
|
||||
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
|
||||
- 4017 和 4150 允许打开同一个串口;允许重复调用 `Open`。
|
||||
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
||||
- 摄像头事件在非界面线程触发,与真实行为一致。
|
||||
|
||||
## 系统架构
|
||||
|
||||
学生代码在服务器上编译,并放进单独的进程运行;浏览器只负责显示和交互。三方之间只通过统一格式的 JSON 消息通信(定义见 `shared/protocol.ts`),以后如需改为在浏览器内运行,只替换 `runtime/` 即可。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser["浏览器(Vue 3)"]
|
||||
Editor["代码编辑器 / 窗体设计器"]
|
||||
Forms["虚拟窗体<br/>按控件树渲染,用户操作发回服务器"]
|
||||
Board["虚拟实训台<br/>滑块、开关调节环境;显示灯、LED 屏"]
|
||||
end
|
||||
subgraph Server["Bun 后端(Hono)"]
|
||||
Session["会话<br/>每个浏览器一个,管理每次运行"]
|
||||
BoardState["实训台状态<br/>传感器输入、继电器输出、LED 文字"]
|
||||
Data["账号、班级、实验、评测(SQLite)"]
|
||||
end
|
||||
subgraph Runtime[".NET 10 运行时(运行进程在 bubblewrap 沙箱里)"]
|
||||
Compiler["编译服务(Roslyn)<br/>全服务器共用一个常驻进程"]
|
||||
Runner["运行进程(每次运行一个)<br/>仿真 WinForms + 教材设备类库"]
|
||||
end
|
||||
Browser <-->|WebSocket| Session
|
||||
Session --- BoardState
|
||||
Session -->|编译请求| Compiler
|
||||
Session <-->|标准输入输出,每行一条 JSON| Runner
|
||||
```
|
||||
|
||||
数据流:
|
||||
|
||||
- 学生点"运行":服务器把工程写到临时目录,编译服务编译出程序集,再启动一个运行进程。
|
||||
- 学生在窗体上操作:浏览器只上报"用户做了什么"(点击、输入、下拉),由运行进程按真实 WinForms 的顺序触发事件,再把控件属性变化发回浏览器。
|
||||
- 学生拖动实训台滑块:服务器更新实训台状态,推给运行进程;学生程序的 `SetData()` 直接读本地副本,不需要网络往返。
|
||||
- 学生程序控制灯和继电器:运行进程报告给服务器,服务器更新实训台状态并推给浏览器。
|
||||
|
||||
**目录结构**
|
||||
|
||||
```
|
||||
IOT/
|
||||
├─ web/ Vue 前端:编辑器、虚拟窗体、虚拟实训台
|
||||
├─ server/ Bun 后端:示例接口、会话、实训台状态、编译服务和运行进程的调度
|
||||
├─ runtime/ .NET:仿真 WinForms(Sim.WinForms)、教材设备类库(Sim.Devices)、运行器(Sim.Runner)
|
||||
├─ shared/ 消息协议定义(TS 类型;C# 端对应实现在 Sim.WinForms/Host)
|
||||
├─ samples/ 示例工程,按教材任务整理
|
||||
└─ docs/ 设计方案、教材逐任务摘要
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||
|
||||
| 层 | 用途 | 选择 |
|
||||
| --- | --- | --- |
|
||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||
| 前端 | 代码编辑器 | Monaco Editor |
|
||||
| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) |
|
||||
| 前端 | 虚拟实训台场景 | HTML + CSS 动画 |
|
||||
| 前端 | 图表 | 计划:Chart.js |
|
||||
| 前端 | 实时通信 | 浏览器原生 WebSocket |
|
||||
| 后端 | 运行时 | Bun |
|
||||
| 后端 | Web 框架 | Hono |
|
||||
| 后端 | 数据库 | 计划:SQLite(`bun:sqlite`)+ Drizzle ORM |
|
||||
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
|
||||
| 后端 | 参数校验 | 计划:Zod |
|
||||
| C# 运行时 | 平台 | .NET 10 LTS |
|
||||
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;计划:编译时改写旧版线程 API |
|
||||
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
|
||||
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
|
||||
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
|
||||
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
|
||||
| C# 运行时 | 代码补全 | 计划:Roslyn `CompletionService` |
|
||||
| 工程 | 仓库 | Bun workspaces 单仓库 |
|
||||
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
|
||||
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
|
||||
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
|
||||
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
|
||||
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
|
||||
| 部署 | 进程管理 | Docker Compose;镜像在开发机构建后经 ssh 传到服务器(`deploy/deploy.sh`) |
|
||||
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
|
||||
|
||||
## 安全与服务器资源
|
||||
|
||||
在公网上运行学生代码,必须做三层防护;第 0 阶段实测,100 个学生程序同时运行合计约 1.1 GB 内存。
|
||||
|
||||
**安全措施**(1、2 已完成;3 在第 2 阶段之前先用访问口令代替)
|
||||
|
||||
1. **沙箱隔离**:每次运行用 bubblewrap 放进独立的命名空间:断网,只能看到系统只读文件、运行时程序和自己的工程目录(只读),/tmp 是独立内存盘,看不到其他进程,不继承服务器的环境变量。另外限制托管堆 128 MB、累计 CPU 时间 120 秒(防死循环占满 CPU)、运行时长 60 分钟,降低优先级(nice 10),全站同时运行上限 150 个。整个平台再放在 Docker 容器里:文件系统只读、去掉全部 capabilities、限制内存和进程数。服务器启动时自检沙箱,不可用时拒绝运行学生代码。
|
||||
- 没有选"每次运行一个 Docker 容器":每次多 0.3–0.5 秒,而且应用要能操作 Docker 守护进程,这本身权限过大。代价是应用容器要放开 seccomp / AppArmor,才能让 bubblewrap 创建命名空间。
|
||||
2. **编译检查**:网络(System.Net)、反射(System.Reflection,AssemblyInfo.cs 里的特性除外)、启动其他程序(Process)、调用本机代码(DllImport、Marshal)、加载程序集、进程间通信、dynamic,编译时直接给出中文报错(SIM001)。文件读写由沙箱限制,不在编译时禁止(项目 7、8 要读图片)。
|
||||
3. **账号管控**:不开放自由注册,由老师用班级码或批量导入创建账号(第 2 阶段)。在此之前,全站用一个访问口令(`.env` 里设置,换口令后所有人要重新输入),按 IP 限制输错次数。
|
||||
|
||||
**资源估算**
|
||||
|
||||
- 编译器做成常驻进程,全服务器共用一个,约占 150 MB;学生程序运行时不加载编译器。
|
||||
- 每个运行中的学生程序平均约 11 MB(按 PSS 计,共享的 .NET 运行库按进程数平摊;单看 RSS 约 47 MB)。
|
||||
- 每个学生同时只运行一个程序,闲置超时自动结束。
|
||||
- 两个班约 100 人同时运行,合计约 1.3 GB;8 GB 内存有充足余量。
|
||||
- 2026-10-22 复测(Docker 容器限制为 2 核,加 bubblewrap 沙箱):50 人同一瞬间点"运行",4.4 秒内全部显示窗体(中位 2.3 秒),容器共 643 MB;100 人 8.4 秒(中位 4.1 秒),1.1 GB。瓶颈是 CPU(编译和启动),实际上课时点击会分散开。
|
||||
- SQLite 开启 WAL 模式,定时备份。
|
||||
|
||||
## 实验规划
|
||||
|
||||
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
||||
|
||||
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
||||
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
||||
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
||||
| 4 入侵监测 | 红外对射检测;网络摄像机画面与云台;报警灯 | PictureBox、摄像头仿真、Alarm 控件、图片保存 | 任务 1、3 已完成;任务 2 需摄像头 |
|
||||
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 任务 1 已完成;任务 2 需线程 API,任务 3 需 ListBox |
|
||||
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | |
|
||||
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | |
|
||||
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | |
|
||||
|
||||
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
|
||||
|
||||
- **任务说明**:对应书上的任务描述和步骤,控件名以书为准。
|
||||
- **参考答案**:修正后的书本代码(修正编译错误和逻辑错误,如 INSERT 列错位、漏写 `new DataSet()`、项目 7 开灯判断写反)。
|
||||
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
||||
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
||||
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
||||
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||
|
||||
老师不写代码也能修改或新增实验。
|
||||
|
||||
## 开发路线图
|
||||
|
||||
各阶段时长按一人兼职估算,会随实际进度调整。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
P0["阶段 0 · 技术验证(已完成 2026-10-22)<br/>教材项目 2 任务 1 原样运行;实测编译、延迟、内存"]
|
||||
P1["阶段 1 · MVP(进行中,约 3 个月)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、访问口令、Docker Compose)✔"]
|
||||
P2["阶段 2 · 教学闭环(约 2 个月)<br/>班级码登录、提交作业、自动评测、老师看板、成绩导出;一个班试用"]
|
||||
P3["阶段 3 · 扩展(下学期)<br/>教材项目 4–8:摄像头、线程、SQLite 模拟数据库、图表、虚拟时钟"]
|
||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||
P2 -->|"关口:一个班试用顺利"| P3
|
||||
P3 --> P4
|
||||
```
|
||||
|
||||
并行事项:尽早办理 ICP 备案(需数周)。
|
||||
|
||||
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||
|
||||
| 指标 | 结果 |
|
||||
| --- | --- |
|
||||
| 编译(编译服务预热后) | 10–300 ms |
|
||||
| 点"运行"到窗体出现 | 约 100 ms |
|
||||
| 单次操作往返 | 约 15 ms |
|
||||
| 100 个程序同时运行 | 合计约 1.1 GB,平均每个 11 MB,另加编译服务约 150 MB |
|
||||
| 100 个程序同时点击 | 往返中位数约 60 ms,P95 约 75 ms |
|
||||
|
||||
验证中的调整:第一版每个运行进程都加载 Roslyn,单个约 170 MB;改为常驻编译服务后,运行进程不再加载编译器,编译也从约 0.9 s 降到几十毫秒。接入沙箱后在 2 核环境下的复测见上文"资源估算"。
|
||||
|
||||
**第 1 阶段进展**:
|
||||
|
||||
- 2026-10-22:虚拟实训台(服务器统一保存状态,网页调节环境);CheckBox、RadioButton、Lamp、Alarm;教材项目 2、3 共 5 个任务的示例全部跑通。
|
||||
- 2026-10-22:代码编辑器换成 Monaco:C# 语法高亮;编译错误以波浪线标在代码里,点错误信息跳到对应行;F5 运行、Shift+F5 停止(在整个页面生效,并阻止浏览器 F5 刷新);改过的代码自动保存在浏览器本地,可一键恢复原始代码。前端打包后 JS + CSS 压缩约 1.2 MB。
|
||||
- 2026-10-22:窗体设计器:工具箱拖放或双击添加控件,选中、拖动、改大小、拖进拖出分组框,Delete 删除、方向键微调;属性窗口和事件窗口;双击控件或事件生成事件处理方法并跳到代码;Timer 等组件显示在组件栏。Designer.cs 在浏览器端直接解析和生成:未改动的语句保留原文,教材 25 个参考窗体和全部示例读入再写出逐字节不变;布局语句(SuspendLayout / ResumeLayout / PerformLayout)的增删规则与 VS 一致。
|
||||
- 2026-10-22:设计器撤销 / 重做(撤销"生成事件处理方法"时连同插入的方法一起撤掉,不覆盖学生已写的代码);工具箱、属性、事件窗口全部显示中文,英文名放在悬停提示里;支持中文的控件名和类名。
|
||||
- 2026-10-22:多窗体:新建窗体(与 VS 新建窗体相同的两个文件,沿用工程命名空间)、删除窗体(启动窗体不能删);标签页按窗体分组;运行时点哪个窗口哪个到最前。新增示例:项目 1 任务 1 登录与增加用户(两个窗体的 Designer.cs 由设计器引擎生成)。
|
||||
- 2026-10-22:整理教材示例:项目 1 任务 2(计算器、多态)、项目 4 任务 1(红外对射)、项目 4 任务 3(报警灯)、项目 5 任务 1(火焰)。界面取自配套参考工程,用设计器引擎按书上的控件名改名。至此 10 个示例,全部有"能编译"检查和端到端行为测试。项目 4–8 其余任务需要的能力(摄像头、线程 API、ListBox、数据库、TrackBar、PictureBox、图表)放到第 3 阶段。
|
||||
- 2026-10-22:虚拟实训台按自身宽度自适应排列,可以收起成一行状态(传感器开关仍可点),给窗体显示区腾地方。
|
||||
- 2026-10-22:部署:学生程序放进 bubblewrap 沙箱(断网、只读、看不到其他进程和服务器环境变量;限制堆内存、CPU 时间、运行时长,全站并发上限);编译检查禁止网络、反射、启动进程等 API;访问口令;Bun 直接提供打包好的网页;Dockerfile + Docker Compose(Caddy 接入 NPM 的 npm_proxy 网络)+ 部署脚本,本机按服务器配置(2 核)完整验证。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||
|
||||
| 风险 | 担心什么 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 用 ScriptDom 解析后转写;连接串一律指向学生自己的 SQLite 库;日期统一存为标准格式;以教材全部 SQL 语句为测试集 |
|
||||
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 编译时替换为平台实现的可暂停线程 |
|
||||
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 自实现 `Image` / `Bitmap` 等少量类型,只传递和保存图片 |
|
||||
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 提供可调、可加速的虚拟时钟 |
|
||||
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
|
||||
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
|
||||
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查(已完成)+ 访问口令,第 2 阶段换成账号 |
|
||||
| ICP 备案 | 办理需要数周 | 已备案 |
|
||||
|
||||
**待确认**
|
||||
|
||||
- [ ] 串口是否要求接对:目前学生选任意 COM 口都能控制设备;是否在部分实验中要求按实训台接线(COM2 4017、COM3 4150、COM4 ZigBee、COM5 LED)
|
||||
- [ ] 虚拟实训台的画面细节(目前为第一版)
|
||||
- [x] 第一个场景:智慧校园(和教材一致)
|
||||
- [x] 设备写法:兼容教材类库
|
||||
- [x] 数据库:教材用 SQL Server 2008 R2,平台用 SQLite 模拟
|
||||
- [x] 项目 5 的推送方式:推送到 LED 屏
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace Sim.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 编译时检查学生代码用到的 API:网络、启动外部程序、反射、调用本机代码等课程用不到、只会带来风险的功能,
|
||||
/// 直接给出中文报错。真正的隔离靠运行时沙箱,这里是第二道防线,也让学生看到明确的原因。
|
||||
/// </summary>
|
||||
internal static class ApiPolicy
|
||||
{
|
||||
#pragma warning disable RS2008 // 不是分析器,不需要发布跟踪
|
||||
private static readonly DiagnosticDescriptor NotAllowed = new(
|
||||
"SIM001", "平台不允许使用的功能", "平台不允许使用 {0}({1})", "Usage", DiagnosticSeverity.Error, isEnabledByDefault: true);
|
||||
#pragma warning restore RS2008
|
||||
|
||||
/// <summary>整个命名空间(含子命名空间)都不允许</summary>
|
||||
private static readonly (string Namespace, string Reason)[] DeniedNamespaces =
|
||||
{
|
||||
("System.Net", "网络访问"),
|
||||
("System.Reflection", "反射"),
|
||||
("System.Runtime.Loader", "加载程序集"),
|
||||
("System.IO.Pipes", "进程间通信"),
|
||||
("System.IO.MemoryMappedFiles", "进程间通信"),
|
||||
};
|
||||
|
||||
/// <summary>单个类型不允许(所在命名空间的其他类型可以用)</summary>
|
||||
private static readonly Dictionary<string, string> DeniedTypes = new()
|
||||
{
|
||||
["System.Diagnostics.Process"] = "启动其他程序",
|
||||
["System.Diagnostics.ProcessStartInfo"] = "启动其他程序",
|
||||
["System.AppDomain"] = "加载程序集",
|
||||
["System.Runtime.InteropServices.Marshal"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.DllImportAttribute"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.LibraryImportAttribute"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.NativeLibrary"] = "调用本机代码",
|
||||
["System.Runtime.InteropServices.NativeMemory"] = "调用本机代码",
|
||||
};
|
||||
|
||||
public static IEnumerable<Diagnostic> Check(CSharpCompilation compilation)
|
||||
{
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
foreach (var name in tree.GetRoot().DescendantNodes().OfType<SimpleNameSyntax>())
|
||||
{
|
||||
// A.B.C 只检查最右边的名字,避免同一处报好几次
|
||||
if (name.Parent is QualifiedNameSyntax q && q.Left == name) continue;
|
||||
// var 推断出的类型在初始化表达式里已经检查过
|
||||
if (name is IdentifierNameSyntax { IsVar: true }) continue;
|
||||
if (name.Parent is MemberAccessExpressionSyntax m && m.Expression == name && model.GetSymbolInfo(name).Symbol is INamespaceOrTypeSymbol) continue;
|
||||
|
||||
var symbol = model.GetSymbolInfo(name).Symbol;
|
||||
if (symbol == null) continue;
|
||||
if (Reason(symbol) is { } reason)
|
||||
{
|
||||
yield return Diagnostic.Create(NotAllowed, name.GetLocation(), Display(symbol), reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Reason(ISymbol symbol)
|
||||
{
|
||||
if (symbol is IDynamicTypeSymbol) return "dynamic 动态调用";
|
||||
// using 指令里的命名空间不算:VS 生成的 AssemblyInfo.cs 会 using System.Runtime.InteropServices
|
||||
if (symbol is INamespaceSymbol) return null;
|
||||
var type = symbol as INamedTypeSymbol ?? symbol.ContainingType;
|
||||
// 方法的返回值、参数类型由调用处检查;这里看符号本身属于哪个类型
|
||||
for (var t = type; t != null; t = t.ContainingType)
|
||||
{
|
||||
var fullName = t.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "");
|
||||
if (DeniedTypes.TryGetValue(fullName, out var reason)) return reason;
|
||||
}
|
||||
if (type == null) return null;
|
||||
// AssemblyInfo.cs 里的 [assembly: AssemblyTitle(...)] 等特性可以用
|
||||
if (type.Name.EndsWith("Attribute") && type.ContainingNamespace.ToDisplayString() == "System.Reflection") return null;
|
||||
var ns = type.ContainingNamespace.ToDisplayString();
|
||||
foreach (var (denied, reason) in DeniedNamespaces)
|
||||
{
|
||||
if (ns == denied || ns.StartsWith(denied + ".")) return reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string Display(ISymbol symbol) => symbol switch
|
||||
{
|
||||
IDynamicTypeSymbol => "dynamic",
|
||||
INamedTypeSymbol type => type.ToDisplayString(),
|
||||
_ => $"{symbol.ContainingType.ToDisplayString()}.{symbol.Name}",
|
||||
};
|
||||
}
|
||||
@@ -55,15 +55,16 @@ internal static class StudentCompiler
|
||||
using var pdbStream = new MemoryStream();
|
||||
var emit = compilation.Emit(peStream, pdbStream,
|
||||
options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb));
|
||||
var denied = ApiPolicy.Check(compilation).ToList();
|
||||
|
||||
var diagnostics = new JsonArray();
|
||||
foreach (var diagnostic in emit.Diagnostics
|
||||
foreach (var diagnostic in emit.Diagnostics.Concat(denied)
|
||||
.Where(d => d.Severity is DiagnosticSeverity.Error or DiagnosticSeverity.Warning)
|
||||
.OrderBy(d => d.Severity == DiagnosticSeverity.Error ? 0 : 1))
|
||||
{
|
||||
diagnostics.Add(ToJson(diagnostic));
|
||||
}
|
||||
return new Result(emit.Success, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||
return new Result(emit.Success && denied.Count == 0, peStream.ToArray(), pdbStream.ToArray(), diagnostics);
|
||||
}
|
||||
|
||||
private static JsonObject ToJson(Diagnostic diagnostic)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
namespace 登录
|
||||
{
|
||||
partial class AddUser
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.LblUsername = new System.Windows.Forms.Label();
|
||||
this.txtNewUser1 = new System.Windows.Forms.TextBox();
|
||||
this.LblPwd = new System.Windows.Forms.Label();
|
||||
this.txtPass1 = new System.Windows.Forms.TextBox();
|
||||
this.LblCheckPwd = new System.Windows.Forms.Label();
|
||||
this.txtPass2 = new System.Windows.Forms.TextBox();
|
||||
this.btnOk = new System.Windows.Forms.Button();
|
||||
this.btnCancel = new System.Windows.Forms.Button();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// LblUsername
|
||||
//
|
||||
this.LblUsername.AutoSize = true;
|
||||
this.LblUsername.Location = new System.Drawing.Point(25, 27);
|
||||
this.LblUsername.Name = "LblUsername";
|
||||
this.LblUsername.Size = new System.Drawing.Size(41, 12);
|
||||
this.LblUsername.TabIndex = 0;
|
||||
this.LblUsername.Text = "用户名";
|
||||
//
|
||||
// txtNewUser1
|
||||
//
|
||||
this.txtNewUser1.Location = new System.Drawing.Point(90, 24);
|
||||
this.txtNewUser1.Name = "txtNewUser1";
|
||||
this.txtNewUser1.Size = new System.Drawing.Size(110, 21);
|
||||
this.txtNewUser1.TabIndex = 1;
|
||||
//
|
||||
// LblPwd
|
||||
//
|
||||
this.LblPwd.AutoSize = true;
|
||||
this.LblPwd.Location = new System.Drawing.Point(25, 57);
|
||||
this.LblPwd.Name = "LblPwd";
|
||||
this.LblPwd.Size = new System.Drawing.Size(29, 12);
|
||||
this.LblPwd.TabIndex = 2;
|
||||
this.LblPwd.Text = "密码";
|
||||
//
|
||||
// txtPass1
|
||||
//
|
||||
this.txtPass1.Location = new System.Drawing.Point(90, 54);
|
||||
this.txtPass1.Name = "txtPass1";
|
||||
this.txtPass1.PasswordChar = '*';
|
||||
this.txtPass1.Size = new System.Drawing.Size(110, 21);
|
||||
this.txtPass1.TabIndex = 3;
|
||||
//
|
||||
// LblCheckPwd
|
||||
//
|
||||
this.LblCheckPwd.AutoSize = true;
|
||||
this.LblCheckPwd.Location = new System.Drawing.Point(25, 87);
|
||||
this.LblCheckPwd.Name = "LblCheckPwd";
|
||||
this.LblCheckPwd.Size = new System.Drawing.Size(53, 12);
|
||||
this.LblCheckPwd.TabIndex = 4;
|
||||
this.LblCheckPwd.Text = "确认密码";
|
||||
//
|
||||
// txtPass2
|
||||
//
|
||||
this.txtPass2.Location = new System.Drawing.Point(90, 84);
|
||||
this.txtPass2.Name = "txtPass2";
|
||||
this.txtPass2.PasswordChar = '*';
|
||||
this.txtPass2.Size = new System.Drawing.Size(110, 21);
|
||||
this.txtPass2.TabIndex = 5;
|
||||
//
|
||||
// btnOk
|
||||
//
|
||||
this.btnOk.Location = new System.Drawing.Point(35, 120);
|
||||
this.btnOk.Name = "btnOk";
|
||||
this.btnOk.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnOk.TabIndex = 6;
|
||||
this.btnOk.Text = "确定";
|
||||
this.btnOk.UseVisualStyleBackColor = true;
|
||||
this.btnOk.Click += new System.EventHandler(this.btnOk_Click);
|
||||
//
|
||||
// btnCancel
|
||||
//
|
||||
this.btnCancel.Location = new System.Drawing.Point(125, 120);
|
||||
this.btnCancel.Name = "btnCancel";
|
||||
this.btnCancel.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnCancel.TabIndex = 7;
|
||||
this.btnCancel.Text = "取消";
|
||||
this.btnCancel.UseVisualStyleBackColor = true;
|
||||
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
|
||||
//
|
||||
// AddUser
|
||||
//
|
||||
this.AcceptButton = this.btnOk;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(220, 160);
|
||||
this.Controls.Add(this.btnCancel);
|
||||
this.Controls.Add(this.btnOk);
|
||||
this.Controls.Add(this.txtPass2);
|
||||
this.Controls.Add(this.LblCheckPwd);
|
||||
this.Controls.Add(this.txtPass1);
|
||||
this.Controls.Add(this.LblPwd);
|
||||
this.Controls.Add(this.txtNewUser1);
|
||||
this.Controls.Add(this.LblUsername);
|
||||
this.Name = "AddUser";
|
||||
this.Text = "增加用户";
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label LblUsername;
|
||||
private System.Windows.Forms.TextBox txtNewUser1;
|
||||
private System.Windows.Forms.Label LblPwd;
|
||||
private System.Windows.Forms.TextBox txtPass1;
|
||||
private System.Windows.Forms.Label LblCheckPwd;
|
||||
private System.Windows.Forms.TextBox txtPass2;
|
||||
private System.Windows.Forms.Button btnOk;
|
||||
private System.Windows.Forms.Button btnCancel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 登录
|
||||
{
|
||||
public partial class AddUser : Form
|
||||
{
|
||||
public AddUser()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void btnOk_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (PublicData.intNum < 4)//确保还有足够的存储空间
|
||||
{
|
||||
if (txtPass1.Text.Equals(txtPass2.Text) && txtPass2.Text != "")//确保两次密码相同且非空
|
||||
{
|
||||
//存储新增用户
|
||||
PublicData.SysUserName[PublicData.intNum] = txtNewUser1.Text;
|
||||
PublicData.SysPwd[PublicData.intNum] = txtPass1.Text;
|
||||
PublicData.intNum++;//增加实际存储数量
|
||||
this.Close();//关闭当前窗体
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("用户名或密码错误,请重新输入", "新增用户");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show("用户名已满不能增加", "新增用户");
|
||||
}
|
||||
}
|
||||
|
||||
private void btnCancel_Click(object sender, EventArgs e)
|
||||
{
|
||||
this.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
namespace 登录
|
||||
{
|
||||
partial class Login
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.LblUsername = new System.Windows.Forms.Label();
|
||||
this.txtUserName = new System.Windows.Forms.TextBox();
|
||||
this.LblPwd = new System.Windows.Forms.Label();
|
||||
this.txtPassword = new System.Windows.Forms.TextBox();
|
||||
this.btnOk = new System.Windows.Forms.Button();
|
||||
this.btnCancel = new System.Windows.Forms.Button();
|
||||
this.btnAdd = new System.Windows.Forms.Button();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// LblUsername
|
||||
//
|
||||
this.LblUsername.AutoSize = true;
|
||||
this.LblUsername.Location = new System.Drawing.Point(30, 30);
|
||||
this.LblUsername.Name = "LblUsername";
|
||||
this.LblUsername.Size = new System.Drawing.Size(53, 12);
|
||||
this.LblUsername.TabIndex = 0;
|
||||
this.LblUsername.Text = "用户名称";
|
||||
//
|
||||
// txtUserName
|
||||
//
|
||||
this.txtUserName.Location = new System.Drawing.Point(100, 27);
|
||||
this.txtUserName.Name = "txtUserName";
|
||||
this.txtUserName.Size = new System.Drawing.Size(160, 21);
|
||||
this.txtUserName.TabIndex = 1;
|
||||
//
|
||||
// LblPwd
|
||||
//
|
||||
this.LblPwd.AutoSize = true;
|
||||
this.LblPwd.Location = new System.Drawing.Point(30, 66);
|
||||
this.LblPwd.Name = "LblPwd";
|
||||
this.LblPwd.Size = new System.Drawing.Size(29, 12);
|
||||
this.LblPwd.TabIndex = 2;
|
||||
this.LblPwd.Text = "密码";
|
||||
//
|
||||
// txtPassword
|
||||
//
|
||||
this.txtPassword.Location = new System.Drawing.Point(100, 63);
|
||||
this.txtPassword.Name = "txtPassword";
|
||||
this.txtPassword.PasswordChar = '*';
|
||||
this.txtPassword.Size = new System.Drawing.Size(160, 21);
|
||||
this.txtPassword.TabIndex = 3;
|
||||
//
|
||||
// btnOk
|
||||
//
|
||||
this.btnOk.Location = new System.Drawing.Point(30, 105);
|
||||
this.btnOk.Name = "btnOk";
|
||||
this.btnOk.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnOk.TabIndex = 4;
|
||||
this.btnOk.Text = "登录";
|
||||
this.btnOk.UseVisualStyleBackColor = true;
|
||||
this.btnOk.Click += new System.EventHandler(this.btnOk_Click);
|
||||
//
|
||||
// btnCancel
|
||||
//
|
||||
this.btnCancel.Location = new System.Drawing.Point(115, 105);
|
||||
this.btnCancel.Name = "btnCancel";
|
||||
this.btnCancel.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnCancel.TabIndex = 5;
|
||||
this.btnCancel.Text = "取消";
|
||||
this.btnCancel.UseVisualStyleBackColor = true;
|
||||
this.btnCancel.Click += new System.EventHandler(this.btnCancel_Click);
|
||||
//
|
||||
// btnAdd
|
||||
//
|
||||
this.btnAdd.Location = new System.Drawing.Point(200, 105);
|
||||
this.btnAdd.Name = "btnAdd";
|
||||
this.btnAdd.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnAdd.TabIndex = 6;
|
||||
this.btnAdd.Text = "增加用户";
|
||||
this.btnAdd.UseVisualStyleBackColor = true;
|
||||
this.btnAdd.Click += new System.EventHandler(this.btnAdd_Click);
|
||||
//
|
||||
// Login
|
||||
//
|
||||
this.AcceptButton = this.btnOk;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(300, 150);
|
||||
this.Controls.Add(this.btnAdd);
|
||||
this.Controls.Add(this.btnCancel);
|
||||
this.Controls.Add(this.btnOk);
|
||||
this.Controls.Add(this.txtPassword);
|
||||
this.Controls.Add(this.LblPwd);
|
||||
this.Controls.Add(this.txtUserName);
|
||||
this.Controls.Add(this.LblUsername);
|
||||
this.Name = "Login";
|
||||
this.Text = "登录";
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label LblUsername;
|
||||
private System.Windows.Forms.TextBox txtUserName;
|
||||
private System.Windows.Forms.Label LblPwd;
|
||||
private System.Windows.Forms.TextBox txtPassword;
|
||||
private System.Windows.Forms.Button btnOk;
|
||||
private System.Windows.Forms.Button btnCancel;
|
||||
private System.Windows.Forms.Button btnAdd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 登录
|
||||
{
|
||||
public partial class Login : Form
|
||||
{
|
||||
public Login()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public bool IsLogin = false;//定义登录标识
|
||||
|
||||
private void btnOk_Click(object sender, EventArgs e)
|
||||
{
|
||||
for (int i = 0; i < PublicData.SysUserName.Length; i++)
|
||||
{
|
||||
string usr = PublicData.SysUserName[i];
|
||||
string pwd = PublicData.SysPwd[i];
|
||||
if (txtUserName.Text == usr && txtPassword.Text == pwd && txtUserName.Text != "")//遍历用户名密码是否存在
|
||||
{
|
||||
IsLogin = true;//变更登录标识
|
||||
MessageBox.Show("登录成功,欢迎进入!", "登录");
|
||||
PublicData.intUser = i;//存储当前用户在数组中的索引
|
||||
break;//结束循环
|
||||
}
|
||||
else
|
||||
IsLogin = false;
|
||||
}
|
||||
if (IsLogin == false)
|
||||
{
|
||||
MessageBox.Show("无效的密码或用户名,请重试", "登录");
|
||||
txtPassword.Focus();
|
||||
}
|
||||
}
|
||||
|
||||
private void btnCancel_Click(object sender, EventArgs e)
|
||||
{
|
||||
IsLogin = false;
|
||||
this.Close();//关闭当前窗体
|
||||
}
|
||||
|
||||
private void btnAdd_Click(object sender, EventArgs e)
|
||||
{
|
||||
AddUser addfrm = new AddUser();//实例化添加用户窗体
|
||||
addfrm.Show();//显示增加用户窗体
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 登录
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Login());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace 登录
|
||||
{
|
||||
public class PublicData
|
||||
{
|
||||
public static string[] SysUserName = new string[4];//存储系统用户名
|
||||
public static string[] SysPwd = new string[4];//存储系统用户密码
|
||||
public static int intNum { get; set; }//当前数据存储的用户数量
|
||||
public static int intUser { get; set; }//记录当前用户索引号
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目1 任务1 C#引入(登录与增加用户)",
|
||||
"source": "教材书 p2–7,按书上代码整理。修正:删去未声明的变量 change;增加用户窗体关闭后会被释放,改为每次点击时新建"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace C_基础
|
||||
{
|
||||
class American : People//继承People类
|
||||
{
|
||||
public override void Speak(System.Windows.Forms.TextBox Txtbox, string name)//重写父类中的虚方法
|
||||
{
|
||||
base.Speak(Txtbox, name+"说的是英语!\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace C_基础
|
||||
{
|
||||
class Chinese:People//继承People类
|
||||
{
|
||||
public override void Speak(System.Windows.Forms.TextBox Txtbox, string name)//重写父类中的虚方法
|
||||
{
|
||||
base.Speak(Txtbox, name+"说的是汉语!\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
namespace C_基础
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.groupBox1 = new System.Windows.Forms.GroupBox();
|
||||
this.btnCalculate = new System.Windows.Forms.Button();
|
||||
this.txtResult = new System.Windows.Forms.TextBox();
|
||||
this.cmboxType = new System.Windows.Forms.ComboBox();
|
||||
this.txtNum2 = new System.Windows.Forms.TextBox();
|
||||
this.txtNum1 = new System.Windows.Forms.TextBox();
|
||||
this.groupBox2 = new System.Windows.Forms.GroupBox();
|
||||
this.txtDispaly = new System.Windows.Forms.TextBox();
|
||||
this.btnDisplay = new System.Windows.Forms.Button();
|
||||
this.groupBox1.SuspendLayout();
|
||||
this.groupBox2.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// groupBox1
|
||||
//
|
||||
this.groupBox1.Controls.Add(this.btnCalculate);
|
||||
this.groupBox1.Controls.Add(this.txtResult);
|
||||
this.groupBox1.Controls.Add(this.cmboxType);
|
||||
this.groupBox1.Controls.Add(this.txtNum2);
|
||||
this.groupBox1.Controls.Add(this.txtNum1);
|
||||
this.groupBox1.Location = new System.Drawing.Point(21, 12);
|
||||
this.groupBox1.Name = "groupBox1";
|
||||
this.groupBox1.Size = new System.Drawing.Size(516, 80);
|
||||
this.groupBox1.TabIndex = 0;
|
||||
this.groupBox1.TabStop = false;
|
||||
this.groupBox1.Text = "简易计算器";
|
||||
//
|
||||
// btnCalculate
|
||||
//
|
||||
this.btnCalculate.Location = new System.Drawing.Point(325, 31);
|
||||
this.btnCalculate.Name = "btnCalculate";
|
||||
this.btnCalculate.Size = new System.Drawing.Size(49, 23);
|
||||
this.btnCalculate.TabIndex = 5;
|
||||
this.btnCalculate.Text = "=";
|
||||
this.btnCalculate.UseVisualStyleBackColor = true;
|
||||
this.btnCalculate.Click += new System.EventHandler(this.btnCalculate_Click);
|
||||
//
|
||||
// txtResult
|
||||
//
|
||||
this.txtResult.Location = new System.Drawing.Point(390, 31);
|
||||
this.txtResult.Name = "txtResult";
|
||||
this.txtResult.ReadOnly = true;
|
||||
this.txtResult.Size = new System.Drawing.Size(100, 21);
|
||||
this.txtResult.TabIndex = 4;
|
||||
//
|
||||
// cmboxType
|
||||
//
|
||||
this.cmboxType.FormattingEnabled = true;
|
||||
this.cmboxType.Items.AddRange(new object[] {
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/"});
|
||||
this.cmboxType.Location = new System.Drawing.Point(112, 31);
|
||||
this.cmboxType.Name = "cmboxType";
|
||||
this.cmboxType.Size = new System.Drawing.Size(64, 20);
|
||||
this.cmboxType.TabIndex = 2;
|
||||
//
|
||||
// txtNum2
|
||||
//
|
||||
this.txtNum2.Location = new System.Drawing.Point(195, 31);
|
||||
this.txtNum2.Name = "txtNum2";
|
||||
this.txtNum2.Size = new System.Drawing.Size(100, 21);
|
||||
this.txtNum2.TabIndex = 1;
|
||||
//
|
||||
// txtNum1
|
||||
//
|
||||
this.txtNum1.Location = new System.Drawing.Point(6, 31);
|
||||
this.txtNum1.Name = "txtNum1";
|
||||
this.txtNum1.Size = new System.Drawing.Size(100, 21);
|
||||
this.txtNum1.TabIndex = 0;
|
||||
//
|
||||
// groupBox2
|
||||
//
|
||||
this.groupBox2.Controls.Add(this.txtDispaly);
|
||||
this.groupBox2.Controls.Add(this.btnDisplay);
|
||||
this.groupBox2.Location = new System.Drawing.Point(21, 98);
|
||||
this.groupBox2.Name = "groupBox2";
|
||||
this.groupBox2.Size = new System.Drawing.Size(516, 116);
|
||||
this.groupBox2.TabIndex = 1;
|
||||
this.groupBox2.TabStop = false;
|
||||
this.groupBox2.Text = "类的多态性";
|
||||
//
|
||||
// txtDispaly
|
||||
//
|
||||
this.txtDispaly.Location = new System.Drawing.Point(46, 20);
|
||||
this.txtDispaly.Multiline = true;
|
||||
this.txtDispaly.Name = "txtDispaly";
|
||||
this.txtDispaly.Size = new System.Drawing.Size(249, 82);
|
||||
this.txtDispaly.TabIndex = 1;
|
||||
//
|
||||
// btnDisplay
|
||||
//
|
||||
this.btnDisplay.Location = new System.Drawing.Point(344, 45);
|
||||
this.btnDisplay.Name = "btnDisplay";
|
||||
this.btnDisplay.Size = new System.Drawing.Size(75, 23);
|
||||
this.btnDisplay.TabIndex = 0;
|
||||
this.btnDisplay.Text = "显示";
|
||||
this.btnDisplay.UseVisualStyleBackColor = true;
|
||||
this.btnDisplay.Click += new System.EventHandler(this.btnDisplay_Click);
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(549, 226);
|
||||
this.Controls.Add(this.groupBox2);
|
||||
this.Controls.Add(this.groupBox1);
|
||||
this.Name = "Form1";
|
||||
this.Text = "C#入门基础";
|
||||
this.groupBox1.ResumeLayout(false);
|
||||
this.groupBox1.PerformLayout();
|
||||
this.groupBox2.ResumeLayout(false);
|
||||
this.groupBox2.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.GroupBox groupBox1;
|
||||
private System.Windows.Forms.TextBox txtResult;
|
||||
private System.Windows.Forms.ComboBox cmboxType;
|
||||
private System.Windows.Forms.TextBox txtNum2;
|
||||
private System.Windows.Forms.TextBox txtNum1;
|
||||
private System.Windows.Forms.Button btnCalculate;
|
||||
private System.Windows.Forms.GroupBox groupBox2;
|
||||
private System.Windows.Forms.TextBox txtDispaly;
|
||||
private System.Windows.Forms.Button btnDisplay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace C_基础
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void btnCalculate_Click(object sender, EventArgs e)
|
||||
{
|
||||
if(txtNum1.Text.Trim()!=""&&txtNum2.Text.Trim()!="")
|
||||
{
|
||||
float a, b;
|
||||
a = float.Parse(txtNum1.Text);//字符串转化为float
|
||||
b = float.Parse(txtNum2.Text);//字符串转化为float
|
||||
float res = (float)Result(a, b, cmboxType.Text);//两数进行装箱,调用计算函数,返回值进行拆箱
|
||||
txtResult.Text = res.ToString();//将返回值显示到界面上
|
||||
}
|
||||
}
|
||||
public Object Result(Object a,Object b,String type)//计算结果,使用拆箱装箱方式
|
||||
{
|
||||
float result=0;
|
||||
float a1,b1;
|
||||
a1=(float)a;//拆箱
|
||||
b1=(float)b;//拆箱
|
||||
switch(type)
|
||||
{
|
||||
case "+": result = a1 + b1; break;//计算两个数的和
|
||||
case "-": result = a1 - b1; break;//计算两个数的差
|
||||
case "*": result = a1 * b1; break;//计算两个数的积
|
||||
case "/": result = a1 / b1; break;//计算两个数的商
|
||||
default: result=0;break;//默认值
|
||||
}
|
||||
return (Object)result;//装箱
|
||||
}
|
||||
|
||||
private void btnDisplay_Click(object sender, EventArgs e)
|
||||
{
|
||||
People pleTemp;//定义People临时存储变量
|
||||
Dictionary<int, string> dicName = new Dictionary<int, string>();//定义Dictionary字典类型,用于存储姓名与序号
|
||||
dicName.Add(1, "张三");//向字典中添加数据
|
||||
dicName.Add(2, "李四");
|
||||
dicName.Add(3, "Mary");
|
||||
dicName.Add(4, "Jimmy");
|
||||
List<People> peoplelist = new List<People>();//定义People的List泛型数据
|
||||
for (int i = 0; i < 2; i++)//添加两个中国人
|
||||
{
|
||||
pleTemp = new Chinese();//实例化中国人
|
||||
peoplelist.Add(pleTemp);
|
||||
}
|
||||
for (int i = 0; i < 2; i++)//添加两个美国人
|
||||
{
|
||||
pleTemp = new American();//实例化美国人
|
||||
peoplelist.Add(pleTemp);
|
||||
}
|
||||
for (int i = 0; i < peoplelist.Count; i++)//遍历List
|
||||
{
|
||||
peoplelist[i].Speak(this.txtDispaly, dicName[i + 1]);//执行派生类重写的方法
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace C_基础
|
||||
{
|
||||
class People//定义一个父类People
|
||||
{
|
||||
public virtual void Speak(TextBox Txtbox,string name)//定义一个虚方法,用来表示人类的说话语言
|
||||
{
|
||||
Txtbox.Text += name;//向控件中输出名字
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace C_基础
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目1 任务2 C#面向对象特性(计算器、多态)",
|
||||
"source": "教材书 p13–16,界面取自配套参考工程,控件名和窗体标题按书修改(BtnCalculate → btnCalculate)"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
namespace 红外对射
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
this.lbl_port = new System.Windows.Forms.Label();
|
||||
this.com_port = new System.Windows.Forms.ComboBox();
|
||||
this.btn_open = new System.Windows.Forms.Button();
|
||||
this.timer1 = new System.Windows.Forms.Timer(this.components);
|
||||
this.groupBox1 = new System.Windows.Forms.GroupBox();
|
||||
this.txt_even = new System.Windows.Forms.TextBox();
|
||||
this.txt_time = new System.Windows.Forms.TextBox();
|
||||
this.lbl_even = new System.Windows.Forms.Label();
|
||||
this.lbl_time = new System.Windows.Forms.Label();
|
||||
this.groupBox1.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// lbl_port
|
||||
//
|
||||
this.lbl_port.AutoSize = true;
|
||||
this.lbl_port.Location = new System.Drawing.Point(40, 25);
|
||||
this.lbl_port.Name = "lbl_port";
|
||||
this.lbl_port.Size = new System.Drawing.Size(53, 12);
|
||||
this.lbl_port.TabIndex = 1;
|
||||
this.lbl_port.Text = "环境串口";
|
||||
//
|
||||
// com_port
|
||||
//
|
||||
this.com_port.FormattingEnabled = true;
|
||||
this.com_port.Location = new System.Drawing.Point(99, 22);
|
||||
this.com_port.Name = "com_port";
|
||||
this.com_port.Size = new System.Drawing.Size(121, 20);
|
||||
this.com_port.TabIndex = 2;
|
||||
//
|
||||
// btn_open
|
||||
//
|
||||
this.btn_open.Location = new System.Drawing.Point(236, 20);
|
||||
this.btn_open.Name = "btn_open";
|
||||
this.btn_open.Size = new System.Drawing.Size(75, 23);
|
||||
this.btn_open.TabIndex = 3;
|
||||
this.btn_open.Text = "开启";
|
||||
this.btn_open.UseVisualStyleBackColor = true;
|
||||
this.btn_open.Click += new System.EventHandler(this.btn_open_Click);
|
||||
//
|
||||
// timer1
|
||||
//
|
||||
this.timer1.Interval = 2000;
|
||||
this.timer1.Tick += new System.EventHandler(this.timer1_Tick);
|
||||
//
|
||||
// groupBox1
|
||||
//
|
||||
this.groupBox1.Controls.Add(this.txt_even);
|
||||
this.groupBox1.Controls.Add(this.txt_time);
|
||||
this.groupBox1.Controls.Add(this.lbl_even);
|
||||
this.groupBox1.Controls.Add(this.lbl_time);
|
||||
this.groupBox1.Location = new System.Drawing.Point(20, 57);
|
||||
this.groupBox1.Name = "groupBox1";
|
||||
this.groupBox1.Size = new System.Drawing.Size(362, 151);
|
||||
this.groupBox1.TabIndex = 5;
|
||||
this.groupBox1.TabStop = false;
|
||||
this.groupBox1.Text = "数据显示";
|
||||
//
|
||||
// txt_even
|
||||
//
|
||||
this.txt_even.Location = new System.Drawing.Point(100, 70);
|
||||
this.txt_even.Name = "txt_even";
|
||||
this.txt_even.Size = new System.Drawing.Size(191, 21);
|
||||
this.txt_even.TabIndex = 3;
|
||||
//
|
||||
// txt_time
|
||||
//
|
||||
this.txt_time.Location = new System.Drawing.Point(100, 34);
|
||||
this.txt_time.Name = "txt_time";
|
||||
this.txt_time.Size = new System.Drawing.Size(191, 21);
|
||||
this.txt_time.TabIndex = 2;
|
||||
//
|
||||
// lbl_even
|
||||
//
|
||||
this.lbl_even.AutoSize = true;
|
||||
this.lbl_even.Location = new System.Drawing.Point(34, 73);
|
||||
this.lbl_even.Name = "lbl_even";
|
||||
this.lbl_even.Size = new System.Drawing.Size(29, 12);
|
||||
this.lbl_even.TabIndex = 1;
|
||||
this.lbl_even.Text = "入侵";
|
||||
//
|
||||
// lbl_time
|
||||
//
|
||||
this.lbl_time.AutoSize = true;
|
||||
this.lbl_time.Location = new System.Drawing.Point(32, 37);
|
||||
this.lbl_time.Name = "lbl_time";
|
||||
this.lbl_time.Size = new System.Drawing.Size(29, 12);
|
||||
this.lbl_time.TabIndex = 0;
|
||||
this.lbl_time.Text = "时间";
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(405, 220);
|
||||
this.Controls.Add(this.groupBox1);
|
||||
this.Controls.Add(this.btn_open);
|
||||
this.Controls.Add(this.com_port);
|
||||
this.Controls.Add(this.lbl_port);
|
||||
this.Name = "Form1";
|
||||
this.Text = "红外对射";
|
||||
this.Load += new System.EventHandler(this.Form1_Load);
|
||||
this.groupBox1.ResumeLayout(false);
|
||||
this.groupBox1.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label lbl_port;
|
||||
private System.Windows.Forms.ComboBox com_port;
|
||||
private System.Windows.Forms.Button btn_open;
|
||||
private System.Windows.Forms.Timer timer1;
|
||||
private System.Windows.Forms.GroupBox groupBox1;
|
||||
private System.Windows.Forms.TextBox txt_even;
|
||||
private System.Windows.Forms.TextBox txt_time;
|
||||
private System.Windows.Forms.Label lbl_even;
|
||||
private System.Windows.Forms.Label lbl_time;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using System.IO.Ports;
|
||||
using DeviceCtrlLibrary;
|
||||
|
||||
namespace 红外对射
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
bool IsOpen = false;
|
||||
|
||||
private void Form1_Load(object sender, EventArgs e)
|
||||
{
|
||||
com_port.Items.Clear();
|
||||
foreach (string item in SerialPort.GetPortNames())
|
||||
{
|
||||
com_port.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void btn_open_Click(object sender, EventArgs e)
|
||||
{
|
||||
IsOpen = !IsOpen;
|
||||
if (IsOpen)
|
||||
{
|
||||
//打开数字量采集器所在的串口,开始定时采集
|
||||
DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);
|
||||
timer1.Start();
|
||||
btn_open.Text = "关闭";
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceCtrlHelper.adam4150Helper.Close();
|
||||
timer1.Stop();
|
||||
btn_open.Text = "开启";
|
||||
}
|
||||
}
|
||||
|
||||
private void timer1_Tick(object sender, EventArgs e)
|
||||
{
|
||||
txt_time.Text = DateTime.Now.ToString();
|
||||
//获取红外对射入侵传感器的数据:true 表示有入侵
|
||||
if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)
|
||||
txt_even.Text = "发现入侵";
|
||||
else
|
||||
txt_even.Text = "正常";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 红外对射
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目4 任务1 采集红外对射入侵传感器数据",
|
||||
"source": "教材书 p73–76,界面取自配套参考工程,控件名按书上表格修改;修正书上 boolIsOpen 少空格"
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
namespace 报警灯
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.alarm1 = new WinFormsDeviceControlLibrary.Alarm();
|
||||
this.btn_alarmOpen = new System.Windows.Forms.Button();
|
||||
this.btn_alarmClose = new System.Windows.Forms.Button();
|
||||
this.groupBox1 = new System.Windows.Forms.GroupBox();
|
||||
this.btn_open = new System.Windows.Forms.Button();
|
||||
this.com_port = new System.Windows.Forms.ComboBox();
|
||||
this.label1 = new System.Windows.Forms.Label();
|
||||
this.groupBox1.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// alarm1
|
||||
//
|
||||
this.alarm1.BackColor = System.Drawing.Color.White;
|
||||
this.alarm1.Location = new System.Drawing.Point(12, 12);
|
||||
this.alarm1.Name = "alarm1";
|
||||
this.alarm1.Size = new System.Drawing.Size(150, 150);
|
||||
this.alarm1.TabIndex = 0;
|
||||
//
|
||||
// btn_alarmOpen
|
||||
//
|
||||
this.btn_alarmOpen.Location = new System.Drawing.Point(12, 192);
|
||||
this.btn_alarmOpen.Name = "btn_alarmOpen";
|
||||
this.btn_alarmOpen.Size = new System.Drawing.Size(35, 23);
|
||||
this.btn_alarmOpen.TabIndex = 1;
|
||||
this.btn_alarmOpen.Text = "开";
|
||||
this.btn_alarmOpen.UseVisualStyleBackColor = true;
|
||||
this.btn_alarmOpen.Click += new System.EventHandler(this.btn_alarmOpen_Click);
|
||||
//
|
||||
// btn_alarmClose
|
||||
//
|
||||
this.btn_alarmClose.Location = new System.Drawing.Point(127, 192);
|
||||
this.btn_alarmClose.Name = "btn_alarmClose";
|
||||
this.btn_alarmClose.Size = new System.Drawing.Size(35, 23);
|
||||
this.btn_alarmClose.TabIndex = 2;
|
||||
this.btn_alarmClose.Text = "关";
|
||||
this.btn_alarmClose.UseVisualStyleBackColor = true;
|
||||
this.btn_alarmClose.Click += new System.EventHandler(this.btn_alarmClose_Click);
|
||||
//
|
||||
// groupBox1
|
||||
//
|
||||
this.groupBox1.Controls.Add(this.btn_open);
|
||||
this.groupBox1.Controls.Add(this.com_port);
|
||||
this.groupBox1.Controls.Add(this.label1);
|
||||
this.groupBox1.Location = new System.Drawing.Point(195, 58);
|
||||
this.groupBox1.Name = "groupBox1";
|
||||
this.groupBox1.Size = new System.Drawing.Size(218, 114);
|
||||
this.groupBox1.TabIndex = 3;
|
||||
this.groupBox1.TabStop = false;
|
||||
this.groupBox1.Text = "配置";
|
||||
//
|
||||
// btn_open
|
||||
//
|
||||
this.btn_open.Location = new System.Drawing.Point(113, 71);
|
||||
this.btn_open.Name = "btn_open";
|
||||
this.btn_open.Size = new System.Drawing.Size(75, 23);
|
||||
this.btn_open.TabIndex = 2;
|
||||
this.btn_open.Text = "开启";
|
||||
this.btn_open.UseVisualStyleBackColor = true;
|
||||
this.btn_open.Click += new System.EventHandler(this.btn_open_Click);
|
||||
//
|
||||
// com_port
|
||||
//
|
||||
this.com_port.FormattingEnabled = true;
|
||||
this.com_port.Location = new System.Drawing.Point(67, 33);
|
||||
this.com_port.Name = "com_port";
|
||||
this.com_port.Size = new System.Drawing.Size(121, 20);
|
||||
this.com_port.TabIndex = 1;
|
||||
//
|
||||
// label1
|
||||
//
|
||||
this.label1.AutoSize = true;
|
||||
this.label1.Location = new System.Drawing.Point(20, 36);
|
||||
this.label1.Name = "label1";
|
||||
this.label1.Size = new System.Drawing.Size(41, 12);
|
||||
this.label1.TabIndex = 0;
|
||||
this.label1.Text = "报警灯";
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.BackColor = System.Drawing.Color.White;
|
||||
this.ClientSize = new System.Drawing.Size(425, 262);
|
||||
this.Controls.Add(this.groupBox1);
|
||||
this.Controls.Add(this.btn_alarmClose);
|
||||
this.Controls.Add(this.btn_alarmOpen);
|
||||
this.Controls.Add(this.alarm1);
|
||||
this.Name = "Form1";
|
||||
this.Text = "报警灯";
|
||||
this.Load += new System.EventHandler(this.Form1_Load);
|
||||
this.groupBox1.ResumeLayout(false);
|
||||
this.groupBox1.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private WinFormsDeviceControlLibrary.Alarm alarm1;
|
||||
private System.Windows.Forms.Button btn_alarmOpen;
|
||||
private System.Windows.Forms.Button btn_alarmClose;
|
||||
private System.Windows.Forms.GroupBox groupBox1;
|
||||
private System.Windows.Forms.Button btn_open;
|
||||
private System.Windows.Forms.ComboBox com_port;
|
||||
private System.Windows.Forms.Label label1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using System.IO.Ports;
|
||||
using DeviceCtrlLibrary;
|
||||
|
||||
namespace 报警灯
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
bool IsOpen = false;
|
||||
|
||||
private void Form1_Load(object sender, EventArgs e)
|
||||
{
|
||||
com_port.Items.Clear();
|
||||
foreach (string item in SerialPort.GetPortNames())
|
||||
{
|
||||
com_port.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void AlarmOnOff(string Device, bool IsOpen)
|
||||
{
|
||||
//利用switch语句打开或关闭继电器模块
|
||||
switch (Device)
|
||||
{
|
||||
case "Alarm":
|
||||
DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);//报警灯接入DO0
|
||||
alarm1.OnOff(IsOpen);//更改界面报警灯状态
|
||||
break;
|
||||
case "LED1": DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen); break;//开启或关闭继电器1模块
|
||||
case "LED2": DeviceCtrlHelper.adam4150Helper.OnOff(2, IsOpen); break;//开启或关闭继电器2模块
|
||||
default: MessageBox.Show("没有合适的接口程序!", "错误提示!"); break;//若为其他模块,则提示错误
|
||||
}
|
||||
}
|
||||
|
||||
private void btn_open_Click(object sender, EventArgs e)
|
||||
{
|
||||
IsOpen = !IsOpen;
|
||||
if (IsOpen)
|
||||
{
|
||||
DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);
|
||||
btn_open.Text = "关闭";
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceCtrlHelper.adam4150Helper.Close();
|
||||
btn_open.Text = "开启";
|
||||
}
|
||||
}
|
||||
|
||||
private void btn_alarmOpen_Click(object sender, EventArgs e)
|
||||
{
|
||||
AlarmOnOff("Alarm", true);
|
||||
}
|
||||
|
||||
private void btn_alarmClose_Click(object sender, EventArgs e)
|
||||
{
|
||||
AlarmOnOff("Alarm", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 报警灯
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目4 任务3 控制报警灯",
|
||||
"source": "教材书 p84–87,界面取自配套参考工程;修正书上 boolIsOpen 少空格"
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
namespace 火焰
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.components = new System.ComponentModel.Container();
|
||||
this.lbl_port = new System.Windows.Forms.Label();
|
||||
this.com_port = new System.Windows.Forms.ComboBox();
|
||||
this.btn_open = new System.Windows.Forms.Button();
|
||||
this.groupBox1 = new System.Windows.Forms.GroupBox();
|
||||
this.txt_even = new System.Windows.Forms.TextBox();
|
||||
this.lbl_even = new System.Windows.Forms.Label();
|
||||
this.txt_time = new System.Windows.Forms.TextBox();
|
||||
this.lbl_time = new System.Windows.Forms.Label();
|
||||
this.timer1 = new System.Windows.Forms.Timer(this.components);
|
||||
this.groupBox1.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// lbl_port
|
||||
//
|
||||
this.lbl_port.AutoSize = true;
|
||||
this.lbl_port.Location = new System.Drawing.Point(53, 21);
|
||||
this.lbl_port.Name = "lbl_port";
|
||||
this.lbl_port.Size = new System.Drawing.Size(29, 12);
|
||||
this.lbl_port.TabIndex = 0;
|
||||
this.lbl_port.Text = "串口";
|
||||
//
|
||||
// com_port
|
||||
//
|
||||
this.com_port.FormattingEnabled = true;
|
||||
this.com_port.Location = new System.Drawing.Point(100, 18);
|
||||
this.com_port.Name = "com_port";
|
||||
this.com_port.Size = new System.Drawing.Size(121, 20);
|
||||
this.com_port.TabIndex = 1;
|
||||
//
|
||||
// btn_open
|
||||
//
|
||||
this.btn_open.Location = new System.Drawing.Point(227, 16);
|
||||
this.btn_open.Name = "btn_open";
|
||||
this.btn_open.Size = new System.Drawing.Size(75, 23);
|
||||
this.btn_open.TabIndex = 2;
|
||||
this.btn_open.Text = "开启";
|
||||
this.btn_open.UseVisualStyleBackColor = true;
|
||||
this.btn_open.Click += new System.EventHandler(this.btn_open_Click);
|
||||
//
|
||||
// groupBox1
|
||||
//
|
||||
this.groupBox1.Controls.Add(this.txt_even);
|
||||
this.groupBox1.Controls.Add(this.lbl_even);
|
||||
this.groupBox1.Controls.Add(this.txt_time);
|
||||
this.groupBox1.Controls.Add(this.lbl_time);
|
||||
this.groupBox1.Location = new System.Drawing.Point(30, 63);
|
||||
this.groupBox1.Name = "groupBox1";
|
||||
this.groupBox1.Size = new System.Drawing.Size(272, 110);
|
||||
this.groupBox1.TabIndex = 3;
|
||||
this.groupBox1.TabStop = false;
|
||||
this.groupBox1.Text = "数据显示";
|
||||
//
|
||||
// txt_even
|
||||
//
|
||||
this.txt_even.Location = new System.Drawing.Point(70, 70);
|
||||
this.txt_even.Name = "txt_even";
|
||||
this.txt_even.Size = new System.Drawing.Size(174, 21);
|
||||
this.txt_even.TabIndex = 3;
|
||||
//
|
||||
// lbl_even
|
||||
//
|
||||
this.lbl_even.AutoSize = true;
|
||||
this.lbl_even.Location = new System.Drawing.Point(23, 73);
|
||||
this.lbl_even.Name = "lbl_even";
|
||||
this.lbl_even.Size = new System.Drawing.Size(29, 12);
|
||||
this.lbl_even.TabIndex = 2;
|
||||
this.lbl_even.Text = "事件";
|
||||
//
|
||||
// txt_time
|
||||
//
|
||||
this.txt_time.Location = new System.Drawing.Point(70, 34);
|
||||
this.txt_time.Name = "txt_time";
|
||||
this.txt_time.Size = new System.Drawing.Size(174, 21);
|
||||
this.txt_time.TabIndex = 1;
|
||||
//
|
||||
// lbl_time
|
||||
//
|
||||
this.lbl_time.AutoSize = true;
|
||||
this.lbl_time.Location = new System.Drawing.Point(23, 37);
|
||||
this.lbl_time.Name = "lbl_time";
|
||||
this.lbl_time.Size = new System.Drawing.Size(29, 12);
|
||||
this.lbl_time.TabIndex = 0;
|
||||
this.lbl_time.Text = "时间";
|
||||
//
|
||||
// timer1
|
||||
//
|
||||
this.timer1.Interval = 2000;
|
||||
this.timer1.Tick += new System.EventHandler(this.timer1_Tick);
|
||||
//
|
||||
// Form1
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(327, 193);
|
||||
this.Controls.Add(this.groupBox1);
|
||||
this.Controls.Add(this.btn_open);
|
||||
this.Controls.Add(this.com_port);
|
||||
this.Controls.Add(this.lbl_port);
|
||||
this.Name = "Form1";
|
||||
this.Text = "火焰";
|
||||
this.Load += new System.EventHandler(this.Form1_Load);
|
||||
this.groupBox1.ResumeLayout(false);
|
||||
this.groupBox1.PerformLayout();
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label lbl_port;
|
||||
private System.Windows.Forms.ComboBox com_port;
|
||||
private System.Windows.Forms.Button btn_open;
|
||||
private System.Windows.Forms.GroupBox groupBox1;
|
||||
private System.Windows.Forms.TextBox txt_even;
|
||||
private System.Windows.Forms.Label lbl_even;
|
||||
private System.Windows.Forms.TextBox txt_time;
|
||||
private System.Windows.Forms.Label lbl_time;
|
||||
private System.Windows.Forms.Timer timer1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using System.IO.Ports;
|
||||
using DeviceCtrlLibrary;
|
||||
|
||||
namespace 火焰
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
bool IsOpen = false;
|
||||
|
||||
private void Form1_Load(object sender, EventArgs e)
|
||||
{
|
||||
//用 for 循环把所有可用的串口号加入列表
|
||||
for (int i = 0; i < SerialPort.GetPortNames().Length; i++)
|
||||
{
|
||||
com_port.Items.Add(SerialPort.GetPortNames()[i].ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private void btn_open_Click(object sender, EventArgs e)
|
||||
{
|
||||
IsOpen = !IsOpen;
|
||||
if (IsOpen)
|
||||
{
|
||||
//打开数字量采集器所在的串口,开始定时采集
|
||||
DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);
|
||||
timer1.Start();
|
||||
btn_open.Text = "关闭";
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceCtrlHelper.adam4150Helper.Close();
|
||||
timer1.Stop();
|
||||
btn_open.Text = "开启";
|
||||
}
|
||||
}
|
||||
|
||||
private String GetFireInfo()
|
||||
{
|
||||
//判断火焰传感器返回bool值是否为true,若为true,则返回火情信息
|
||||
if (DeviceCtrlHelper.adam4150Helper.SetData().fireValue)
|
||||
{
|
||||
return "发现火情!";
|
||||
}
|
||||
return "正常!";//无火情返回正常信息
|
||||
}
|
||||
|
||||
private void timer1_Tick(object sender, EventArgs e)
|
||||
{
|
||||
txt_time.Text = DateTime.Now.ToString();
|
||||
txt_even.Text = GetFireInfo();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace 火焰
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目5 任务1 采集火焰传感器数据",
|
||||
"source": "教材书 p91–94,界面取自配套参考工程,控件名按书上表格修改"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 访问口令:账号系统(第 2 阶段)做好之前,用一个统一口令挡住外人。
|
||||
// 输对口令后发一个 cookie,值是口令的哈希;老师换口令后,旧 cookie 全部失效。
|
||||
import type { Context } from 'hono'
|
||||
import { setCookie } from 'hono/cookie'
|
||||
import { config } from './config'
|
||||
|
||||
const COOKIE = 'iot_access'
|
||||
// 全班通常共用学校的一个出口 IP,上限不能太小,否则几个同学输错就把全班挡在外面
|
||||
const MAX_FAILURES = 30
|
||||
const FAILURE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
const token = config.accessPassword
|
||||
? new Bun.CryptoHasher('sha256').update(`iot-sim-access:${config.accessPassword}`).digest('hex')
|
||||
: ''
|
||||
|
||||
export const accessRequired = token !== ''
|
||||
|
||||
/** 请求是否带着有效的口令 cookie(没设口令时总是放行) */
|
||||
export function hasAccess(cookieHeader: string | null) {
|
||||
if (!accessRequired) return true
|
||||
const value = cookieHeader
|
||||
?.split(';')
|
||||
.map((part) => part.trim().split('='))
|
||||
.find(([name]) => name === COOKIE)?.[1]
|
||||
return value === token
|
||||
}
|
||||
|
||||
/** 每个 IP 一段时间内的失败次数,防止暴力猜口令 */
|
||||
const failures = new Map<string, { count: number; since: number }>()
|
||||
|
||||
export function tooManyFailures(ip: string) {
|
||||
const entry = failures.get(ip)
|
||||
if (!entry) return false
|
||||
if (Date.now() - entry.since > FAILURE_WINDOW_MS) {
|
||||
failures.delete(ip)
|
||||
return false
|
||||
}
|
||||
return entry.count >= MAX_FAILURES
|
||||
}
|
||||
|
||||
/** 检查口令;对了就设置 cookie */
|
||||
export function login(c: Context, password: string, ip: string) {
|
||||
if (password !== config.accessPassword) {
|
||||
const entry = failures.get(ip) ?? { count: 0, since: Date.now() }
|
||||
entry.count++
|
||||
failures.set(ip, entry)
|
||||
return false
|
||||
}
|
||||
failures.delete(ip)
|
||||
setCookie(c, COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
secure: new URL(c.req.url).protocol === 'https:' || c.req.header('x-forwarded-proto') === 'https',
|
||||
path: '/',
|
||||
maxAge: 180 * 24 * 3600,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Subprocess } from 'bun'
|
||||
import type { Diagnostic } from '@iot/shared'
|
||||
import { RUNNER_DLL, readLines } from './runner-process'
|
||||
import { config } from './config'
|
||||
import { readLines } from './runner-process'
|
||||
|
||||
export interface CompileResult {
|
||||
ok: boolean
|
||||
@@ -37,11 +38,11 @@ class CompileService {
|
||||
/** 服务器启动时调用,提前完成编译器的加载和预热 */
|
||||
start() {
|
||||
if (this.ready) return this.ready
|
||||
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'compile-server'], {
|
||||
const proc = Bun.spawn(['dotnet', config.runnerDll, 'compile-server'], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'inherit',
|
||||
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
|
||||
})
|
||||
this.proc = proc
|
||||
this.ready = new Promise<void>((resolveReady) => {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 服务器配置:都可以用环境变量覆盖,开发时不设置也能直接运行。
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const env = process.env
|
||||
|
||||
/** 项目根目录。部署时服务端打包成单个文件,位置变了,用 APP_ROOT 指定 */
|
||||
export const ROOT = env.APP_ROOT ?? resolve(import.meta.dir, '../..')
|
||||
|
||||
export const config = {
|
||||
port: Number(env.PORT ?? 3000),
|
||||
/** 运行时程序(编译服务和运行进程共用),部署时用 Release 版 */
|
||||
runnerDll: resolve(ROOT, env.RUNNER_DLL ?? 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll'),
|
||||
/** 每次运行的临时工程目录放在这里 */
|
||||
runsDir: resolve(ROOT, env.RUNS_DIR ?? '.runs'),
|
||||
samplesDir: resolve(ROOT, env.SAMPLES_DIR ?? 'samples'),
|
||||
/** 打包好的网页(web/dist);目录不存在时不提供网页,开发时由 Vite 提供 */
|
||||
staticDir: resolve(ROOT, env.STATIC_DIR ?? 'web/dist'),
|
||||
/** 学生程序的沙箱:bwrap(bubblewrap)或 none;默认装了 bwrap 就用 */
|
||||
sandbox: (env.SIM_SANDBOX ?? (Bun.which('bwrap') ? 'bwrap' : 'none')) as 'bwrap' | 'none',
|
||||
/** 学生程序托管堆上限(MB) */
|
||||
heapLimitMb: Number(env.SIM_HEAP_LIMIT_MB ?? 128),
|
||||
/**
|
||||
* 一次运行最多用多少秒 CPU。正常的界面程序大部分时间在等事件,一小时也用不了几秒;
|
||||
* 写出死循环的程序会一直占满一个核(服务器只有 2 核),到时间就被结束
|
||||
*/
|
||||
cpuLimitSeconds: Number(env.SIM_CPU_LIMIT_SECONDS ?? 120),
|
||||
/** 全站同时运行的学生程序最多多少个(两个班约 100 人) */
|
||||
maxRuns: Number(env.SIM_MAX_RUNS ?? 150),
|
||||
/** 一次运行最长多少分钟,到时自动结束 */
|
||||
maxRunMinutes: Number(env.SIM_MAX_RUN_MINUTES ?? 60),
|
||||
/** 访问口令;不设置表示不需要口令(本机开发) */
|
||||
accessPassword: env.ACCESS_PASSWORD ?? '',
|
||||
timeZone: env.TZ ?? 'Asia/Shanghai',
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
import { Hono } from 'hono'
|
||||
import type { Server } from 'bun'
|
||||
import type { ClientMessage, ServerMessage } from '@iot/shared'
|
||||
import { accessRequired, hasAccess, login, tooManyFailures } from './access'
|
||||
import { compileService } from './compile-service'
|
||||
import { config } from './config'
|
||||
import { cleanupStaleRuns, RunSession } from './run-session'
|
||||
import { listSamples, loadSample } from './samples'
|
||||
import { checkSandbox, sandboxError } from './sandbox'
|
||||
import { serveStatic } from './static'
|
||||
|
||||
await cleanupStaleRuns()
|
||||
await checkSandbox()
|
||||
if (sandboxError) console.error(sandboxError)
|
||||
|
||||
const app = new Hono()
|
||||
const app = new Hono<{ Bindings: Server<SocketData> }>()
|
||||
|
||||
/** 客户端 IP:经过 Caddy 转发时由 Caddy 填在 X-Real-IP 里(它已核对过前面代理的 X-Forwarded-For) */
|
||||
function clientIp(req: Request, server: Server<SocketData>) {
|
||||
return req.headers.get('x-real-ip') || server.requestIP(req)?.address || ''
|
||||
}
|
||||
|
||||
app.get('/api/auth', (c) => c.json({ required: accessRequired, ok: hasAccess(c.req.header('cookie') ?? null) }))
|
||||
|
||||
app.post('/api/login', async (c) => {
|
||||
const ip = clientIp(c.req.raw, c.env)
|
||||
if (tooManyFailures(ip)) return c.json({ ok: false, message: '输错次数太多,请 10 分钟后再试' }, 429)
|
||||
const { password } = await c.req.json<{ password?: string }>().catch(() => ({ password: undefined }))
|
||||
if (!login(c, String(password ?? ''), ip)) return c.json({ ok: false, message: '口令不对' }, 401)
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
// 其余接口都要先输入口令
|
||||
app.use('/api/*', async (c, next) => {
|
||||
if (!hasAccess(c.req.header('cookie') ?? null)) return c.json({ message: '请先输入访问口令' }, 401)
|
||||
await next()
|
||||
})
|
||||
|
||||
app.get('/api/samples', async (c) => c.json(await listSamples()))
|
||||
|
||||
@@ -15,18 +43,22 @@ app.get('/api/samples/:id', async (c) => {
|
||||
return files ? c.json(files) : c.notFound()
|
||||
})
|
||||
|
||||
// 打包好的网页(部署时);开发时网页由 Vite 提供
|
||||
app.get('*', (c) => serveStatic(new URL(c.req.url).pathname))
|
||||
|
||||
interface SocketData {
|
||||
session?: RunSession
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
port: config.port,
|
||||
fetch(req, server) {
|
||||
if (new URL(req.url).pathname === '/ws') {
|
||||
if (!hasAccess(req.headers.get('cookie'))) return new Response('请先输入访问口令', { status: 401 })
|
||||
if (server.upgrade(req, { data: {} })) return
|
||||
return new Response('WebSocket 升级失败', { status: 400 })
|
||||
}
|
||||
return app.fetch(req)
|
||||
return app.fetch(req, server)
|
||||
},
|
||||
websocket: {
|
||||
data: {} as SocketData,
|
||||
|
||||
@@ -4,9 +4,14 @@ import type { Subprocess } from 'bun'
|
||||
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
|
||||
import { Board } from './board'
|
||||
import { compileService } from './compile-service'
|
||||
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
|
||||
import { config } from './config'
|
||||
import { readLines } from './runner-process'
|
||||
import { cpuLimitMessage, runCommand, sandboxError } from './sandbox'
|
||||
|
||||
const RUNS_DIR = join(ROOT, '.runs')
|
||||
const RUNS_DIR = config.runsDir
|
||||
|
||||
/** 全站正在运行的学生程序数 */
|
||||
let liveRuns = 0
|
||||
|
||||
/** 服务器启动时清掉上次异常退出遗留的运行目录 */
|
||||
export async function cleanupStaleRuns() {
|
||||
@@ -37,6 +42,7 @@ export class RunSession {
|
||||
}
|
||||
|
||||
private async startNow(files: SourceFile[]) {
|
||||
if (sandboxError) throw new Error(sandboxError)
|
||||
await this.dispose()
|
||||
const generation = ++this.generation
|
||||
await mkdir(RUNS_DIR, { recursive: true })
|
||||
@@ -61,20 +67,24 @@ export class RunSession {
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
if (liveRuns >= config.maxRuns) {
|
||||
this.send({ type: 'serverError', message: '服务器繁忙:同时运行的程序太多,请稍后再试' })
|
||||
this.send({ type: 'status', state: 'stopped' })
|
||||
await this.cleanup()
|
||||
return
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
||||
DOTNET_NOLOGO: '1',
|
||||
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
|
||||
SIM_BOARD: JSON.stringify(this.board.runnerInputs()),
|
||||
},
|
||||
})
|
||||
const { cmd, env } = runCommand(dir, { SIM_BOARD: JSON.stringify(this.board.runnerInputs()) })
|
||||
const proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', env })
|
||||
this.proc = proc
|
||||
liveRuns++
|
||||
// 运行太久自动结束,防止忘了关的程序一直占着服务器
|
||||
const timeout = setTimeout(() => {
|
||||
if (this.proc !== proc) return
|
||||
this.send({ type: 'console', text: `\n程序已运行 ${config.maxRunMinutes} 分钟,自动结束。\n` })
|
||||
proc.kill()
|
||||
}, config.maxRunMinutes * 60_000)
|
||||
|
||||
void readLines(proc.stdout, (line) => {
|
||||
let message: RunnerMessage
|
||||
@@ -94,8 +104,12 @@ export class RunSession {
|
||||
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
|
||||
|
||||
proc.exited.then(async (code) => {
|
||||
liveRuns--
|
||||
clearTimeout(timeout)
|
||||
if (this.proc !== proc) return
|
||||
this.proc = undefined
|
||||
const cpuMessage = cpuLimitMessage(code, proc.signalCode)
|
||||
if (cpuMessage) this.send({ type: 'console', text: cpuMessage })
|
||||
this.send({ type: 'exited', code })
|
||||
this.send({ type: 'status', state: 'stopped' })
|
||||
await this.cleanup()
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
export const ROOT = resolve(import.meta.dir, '../..')
|
||||
export const RUNNER_DLL = resolve(ROOT, 'runtime/Sim.Runner/bin/Debug/net10.0/Sim.Runner.dll')
|
||||
|
||||
/** 逐行读取子进程输出 */
|
||||
export async function readLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void) {
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readdir } from 'node:fs/promises'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { config } from './config'
|
||||
|
||||
const SAMPLES_DIR = resolve(import.meta.dir, '../../samples')
|
||||
const SAMPLES_DIR = config.samplesDir
|
||||
|
||||
export interface SampleInfo {
|
||||
id: string
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 学生程序的沙箱:每次运行用 bubblewrap 放进独立的命名空间。
|
||||
// 断网;只能看到系统只读文件、运行时程序和自己的工程目录;/tmp 是独立的内存盘;
|
||||
// 看不到服务器上的其他进程(包括别的学生程序);服务器进程的环境变量也不会传进去。
|
||||
import { lstatSync, readlinkSync, realpathSync } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { config } from './config'
|
||||
|
||||
const RUNNER_DIR = '/sim/runner'
|
||||
const APP_DIR = '/sim/app'
|
||||
|
||||
/** 学生程序的全部环境变量(不继承服务器的) */
|
||||
function runnerEnv(extra: Record<string, string>) {
|
||||
return {
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
HOME: '/tmp',
|
||||
TZ: config.timeZone,
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
||||
DOTNET_NOLOGO: '1',
|
||||
// 学生写出死循环分配内存时,在这里报 OutOfMemoryException,而不是拖垮服务器
|
||||
DOTNET_GCHeapHardLimit: (config.heapLimitMb * 1024 * 1024).toString(16),
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** /lib、/bin 等在新系统上是指向 /usr 的链接,照原样建链接;老系统上是真目录,只读挂载 */
|
||||
function rootLinks(): string[] {
|
||||
const args: string[] = []
|
||||
for (const path of ['/bin', '/sbin', '/lib', '/lib32', '/lib64']) {
|
||||
let stat
|
||||
try {
|
||||
stat = lstatSync(path)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isSymbolicLink()) args.push('--symlink', readlinkSync(path), path)
|
||||
else if (stat.isDirectory()) args.push('--ro-bind', path, path)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
/** dotnet 不在 /usr 下时(比如装在 /opt),把它所在目录也挂进去 */
|
||||
function dotnetMounts(): string[] {
|
||||
const dotnet = Bun.which('dotnet')
|
||||
if (!dotnet) return []
|
||||
const home = dirname(realpathSync(dotnet))
|
||||
return home.startsWith('/usr/') ? [] : ['--ro-bind', home, home]
|
||||
}
|
||||
|
||||
/** 沙箱不可用的原因;可用(或没启用沙箱)时为 undefined。服务器启动时检查一次 */
|
||||
export let sandboxError: string | undefined
|
||||
|
||||
export async function checkSandbox() {
|
||||
if (config.sandbox === 'none') return
|
||||
await mkdir(config.runsDir, { recursive: true })
|
||||
const proc = Bun.spawn([...bwrapArgs(config.runsDir), 'true'], { stdout: 'ignore', stderr: 'pipe', env: runnerEnv({}) })
|
||||
const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()])
|
||||
if (code === 0) return
|
||||
sandboxError =
|
||||
`沙箱(bubblewrap)无法启动:${stderr.trim().replace(/[.。]$/, '')}。` +
|
||||
'学生程序不会在没有隔离的情况下运行。检查 compose.yaml 里的 security_opt;' +
|
||||
'Ubuntu 23.10 及以上的宿主机还要执行 sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0(见 deploy/README.md)'
|
||||
}
|
||||
|
||||
/** 启动一次学生程序运行的命令行和环境变量 */
|
||||
export function runCommand(projectDir: string, extraEnv: Record<string, string>) {
|
||||
const env = runnerEnv(extraEnv)
|
||||
if (config.sandbox === 'none') {
|
||||
return { cmd: ['dotnet', config.runnerDll, 'run', projectDir], env }
|
||||
}
|
||||
const cmd = [
|
||||
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
|
||||
'nice', '-n', '10',
|
||||
// CPU 时间用完先收到 SIGXCPU 结束,过几秒还在就强制结束
|
||||
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds + 5}`,
|
||||
...bwrapArgs(projectDir),
|
||||
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
|
||||
]
|
||||
return { cmd, env }
|
||||
}
|
||||
|
||||
const SIGXCPU = 24
|
||||
|
||||
/** 运行进程退出时,如果是因为 CPU 时间用完,给学生的说明 */
|
||||
export function cpuLimitMessage(code: number | null, signal: string | number | null) {
|
||||
// bwrap 把子进程"被信号结束"转成退出码 128 + 信号编号
|
||||
return signal === 'SIGXCPU' || code === 128 + SIGXCPU
|
||||
? `\n程序占用 CPU 太多,已被结束(累计超过 ${config.cpuLimitSeconds} 秒)。检查是否有死循环:循环里要有退出条件,等待请用 Timer 或 Thread.Sleep。\n`
|
||||
: undefined
|
||||
}
|
||||
|
||||
function bwrapArgs(projectDir: string) {
|
||||
return [
|
||||
'bwrap',
|
||||
'--unshare-all',
|
||||
'--die-with-parent',
|
||||
'--new-session',
|
||||
'--ro-bind', '/usr', '/usr',
|
||||
...rootLinks(),
|
||||
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
|
||||
...dotnetMounts(),
|
||||
'--proc', '/proc',
|
||||
'--dev', '/dev',
|
||||
'--tmpfs', '/tmp',
|
||||
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
|
||||
'--ro-bind', projectDir, APP_DIR,
|
||||
'--chdir', APP_DIR,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 提供打包好的网页文件(web/dist)。带哈希的 assets 长期缓存,index.html 每次都检查更新。
|
||||
import { normalize, resolve } from 'node:path'
|
||||
import { config } from './config'
|
||||
|
||||
export async function serveStatic(pathname: string) {
|
||||
const root = config.staticDir
|
||||
let path = resolve(root, `.${normalize(decodeURIComponent(pathname))}`)
|
||||
if (!path.startsWith(`${root}/`) || !(await Bun.file(path).exists())) path = resolve(root, 'index.html')
|
||||
const file = Bun.file(path)
|
||||
if (!(await file.exists())) return new Response('网页还没有打包:bun run --filter web build', { status: 404 })
|
||||
const immutable = path.startsWith(`${root}/assets/`)
|
||||
return new Response(file, {
|
||||
headers: { 'Cache-Control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache' },
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ControlNode, RunnerMessage } from '@iot/shared'
|
||||
import { compileService } from '../src/compile-service'
|
||||
import { ROOT, RUNNER_DLL, readLines } from '../src/runner-process'
|
||||
import { config, ROOT } from '../src/config'
|
||||
import { readLines } from '../src/runner-process'
|
||||
|
||||
const COUNT = Number(process.argv[2] ?? 100)
|
||||
const dir = resolve(ROOT, '.runs/load-test')
|
||||
@@ -47,7 +48,7 @@ const startAt = performance.now()
|
||||
const instances: Instance[] = []
|
||||
const shown: Promise<void>[] = []
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
|
||||
const proc = Bun.spawn(['dotnet', config.runnerDll, 'run', dir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
|
||||
const instance: Instance = { proc }
|
||||
instances.push(instance)
|
||||
shown.push(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。
|
||||
// 运行前先编译运行时:bun run build:runtime
|
||||
import { afterAll, afterEach, expect, test } from 'bun:test'
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test'
|
||||
import { cp, rm } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared'
|
||||
import { compileService } from '../src/compile-service'
|
||||
import { ROOT, RUNNER_DLL } from '../src/runner-process'
|
||||
import { config, ROOT } from '../src/config'
|
||||
import { cpuLimitMessage, runCommand } from '../src/sandbox'
|
||||
|
||||
class Runner {
|
||||
readonly messages: RunnerMessage[] = []
|
||||
private proc?: Bun.Subprocess<'pipe', 'pipe', 'inherit'>
|
||||
private reading?: Promise<void>
|
||||
private waiters: { match: (m: RunnerMessage) => boolean; resolve: (m: RunnerMessage) => void }[] = []
|
||||
|
||||
/** 与服务器相同的流程:先由编译服务编译,成功后启动运行进程 */
|
||||
@@ -18,8 +20,9 @@ class Runner {
|
||||
const compiled = await compileService.compile(projectDir)
|
||||
runner.receive({ type: 'compiled', ...compiled })
|
||||
if (compiled.ok) {
|
||||
runner.proc = Bun.spawn(['dotnet', RUNNER_DLL, 'run', projectDir], { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' })
|
||||
void runner.read()
|
||||
const { cmd, env } = runCommand(projectDir, {})
|
||||
runner.proc = Bun.spawn(cmd, { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit', env })
|
||||
runner.reading = runner.read()
|
||||
}
|
||||
return runner
|
||||
}
|
||||
@@ -87,6 +90,13 @@ class Runner {
|
||||
kill() {
|
||||
this.proc?.kill()
|
||||
}
|
||||
|
||||
/** 等程序自己结束、输出全部读完 */
|
||||
async finished() {
|
||||
await this.proc!.exited
|
||||
await this.reading
|
||||
return { code: this.proc!.exitCode, signal: this.proc!.signalCode }
|
||||
}
|
||||
}
|
||||
|
||||
function findByName(node: ControlNode, name: string): ControlNode {
|
||||
@@ -237,3 +247,197 @@ test('项目3 任务2:勾选人体开关后,有人经过时 5 秒内路灯
|
||||
const on = await runner.next('device', (m) => m.device === 'zigbee', 7000)
|
||||
expect(on.data).toEqual([1, 1])
|
||||
}, 30000)
|
||||
|
||||
test('项目1 任务1:多窗体——打开增加用户窗体、添加用户、关闭后登录成功,可以再次打开', async () => {
|
||||
const { runner, form: login, id } = await startSample('p1-t1-登录')
|
||||
expect(login.props.Text).toBe('登录')
|
||||
|
||||
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
|
||||
const { form: addUser } = await runner.waitFor('formShown', (m) => m.form.props.Text === '增加用户')
|
||||
const addId = (name: string) => findByName(addUser, name).id
|
||||
runner.send({ type: 'action', id: addId('txtNewUser1'), action: 'text', value: 'admin' })
|
||||
runner.send({ type: 'action', id: addId('txtPass1'), action: 'text', value: '123' })
|
||||
runner.send({ type: 'action', id: addId('txtPass2'), action: 'text', value: '123' })
|
||||
runner.send({ type: 'action', id: addId('btnOk'), action: 'click' })
|
||||
await runner.waitFor('formClosed', (m) => m.formId === addUser.id)
|
||||
|
||||
runner.send({ type: 'action', id: id('txtUserName'), action: 'text', value: 'admin' })
|
||||
runner.send({ type: 'action', id: id('txtPassword'), action: 'text', value: '123' })
|
||||
runner.send({ type: 'action', id: id('btnOk'), action: 'click' })
|
||||
const box = await runner.waitFor('messageBox')
|
||||
expect(box.text).toBe('登录成功,欢迎进入!')
|
||||
runner.send({ type: 'reply', requestId: box.requestId, result: 'OK' })
|
||||
|
||||
// 再次打开增加用户窗体:每次新建实例,不会因为上一个已释放而出错
|
||||
runner.send({ type: 'action', id: id('btnAdd'), action: 'click' })
|
||||
const second = await runner.next('formShown', (m) => m.form.props.Text === '增加用户')
|
||||
expect(second.form.id).not.toBe(addUser.id)
|
||||
expect(runner.messages.some((m) => m.type === 'exception')).toBe(false)
|
||||
}, 30000)
|
||||
|
||||
describe('所有示例都能编译通过', async () => {
|
||||
const ids = (await Array.fromAsync(new Bun.Glob('*/meta.json').scan({ cwd: resolve(ROOT, 'samples') }))).map((p) => p.split('/')[0]).sort()
|
||||
for (const id of ids) {
|
||||
test(id, async () => {
|
||||
const result = await compileService.compile(await prepareSample(id))
|
||||
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
|
||||
test('项目1 任务2:计算器(装箱拆箱)和多态', async () => {
|
||||
const { runner, id } = await startSample('p1-t2-面向对象特性')
|
||||
const textOf = (name: string) =>
|
||||
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
|
||||
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
|
||||
)
|
||||
runner.send({ type: 'action', id: id('txtNum1'), action: 'text', value: '7' })
|
||||
runner.send({ type: 'action', id: id('txtNum2'), action: 'text', value: '2' })
|
||||
runner.send({ type: 'action', id: id('cmboxType'), action: 'select', index: 3 })
|
||||
const result = textOf('txtResult')
|
||||
runner.send({ type: 'action', id: id('btnCalculate'), action: 'click' })
|
||||
expect(await result).toBe('3.5')
|
||||
|
||||
const display = textOf('txtDispaly')
|
||||
runner.send({ type: 'action', id: id('btnDisplay'), action: 'click' })
|
||||
expect(await display).toBe('张三说的是汉语!\r\n李四说的是汉语!\r\nMary说的是英语!\r\nJimmy说的是英语!\r\n')
|
||||
}, 30000)
|
||||
|
||||
/** 选 COM3、点"开启",等定时器读到实训台上的传感器 */
|
||||
async function openAdam4150(runner: Runner, id: (name: string) => string) {
|
||||
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
|
||||
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
|
||||
}
|
||||
|
||||
test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async () => {
|
||||
const { runner, id } = await startSample('p4-t1-红外对射')
|
||||
await openAdam4150(runner, id)
|
||||
const even = (value: string) => runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === value), 5000)
|
||||
await even('正常')
|
||||
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
|
||||
await even('发现入侵')
|
||||
}, 30000)
|
||||
|
||||
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
|
||||
const { runner, id } = await startSample('p4-t3-报警灯')
|
||||
await openAdam4150(runner, id)
|
||||
const relay = runner.next('device', (m) => m.device === 'adam4150')
|
||||
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
|
||||
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
|
||||
expect((await relay).data).toEqual([0, 1])
|
||||
await lamp
|
||||
}, 30000)
|
||||
|
||||
test('项目5 任务1:有火焰时显示"发现火情!"', async () => {
|
||||
const { runner, id } = await startSample('p5-t1-火焰')
|
||||
await openAdam4150(runner, id)
|
||||
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, true, false, false] })
|
||||
await runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === '发现火情!'), 5000)
|
||||
}, 30000)
|
||||
|
||||
/** 把一段 Main 方法体写成工程并运行,收集它的控制台输出 */
|
||||
async function runMain(name: string, body: string, usings = '') {
|
||||
const dir = resolve(ROOT, '.runs', `test-${name}`)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await Bun.write(
|
||||
`${dir}/Program.cs`,
|
||||
`using System;\nusing System.IO;\nusing System.Linq;\n${usings}\nstatic class Program\n{\n static void Main()\n {\n${body}\n }\n}\n`,
|
||||
)
|
||||
runner = await Runner.start(dir)
|
||||
return { runner, dir }
|
||||
}
|
||||
|
||||
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
|
||||
process.env.SIM_TEST_SECRET = '不该被看到'
|
||||
const { runner } = await runMain(
|
||||
'sandbox',
|
||||
`
|
||||
Console.WriteLine("passwd=" + File.Exists("/etc/passwd"));
|
||||
Console.WriteLine("runs=" + Directory.Exists(${JSON.stringify(config.runsDir)}));
|
||||
Console.WriteLine("home=" + Directory.Exists("/home"));
|
||||
Console.WriteLine("secret=" + (Environment.GetEnvironmentVariable("SIM_TEST_SECRET") ?? "null"));
|
||||
Console.WriteLine("procs=" + Directory.GetDirectories("/proc").Count(d => char.IsDigit(Path.GetFileName(d)[0])));
|
||||
try { File.WriteAllText("hack.txt", "x"); Console.WriteLine("write=ok"); } catch (Exception) { Console.WriteLine("write=denied"); }
|
||||
File.WriteAllText("/tmp/ok.txt", "x");
|
||||
Console.WriteLine("tmp=" + File.Exists("/tmp/ok.txt"));`,
|
||||
)
|
||||
expect((await runner.waitFor('compiled')).ok).toBe(true)
|
||||
await runner.finished()
|
||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||
expect(output).toContain('passwd=False')
|
||||
expect(output).toContain('runs=False')
|
||||
expect(output).toContain('home=False')
|
||||
expect(output).toContain('secret=null')
|
||||
// 只能看到 bwrap 和自己
|
||||
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
|
||||
expect(output).toContain('write=denied')
|
||||
expect(output).toContain('tmp=True')
|
||||
}, 30000)
|
||||
|
||||
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
|
||||
const { runner } = await runMain(
|
||||
'heap',
|
||||
`
|
||||
var blocks = new System.Collections.Generic.List<byte[]>();
|
||||
try { while (true) blocks.Add(new byte[1 << 20]); }
|
||||
catch (OutOfMemoryException) { Console.WriteLine("oom=" + blocks.Count); }`,
|
||||
)
|
||||
await runner.finished()
|
||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||
const megabytes = Number(/oom=(\d+)/.exec(output)?.[1])
|
||||
expect(megabytes).toBeGreaterThan(50)
|
||||
expect(megabytes).toBeLessThanOrEqual(config.heapLimitMb)
|
||||
}, 30000)
|
||||
|
||||
test('编译检查:网络、启动程序、反射、dynamic 给出中文报错;AssemblyInfo.cs 里的特性不受影响', async () => {
|
||||
const dir = resolve(ROOT, '.runs/test-policy')
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await Bun.write(
|
||||
`${dir}/Program.cs`,
|
||||
`using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http;
|
||||
static class Program
|
||||
{
|
||||
static void Main()
|
||||
{
|
||||
Process.Start("sh");
|
||||
var client = new HttpClient();
|
||||
typeof(string).GetMethod("Trim", Type.EmptyTypes).Invoke("x", null);
|
||||
dynamic d = 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
`${dir}/Properties/AssemblyInfo.cs`,
|
||||
`using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
[assembly: AssemblyTitle("WindowsFormsApplication1")]
|
||||
[assembly: ComVisible(false)]
|
||||
[assembly: Guid("0a1b2c3d-1111-2222-3333-444455556666")]
|
||||
`,
|
||||
)
|
||||
const result = await compileService.compile(dir)
|
||||
expect(result.ok).toBe(false)
|
||||
const denied = result.diagnostics.filter((d) => d.code === 'SIM001')
|
||||
expect(denied.map((d) => [d.line, d.message])).toEqual([
|
||||
[8, '平台不允许使用 System.Diagnostics.Process.Start(启动其他程序)'],
|
||||
[9, '平台不允许使用 System.Net.Http.HttpClient(网络访问)'],
|
||||
[10, '平台不允许使用 System.Reflection.MethodBase.Invoke(反射)'],
|
||||
[11, '平台不允许使用 dynamic(dynamic 动态调用)'],
|
||||
])
|
||||
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
|
||||
}, 30000)
|
||||
|
||||
test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到时被结束,并提示检查死循环', async () => {
|
||||
const limit = config.cpuLimitSeconds
|
||||
config.cpuLimitSeconds = 2
|
||||
try {
|
||||
const { runner } = await runMain('cpu', ' long i = 0;\n while (true) i++;')
|
||||
const { code, signal } = await runner.finished()
|
||||
expect(cpuLimitMessage(code, signal)).toContain('死循环')
|
||||
} finally {
|
||||
config.cpuLimitSeconds = limit
|
||||
}
|
||||
}, 30000)
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iot/shared": "workspace:*",
|
||||
"monaco-editor": "^0.57.0",
|
||||
"naive-ui": "^2.41.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
|
||||
<defs>
|
||||
<linearGradient id="dome" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#8e3b3b"/>
|
||||
<stop offset="0.4" stop-color="#c46a6a"/>
|
||||
<stop offset="1" stop-color="#6e2a2a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="base" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#4b4f56"/>
|
||||
<stop offset="1" stop-color="#2c2f34"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 灯罩 -->
|
||||
<path d="M40 112V70a35 35 0 0 1 70 0v42z" fill="url(#dome)" stroke="#5a2222" stroke-width="2"/>
|
||||
<path d="M52 74a23 23 0 0 1 16-22" fill="none" stroke="#e8b4b4" stroke-width="5" stroke-linecap="round" opacity="0.6"/>
|
||||
<rect x="60" y="72" width="30" height="34" rx="4" fill="#7a3030" opacity="0.6"/>
|
||||
<!-- 底座 -->
|
||||
<rect x="30" y="112" width="90" height="16" rx="4" fill="url(#base)"/>
|
||||
<rect x="24" y="128" width="102" height="10" rx="3" fill="#222428"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 977 B |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" viewBox="0 0 150 150">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="48%" r="50%">
|
||||
<stop offset="0" stop-color="#ff5252" stop-opacity="0.85"/>
|
||||
<stop offset="1" stop-color="#ff1744" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="dome" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#ff1744"/>
|
||||
<stop offset="0.4" stop-color="#ff8a80"/>
|
||||
<stop offset="1" stop-color="#d50000"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="base" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0" stop-color="#4b4f56"/>
|
||||
<stop offset="1" stop-color="#2c2f34"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 光晕和光线 -->
|
||||
<circle cx="75" cy="72" r="70" fill="url(#glow)"/>
|
||||
<g stroke="#ff5252" stroke-width="5" stroke-linecap="round">
|
||||
<path d="M75 6v14M24 26l10 10M126 26l-10 10M8 72h14M128 72h14"/>
|
||||
</g>
|
||||
<!-- 灯罩 -->
|
||||
<path d="M40 112V70a35 35 0 0 1 70 0v42z" fill="url(#dome)" stroke="#b71c1c" stroke-width="2"/>
|
||||
<path d="M52 74a23 23 0 0 1 16-22" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity="0.85"/>
|
||||
<rect x="60" y="72" width="30" height="34" rx="4" fill="#fff59d" opacity="0.85"/>
|
||||
<!-- 底座 -->
|
||||
<rect x="30" y="112" width="90" height="16" rx="4" fill="url(#base)"/>
|
||||
<rect x="24" y="128" width="102" height="10" rx="3" fill="#222428"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="180" viewBox="0 0 105 180">
|
||||
<defs>
|
||||
<radialGradient id="glass" cx="40%" cy="35%" r="65%">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="0.55" stop-color="#e3e7ec"/>
|
||||
<stop offset="1" stop-color="#a9b1bc"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="base" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#5b5f66"/>
|
||||
<stop offset="0.45" stop-color="#c9ccd1"/>
|
||||
<stop offset="1" stop-color="#4a4e55"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 玻璃泡 -->
|
||||
<path d="M52.5 10C27 10 12 29 12 52c0 17 9 27 17 38 5 7 7 13 7 21h33c0-8 2-14 7-21 8-11 17-21 17-38 0-23-15-42-40.5-42z"
|
||||
fill="url(#glass)" stroke="#8d96a3" stroke-width="2"/>
|
||||
<!-- 灯丝 -->
|
||||
<path d="M43 111V82l-6-14m25 43V82l6-14M37 68c5-6 8 6 13 0s8 6 13 0 8 6 5 0" fill="none" stroke="#6b7480" stroke-width="2"/>
|
||||
<ellipse cx="38" cy="38" rx="9" ry="14" fill="#fff" opacity="0.7"/>
|
||||
<!-- 灯头 -->
|
||||
<rect x="35" y="111" width="35" height="10" rx="3" fill="url(#base)"/>
|
||||
<rect x="36" y="122" width="33" height="9" rx="3" fill="url(#base)"/>
|
||||
<rect x="37" y="132" width="31" height="9" rx="3" fill="url(#base)"/>
|
||||
<rect x="38" y="142" width="29" height="9" rx="3" fill="url(#base)"/>
|
||||
<path d="M42 152h21l-5 12h-11z" fill="#33363b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="180" viewBox="0 0 105 180">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="32%" r="55%">
|
||||
<stop offset="0" stop-color="#fff59d" stop-opacity="0.9"/>
|
||||
<stop offset="1" stop-color="#ffd54f" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="glass" cx="45%" cy="40%" r="65%">
|
||||
<stop offset="0" stop-color="#fffde7"/>
|
||||
<stop offset="0.35" stop-color="#ffee58"/>
|
||||
<stop offset="0.8" stop-color="#ffb300"/>
|
||||
<stop offset="1" stop-color="#e65100"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="base" x1="0" x2="1">
|
||||
<stop offset="0" stop-color="#5b5f66"/>
|
||||
<stop offset="0.45" stop-color="#c9ccd1"/>
|
||||
<stop offset="1" stop-color="#4a4e55"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- 光晕 -->
|
||||
<circle cx="52.5" cy="55" r="52" fill="url(#glow)"/>
|
||||
<!-- 玻璃泡 -->
|
||||
<path d="M52.5 10C27 10 12 29 12 52c0 17 9 27 17 38 5 7 7 13 7 21h33c0-8 2-14 7-21 8-11 17-21 17-38 0-23-15-42-40.5-42z"
|
||||
fill="url(#glass)" stroke="#f9a825" stroke-width="2"/>
|
||||
<!-- 灯丝(发光) -->
|
||||
<path d="M43 111V82l-6-14m25 43V82l6-14M37 68c5-6 8 6 13 0s8 6 13 0 8 6 5 0" fill="none" stroke="#fffde7" stroke-width="2.5"/>
|
||||
<ellipse cx="38" cy="38" rx="9" ry="14" fill="#fff" opacity="0.8"/>
|
||||
<!-- 灯头 -->
|
||||
<rect x="35" y="111" width="35" height="10" rx="3" fill="url(#base)"/>
|
||||
<rect x="36" y="122" width="33" height="9" rx="3" fill="url(#base)"/>
|
||||
<rect x="37" y="132" width="31" height="9" rx="3" fill="url(#base)"/>
|
||||
<rect x="38" y="142" width="29" height="9" rx="3" fill="url(#base)"/>
|
||||
<path d="M42 152h21l-5 12h-11z" fill="#33363b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
// 访问口令页:输对后刷新页面进入平台(口令由服务器记在 cookie 里,之后不用再输)。
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NConfigProvider, NInput, zhCN } from 'naive-ui'
|
||||
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!password.value || busy.value) return
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: password.value }),
|
||||
})
|
||||
if (response.ok) {
|
||||
location.reload()
|
||||
return
|
||||
}
|
||||
error.value = ((await response.json().catch(() => ({}))) as { message?: string }).message ?? '登录失败'
|
||||
} catch {
|
||||
error.value = '连不上服务器,请稍后再试'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider :locale="zhCN">
|
||||
<div class="gate">
|
||||
<form class="card" @submit.prevent="submit">
|
||||
<h1>物联网编程仿真平台</h1>
|
||||
<p>请输入老师给的访问口令</p>
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="访问口令" autofocus />
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!password">进入</NButton>
|
||||
</form>
|
||||
</div>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 28px 24px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 第 0 阶段的验证页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体。
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { NButton, NConfigProvider, NSelect, NTabPane, NTabs, NTag, zhCN } from 'naive-ui'
|
||||
// 主页面:选择示例工程 → 编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { NButton, NConfigProvider, NInput, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, zhCN } from 'naive-ui'
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import FormDesigner from './designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from './designer/history'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './designer/templates'
|
||||
import CodeEditor from './editor/CodeEditor.vue'
|
||||
import { clearDraft, loadDraft, saveDraft } from './editor/drafts'
|
||||
import { createRunSession } from './run/session'
|
||||
import TrainingBoard from './scene/TrainingBoard.vue'
|
||||
import FormWindow from './winforms/FormWindow.vue'
|
||||
@@ -14,12 +19,63 @@ const { state } = session
|
||||
const samples = ref<{ label: string; value: string }[]>([])
|
||||
const sampleId = ref<string>()
|
||||
const files = ref<SourceFile[]>([])
|
||||
const activeFile = ref<string>()
|
||||
/** 当前标签页:文件路径,或 "设计:X.Designer.cs" 表示窗体 X 的设计器 */
|
||||
const activeTab = ref<string>()
|
||||
const DESIGN = '设计:'
|
||||
|
||||
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
|
||||
const forms = computed(() => {
|
||||
const startup = startupForm(files.value)
|
||||
return files.value
|
||||
.filter((f) => f.path.endsWith('.Designer.cs'))
|
||||
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
||||
// 启动窗体排在最前
|
||||
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
||||
})
|
||||
/** 标签页按窗体分组:每个窗体依次是 [设计]、代码、Designer.cs;其他文件(Program.cs 等)放最后 */
|
||||
const tabs = computed(() => {
|
||||
const grouped = new Set(forms.value.flatMap((f) => [f.code, f.designer]))
|
||||
return [
|
||||
...forms.value.flatMap((f) => [
|
||||
{ name: f.tab, label: `${f.code} [设计]` },
|
||||
{ name: f.code, label: f.code },
|
||||
{ name: f.designer, label: f.designer },
|
||||
]),
|
||||
...files.value.filter((f) => !grouped.has(f.path)).map((f) => ({ name: f.path, label: f.path })),
|
||||
]
|
||||
})
|
||||
const activeForm = computed(() => forms.value.find((f) => f.tab === activeTab.value))
|
||||
/** 代码编辑器显示的文件:设计器打开时保持上一次的代码文件 */
|
||||
const activeFile = computed(() => (activeForm.value ? undefined : activeTab.value))
|
||||
const fileText = (path: string) => files.value.find((f) => f.path === path)?.content ?? ''
|
||||
const designerHistory = createDesignerHistory({ read: fileText, write: (path, content) => updateFile(path, content) })
|
||||
|
||||
// 开发模式下暴露页面状态,供浏览器自动化测试读取
|
||||
if (import.meta.env.DEV) Object.assign(window, { __iot: { files, fileText, designerHistory, activeTab } })
|
||||
/** 当前代码是否和示例原始代码不同(来自浏览器里保存的草稿) */
|
||||
const modified = ref(false)
|
||||
const editorRef = ref<InstanceType<typeof CodeEditor>>()
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
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')
|
||||
|
||||
/**
|
||||
* F5 运行、Shift+F5 停止,与 Visual Studio 一致。在整个页面上生效,
|
||||
* 并阻止浏览器默认的"F5 刷新页面"(否则学生按习惯按下 F5,正在运行的程序和窗体都会丢失)。
|
||||
*/
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key !== 'F5') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.shiftKey) session.stop()
|
||||
else run()
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeyDown, { capture: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, { capture: true }))
|
||||
|
||||
onMounted(async () => {
|
||||
// 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节
|
||||
void session.connect()
|
||||
@@ -31,21 +87,111 @@ onMounted(async () => {
|
||||
if (initial) await loadSample(initial.id)
|
||||
})
|
||||
|
||||
async function loadSample(id: string) {
|
||||
async function loadSample(id: string, discardDraft = false) {
|
||||
sampleId.value = id
|
||||
history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`)
|
||||
files.value = await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json()
|
||||
// 默认打开窗体的代码文件(不一定叫 Form1.cs,如教材的 frm_analog.cs)
|
||||
activeFile.value =
|
||||
files.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? files.value[0]?.path
|
||||
if (discardDraft) clearDraft(id)
|
||||
designerHistory.clear()
|
||||
const draft = discardDraft ? null : loadDraft(id)
|
||||
modified.value = draft !== null
|
||||
files.value = draft ?? (await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json())
|
||||
// 和 VS 一样,默认打开主窗体的设计器
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value.find((f) => f.path !== 'Program.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
|
||||
if (!file || file.content === content) return
|
||||
file.content = content
|
||||
filesChanged()
|
||||
}
|
||||
|
||||
function filesChanged() {
|
||||
modified.value = true
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => sampleId.value && saveDraft(sampleId.value, files.value), 500)
|
||||
}
|
||||
|
||||
// ---------- 新建、删除窗体 ----------
|
||||
const newFormOpen = ref(false)
|
||||
const newFormName = ref('')
|
||||
const newFormError = computed(() => {
|
||||
const name = newFormName.value.trim()
|
||||
if (!name) return '请输入窗体名'
|
||||
if (!/^[\p{L}_][\p{L}\p{N}_]*$/u.test(name)) return '只能用字母、汉字、数字、下划线,且不能以数字开头'
|
||||
if (formNameTaken(files.value, name)) return `已经有名为“${name}”的窗体或类`
|
||||
return null
|
||||
})
|
||||
|
||||
function openNewForm(show: boolean) {
|
||||
newFormOpen.value = show
|
||||
if (show) newFormName.value = nextFormName(files.value)
|
||||
}
|
||||
|
||||
/** 新建窗体:生成 X.cs 和 X.Designer.cs,打开它的设计器 */
|
||||
function createForm() {
|
||||
if (newFormError.value) return
|
||||
const created = newFormFiles(projectNamespace(files.value), newFormName.value.trim())
|
||||
files.value.push(...created)
|
||||
filesChanged()
|
||||
newFormOpen.value = false
|
||||
activeTab.value = DESIGN + created[1].path
|
||||
}
|
||||
|
||||
/** 当前设计器里的窗体能否删除:启动窗体(Program.cs 里 Application.Run 的那个)不能删 */
|
||||
const deletableForm = computed(() => {
|
||||
const form = activeForm.value
|
||||
if (!form) return undefined
|
||||
const className = /partial\s+class\s+([\p{L}\p{N}_]+)/u.exec(fileText(form.designer))?.[1]
|
||||
return className && className !== startupForm(files.value) ? form : undefined
|
||||
})
|
||||
|
||||
function deleteForm() {
|
||||
const form = deletableForm.value
|
||||
if (!form) return
|
||||
files.value = files.value.filter((f) => f.path !== form.designer && f.path !== form.code)
|
||||
designerHistory.clear(form.designer)
|
||||
filesChanged()
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
/** 设计器改动了 Designer.cs(以及插入事件处理方法时的代码文件) */
|
||||
function onDesignerUpdate(designerText: string, codeText?: string) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
// 没有实际改动(如双击已绑定的事件,只是跳到代码)不记入历史
|
||||
const codeChanged = codeText !== undefined && codeText !== fileText(form.code)
|
||||
if (designerText === fileText(form.designer) && !codeChanged) return
|
||||
designerHistory.record(form.designer, {
|
||||
designerBefore: fileText(form.designer),
|
||||
designerAfter: designerText,
|
||||
...(codeText !== undefined ? { codeBefore: fileText(form.code), codeAfter: codeText } : {}),
|
||||
})
|
||||
updateFile(form.designer, designerText)
|
||||
if (codeText !== undefined) updateFile(form.code, codeText)
|
||||
}
|
||||
|
||||
/** 设计器里双击控件或事件:切到代码文件并跳到事件处理方法 */
|
||||
async function openCode(line: number) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
activeTab.value = form.code
|
||||
await nextTick()
|
||||
editorRef.value?.reveal(form.code, line, 13)
|
||||
}
|
||||
|
||||
async function showDiagnostic(file: string, line: number, column: number) {
|
||||
activeTab.value = file
|
||||
await nextTick()
|
||||
editorRef.value?.reveal(file, line, column)
|
||||
}
|
||||
|
||||
function restoreOriginal() {
|
||||
if (sampleId.value) void loadSample(sampleId.value, true)
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (running.value) return
|
||||
session.run(files.value.map((f) => ({ ...f })))
|
||||
}
|
||||
</script>
|
||||
@@ -62,8 +208,14 @@ function run() {
|
||||
placeholder="选择示例工程"
|
||||
@update:value="loadSample"
|
||||
/>
|
||||
<NButton type="primary" :disabled="running" @click="run">▶ 运行</NButton>
|
||||
<NButton :disabled="!running" @click="session.stop">■ 停止</NButton>
|
||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||
<NPopconfirm v-if="modified" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
<NButton quaternary size="small">恢复原始代码</NButton>
|
||||
</template>
|
||||
你对这个示例的修改将全部丢弃,确定恢复吗?
|
||||
</NPopconfirm>
|
||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||
<span class="metrics">
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
@@ -71,18 +223,51 @@ function run() {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<main class="workspace" :class="{ designing: activeForm }">
|
||||
<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>
|
||||
<div class="tab-row">
|
||||
<NTabs v-model:value="activeTab" type="card" size="small" class="file-tabs">
|
||||
<NTab v-for="tab in tabs" :key="tab.name" :name="tab.name">{{ tab.label }}</NTab>
|
||||
</NTabs>
|
||||
<NPopover trigger="click" placement="bottom-end" :show="newFormOpen" @update:show="openNewForm">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>+ 新建窗体</NButton>
|
||||
</template>
|
||||
<div class="new-form">
|
||||
<div>窗体名(也是类名和文件名)</div>
|
||||
<NInput v-model:value="newFormName" size="small" @keydown.enter="createForm" />
|
||||
<div v-if="newFormError" class="new-form-error">{{ newFormError }}</div>
|
||||
<NButton size="small" type="primary" :disabled="!!newFormError" @click="createForm">创建</NButton>
|
||||
</div>
|
||||
</NPopover>
|
||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary>删除窗体</NButton>
|
||||
</template>
|
||||
将删除 {{ deletableForm.code }} 和 {{ deletableForm.designer }},代码里用到这个窗体的地方需要自己修改。确定删除吗?
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
<FormDesigner
|
||||
v-if="activeForm"
|
||||
class="code"
|
||||
:designer-text="fileText(activeForm.designer)"
|
||||
:code-text="fileText(activeForm.code)"
|
||||
:can-undo="designerHistory.canUndo(activeForm.designer)"
|
||||
:can-redo="designerHistory.canRedo(activeForm.designer)"
|
||||
@update="onDesignerUpdate"
|
||||
@undo="designerHistory.undo(activeForm.designer, activeForm.code)"
|
||||
@redo="designerHistory.redo(activeForm.designer, activeForm.code)"
|
||||
@open-code="openCode"
|
||||
/>
|
||||
<CodeEditor
|
||||
v-show="!activeForm"
|
||||
ref="editorRef"
|
||||
class="code"
|
||||
:files="files"
|
||||
:active-file="activeFile"
|
||||
:diagnostics="state.diagnostics"
|
||||
@change="updateFile"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="run-area">
|
||||
@@ -105,7 +290,13 @@ function run() {
|
||||
</div>
|
||||
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
||||
<div class="output">
|
||||
<div v-for="(d, i) in state.diagnostics" :key="`d${i}`" :class="['diag', d.severity]">
|
||||
<div
|
||||
v-for="(d, i) in state.diagnostics"
|
||||
:key="`d${i}`"
|
||||
:class="['diag', d.severity]"
|
||||
title="点击跳到出错的代码"
|
||||
@click="showDiagnostic(d.file, d.line, d.column)"
|
||||
>
|
||||
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }}
|
||||
<div v-if="d.hint" class="hint">提示:{{ d.hint }}</div>
|
||||
</div>
|
||||
@@ -157,25 +348,46 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 设计器需要工具箱、窗体和属性窗口,左栏放宽 */
|
||||
.workspace.designing {
|
||||
grid-template-columns: minmax(0, 3fr) minmax(360px, 2fr);
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
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;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.file-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.new-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.new-form-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.run-area {
|
||||
@@ -212,6 +424,14 @@ body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diag {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diag:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.diag.error,
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
<script setup lang="ts">
|
||||
// 窗体设计器(仿 VS):左边工具箱,中间窗体,下方组件栏,右边属性 / 事件窗口。
|
||||
// 窗体用运行时同一套 ControlView 渲染;上面盖一层透明的操作层,负责选中、拖动、改大小、放置新控件。
|
||||
// 每次改动都重新生成 Designer.cs,通过 update 事件交给页面保存。
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import ControlView from '../winforms/ControlView.vue'
|
||||
import { displayText, fontCss, type FontValue } from '../winforms/format'
|
||||
import { CONTROLS, type ControlDef, FORM_DEF, controlDef, isContainerType } from './catalog'
|
||||
import { ensureHandler } from './code'
|
||||
import { type DesignerDocument, DesignerParseError, generateDesigner, parseDesigner } from './document'
|
||||
import {
|
||||
addControl,
|
||||
childrenOf,
|
||||
getEventHandler,
|
||||
getPropText,
|
||||
listControls,
|
||||
moveControl,
|
||||
removeControl,
|
||||
renameControl,
|
||||
setEvent,
|
||||
setItems,
|
||||
setProp,
|
||||
typeOf,
|
||||
} from './edit'
|
||||
import PropertyGrid from './PropertyGrid.vue'
|
||||
import { componentsOf, previewTree } from './preview'
|
||||
import { parseValue, type Value } from './values'
|
||||
|
||||
const props = defineProps<{
|
||||
designerText: string
|
||||
codeText: string
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Designer.cs 改了;codeText 不为空时窗体代码文件也改了(插入了事件处理方法) */
|
||||
update: [designerText: string, codeText?: string]
|
||||
/** 跳到窗体代码文件的某一行 */
|
||||
openCode: [line: number]
|
||||
undo: []
|
||||
redo: []
|
||||
}>()
|
||||
|
||||
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
|
||||
function onShortcut(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest('input, textarea, select')) return
|
||||
if (!(event.ctrlKey || event.metaKey)) return
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'z' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
emit('undo')
|
||||
} else if (key === 'y' || (key === 'z' && event.shiftKey)) {
|
||||
event.preventDefault()
|
||||
emit('redo')
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 文档 ----------
|
||||
const parsed = computed<{ doc?: DesignerDocument; error?: string }>(() => {
|
||||
try {
|
||||
return { doc: parseDesigner(props.designerText) }
|
||||
} catch (error) {
|
||||
return { error: error instanceof DesignerParseError ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
/** 拖动过程中的临时文档(松手时才写回文件) */
|
||||
const dragDoc = shallowRef<DesignerDocument>()
|
||||
const doc = computed(() => dragDoc.value ?? parsed.value.doc)
|
||||
const tree = computed(() => (doc.value ? previewTree(doc.value) : undefined))
|
||||
const components = computed(() => (doc.value ? componentsOf(doc.value) : []))
|
||||
|
||||
/** 选中的控件;'' 表示窗体 */
|
||||
const selected = ref('')
|
||||
watch(parsed, () => {
|
||||
if (selected.value && doc.value && !listControls(doc.value).some((c) => c.name === selected.value)) selected.value = ''
|
||||
})
|
||||
|
||||
function commit(next: DesignerDocument, codeText?: string) {
|
||||
dragDoc.value = undefined
|
||||
emit('update', generateDesigner(next), codeText)
|
||||
}
|
||||
|
||||
// ---------- 几何 ----------
|
||||
interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function valueOf(d: DesignerDocument, target: string, path: string): Value | undefined {
|
||||
const text = getPropText(d, target, path)
|
||||
return text === undefined ? undefined : parseValue(text)
|
||||
}
|
||||
|
||||
function localRect(d: DesignerDocument, name: string): Rect {
|
||||
const location = valueOf(d, name, 'Location')
|
||||
const size = valueOf(d, name, 'Size')
|
||||
return {
|
||||
x: location?.kind === 'point' ? location.x : 0,
|
||||
y: location?.kind === 'point' ? location.y : 0,
|
||||
width: size?.kind === 'size' ? size.width : 75,
|
||||
height: size?.kind === 'size' ? size.height : 23,
|
||||
}
|
||||
}
|
||||
|
||||
/** 控件在窗体客户区里的绝对位置(Location 相对于所在容器) */
|
||||
function absRect(d: DesignerDocument, name: string): Rect {
|
||||
const rect = localRect(d, name)
|
||||
const parent = listControls(d).find((c) => c.name === name)?.parent
|
||||
if (parent) {
|
||||
const p = absRect(d, parent)
|
||||
rect.x += p.x
|
||||
rect.y += p.y
|
||||
}
|
||||
return rect
|
||||
}
|
||||
|
||||
const clientSize = computed(() => {
|
||||
const v = doc.value && valueOf(doc.value, '', 'ClientSize')
|
||||
return v?.kind === 'size' ? v : { width: 300, height: 300 }
|
||||
})
|
||||
|
||||
const contains = (r: Rect, x: number, y: number) => x >= r.x && y >= r.y && x < r.x + r.width && y < r.y + r.height
|
||||
|
||||
/** 找到点下的最上层控件(先查子控件;同一层里 Controls 中靠前的在上面) */
|
||||
function hitTest(d: DesignerDocument, x: number, y: number, container = '', skip?: Set<string>): string | null {
|
||||
for (const child of childrenOf(d, container)) {
|
||||
if (skip?.has(child) || !contains(absRect(d, child), x, y)) continue
|
||||
if (isContainerType(typeOf(d, child) ?? '')) return hitTest(d, x, y, child, skip) ?? child
|
||||
return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 点所在的最深一层容器(窗体为 ''),拖放控件时用来决定放进哪个容器 */
|
||||
function containerAt(d: DesignerDocument, x: number, y: number, skip?: Set<string>): string {
|
||||
const hit = hitTest(d, x, y, '', skip)
|
||||
if (hit === null) return ''
|
||||
if (isContainerType(typeOf(d, hit) ?? '')) return hit
|
||||
return listControls(d).find((c) => c.name === hit)?.parent ?? ''
|
||||
}
|
||||
|
||||
function descendants(d: DesignerDocument, name: string): Set<string> {
|
||||
const set = new Set([name])
|
||||
for (const child of childrenOf(d, name)) for (const n of descendants(d, child)) set.add(n)
|
||||
return set
|
||||
}
|
||||
|
||||
const selectedRect = computed(() => (doc.value && selected.value ? absRect(doc.value, selected.value) : undefined))
|
||||
const selectedAutoSize = computed(() => doc.value && selected.value && getPropText(doc.value, selected.value, 'AutoSize') === 'true')
|
||||
|
||||
// ---------- 指针操作:选中、移动、改大小 ----------
|
||||
const surface = ref<HTMLElement>()
|
||||
type Handle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||
|
||||
let drag:
|
||||
| { mode: 'move'; name: string; startX: number; startY: number; origin: Rect; moved: boolean }
|
||||
| { mode: 'resize'; name: string; handle: Handle; startX: number; startY: number; origin: Rect }
|
||||
| { mode: 'form'; startX: number; startY: number; origin: { width: number; height: number } }
|
||||
| undefined
|
||||
|
||||
function pointer(event: PointerEvent | DragEvent) {
|
||||
const box = surface.value!.getBoundingClientRect()
|
||||
return { x: event.clientX - box.left, y: event.clientY - box.top }
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent) {
|
||||
if (!doc.value || event.button !== 0) return
|
||||
surface.value!.focus()
|
||||
const { x, y } = pointer(event)
|
||||
const hit = hitTest(doc.value, x, y)
|
||||
selected.value = hit ?? ''
|
||||
if (hit) {
|
||||
drag = { mode: 'move', name: hit, startX: x, startY: y, origin: absRect(doc.value, hit), moved: false }
|
||||
surface.value!.setPointerCapture(event.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
function startResize(event: PointerEvent, handle: Handle) {
|
||||
if (!doc.value || !selected.value) return
|
||||
const { x, y } = pointer(event)
|
||||
drag = { mode: 'resize', name: selected.value, handle, startX: x, startY: y, origin: localRect(doc.value, selected.value) }
|
||||
surface.value!.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function startFormResize(event: PointerEvent) {
|
||||
if (!doc.value) return
|
||||
selected.value = ''
|
||||
const { x, y } = pointer(event)
|
||||
drag = { mode: 'form', startX: x, startY: y, origin: { ...clientSize.value } }
|
||||
surface.value!.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
const base = parsed.value.doc
|
||||
if (!drag || !base) return
|
||||
const { x, y } = pointer(event)
|
||||
const dx = Math.round(x - drag.startX)
|
||||
const dy = Math.round(y - drag.startY)
|
||||
if (drag.mode === 'move') {
|
||||
if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 3) return
|
||||
drag.moved = true
|
||||
const local = localRect(base, drag.name)
|
||||
dragDoc.value = setProp(base, drag.name, 'Location', { kind: 'point', x: local.x + dx, y: local.y + dy })
|
||||
} else if (drag.mode === 'resize') {
|
||||
const r = { ...drag.origin }
|
||||
const h = drag.handle
|
||||
if (h.includes('e')) r.width = Math.max(4, drag.origin.width + dx)
|
||||
if (h.includes('s')) r.height = Math.max(4, drag.origin.height + dy)
|
||||
if (h.includes('w')) {
|
||||
r.width = Math.max(4, drag.origin.width - dx)
|
||||
r.x = drag.origin.x + drag.origin.width - r.width
|
||||
}
|
||||
if (h.includes('n')) {
|
||||
r.height = Math.max(4, drag.origin.height - dy)
|
||||
r.y = drag.origin.y + drag.origin.height - r.height
|
||||
}
|
||||
let next = setProp(base, drag.name, 'Location', { kind: 'point', x: r.x, y: r.y })
|
||||
next = setProp(next, drag.name, 'Size', { kind: 'size', width: r.width, height: r.height })
|
||||
dragDoc.value = next
|
||||
} else {
|
||||
dragDoc.value = setProp(base, '', 'ClientSize', {
|
||||
kind: 'size',
|
||||
width: Math.max(80, drag.origin.width + dx),
|
||||
height: Math.max(40, drag.origin.height + dy),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
const base = parsed.value.doc
|
||||
const current = drag
|
||||
drag = undefined
|
||||
if (!current || !base || !dragDoc.value) {
|
||||
dragDoc.value = undefined
|
||||
return
|
||||
}
|
||||
if (current.mode === 'move') {
|
||||
// 松手的位置在另一个容器里:把控件移进去(坐标换算成相对新容器)
|
||||
const { x, y } = pointer(event)
|
||||
const target = containerAt(dragDoc.value, x, y, descendants(base, current.name))
|
||||
const abs = absRect(dragDoc.value, current.name)
|
||||
const origin = target ? absRect(dragDoc.value, target) : { x: 0, y: 0 }
|
||||
commit(moveControl(base, current.name, target, { x: abs.x - origin.x, y: abs.y - origin.y }))
|
||||
} else {
|
||||
commit(dragDoc.value)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 键盘 ----------
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const d = parsed.value.doc
|
||||
if (!d || !selected.value) return
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
const name = selected.value
|
||||
selected.value = ''
|
||||
commit(removeControl(d, name))
|
||||
return
|
||||
}
|
||||
const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]
|
||||
if (step && listControls(d).find((c) => c.name === selected.value)?.parent !== null) {
|
||||
event.preventDefault()
|
||||
const r = localRect(d, selected.value)
|
||||
commit(setProp(d, selected.value, 'Location', { kind: 'point', x: r.x + step[0], y: r.y + step[1] }))
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
selected.value = listControls(d).find((c) => c.name === selected.value)?.parent ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工具箱 ----------
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '组件', '物联网设备'] as const
|
||||
const ICONS: Record<string, string> = {
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢',
|
||||
定时器: '⏱', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
}
|
||||
const DRAG_TYPE = 'application/x-winforms-control'
|
||||
|
||||
function onToolDragStart(event: DragEvent, def: ControlDef) {
|
||||
event.dataTransfer!.setData(DRAG_TYPE, def.type)
|
||||
event.dataTransfer!.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer?.types.includes(DRAG_TYPE)) {
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
const d = parsed.value.doc
|
||||
const type = event.dataTransfer?.getData(DRAG_TYPE)
|
||||
if (!d || !type) return
|
||||
event.preventDefault()
|
||||
const { x, y } = pointer(event)
|
||||
place(d, controlDef(type), x, y)
|
||||
}
|
||||
|
||||
/** 双击工具箱:放在选中的容器(或窗体)左上角 */
|
||||
function onToolDoubleClick(def: ControlDef) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
// 直接放进选中的容器,不按坐标判断(左上角可能正好压在另一个分组框上)
|
||||
const container = !def.component && selected.value && isContainerType(typeOf(d, selected.value) ?? '') ? selected.value : ''
|
||||
const result = addControl(d, def, container, { x: 12, y: container ? 20 : 12 })
|
||||
selected.value = result.name
|
||||
commit(result.doc)
|
||||
}
|
||||
|
||||
function place(d: DesignerDocument, def: ControlDef, x: number, y: number) {
|
||||
const container = def.component ? '' : containerAt(d, x, y)
|
||||
const origin = container ? absRect(d, container) : { x: 0, y: 0 }
|
||||
const result = addControl(d, def, container, { x: x - origin.x, y: y - origin.y })
|
||||
selected.value = result.name
|
||||
commit(result.doc)
|
||||
}
|
||||
|
||||
// ---------- 属性窗口的回调 ----------
|
||||
function onSetProp(target: string, path: string, value: Value | null) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
let next = setProp(d, target, path, value)
|
||||
// AutoSize 的控件:文字或字体变化时按文字重新计算大小(VS 也会这样更新 Size)
|
||||
const def = target ? controlDef(typeOf(d, target) ?? '') : undefined
|
||||
if (def?.autoSize && (path === 'Text' || path === 'AutoSize') && getPropText(next, target, 'AutoSize') === 'true') {
|
||||
const text = valueOf(next, target, 'Text')
|
||||
const size = def.autoSize(text?.kind === 'string' ? text.value : '')
|
||||
next = setProp(next, target, 'Size', { kind: 'size', ...size })
|
||||
}
|
||||
commit(next)
|
||||
}
|
||||
|
||||
function onSetItems(target: string, items: string[]) {
|
||||
const d = parsed.value.doc
|
||||
if (d) commit(setItems(d, target, items))
|
||||
}
|
||||
|
||||
function onRename(from: string, to: string) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
selected.value = to
|
||||
commit(renameControl(d, from, to))
|
||||
}
|
||||
|
||||
/** 绑定事件并在代码里生成处理方法(已存在则直接跳过去),然后跳到代码 */
|
||||
function openEvent(target: string, eventName: string, handler?: string) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
const def = target ? controlDef(typeOf(d, target) ?? '') : FORM_DEF
|
||||
const event = def.events.find((e) => e.name === eventName)
|
||||
if (!event) return
|
||||
const name = handler ?? getEventHandler(d, target, eventName) ?? `${target || d.formName}_${eventName}`
|
||||
const next = setEvent(d, target, eventName, event.delegate, name)
|
||||
try {
|
||||
const result = ensureHandler(props.codeText, d.className, name, event.args)
|
||||
commit(next, result.code === props.codeText ? undefined : result.code)
|
||||
emit('openCode', result.line)
|
||||
} catch (error) {
|
||||
commit(next)
|
||||
alert(String(error))
|
||||
}
|
||||
}
|
||||
|
||||
function clearEvent(target: string, eventName: string) {
|
||||
const d = parsed.value.doc
|
||||
if (d) commit(setEvent(d, target, eventName, '', null))
|
||||
}
|
||||
|
||||
/** 双击控件:生成默认事件(按钮的 Click、窗体的 Load……) */
|
||||
function onDoubleClick(event: MouseEvent) {
|
||||
const d = parsed.value.doc
|
||||
if (!d) return
|
||||
const box = surface.value!.getBoundingClientRect()
|
||||
const hit = hitTest(d, event.clientX - box.left, event.clientY - box.top) ?? ''
|
||||
const def = hit ? controlDef(typeOf(d, hit) ?? '') : FORM_DEF
|
||||
openEvent(hit, def.defaultEvent)
|
||||
}
|
||||
|
||||
function onComponentDoubleClick(name: string) {
|
||||
const d = parsed.value.doc
|
||||
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
|
||||
}
|
||||
|
||||
const formTitle = computed(() => String(tree.value?.props.Text ?? ''))
|
||||
const formStyle = computed(() => ({
|
||||
width: `${clientSize.value.width}px`,
|
||||
height: `${clientSize.value.height}px`,
|
||||
color: (tree.value?.props.ForeColor as string | undefined) ?? undefined,
|
||||
backgroundColor: (tree.value?.props.BackColor as string | undefined) ?? undefined,
|
||||
...fontCss(tree.value?.props.Font as FontValue | undefined),
|
||||
}))
|
||||
const childrenTopLast = computed(() => [...(tree.value?.children ?? [])].reverse())
|
||||
const noop = () => {}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="parsed.error" class="cannot-open">
|
||||
<p><b>设计器无法打开这个窗体。</b></p>
|
||||
<p>{{ parsed.error }}</p>
|
||||
<p>Designer.cs 的内容不是窗体设计器能识别的格式,可能是手动修改造成的。可以在代码里改回来,或者用"恢复原始代码"。</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="doc && tree" class="designer" @keydown="onShortcut">
|
||||
<aside class="toolbox">
|
||||
<div class="panel-title">工具箱</div>
|
||||
<template v-for="group in TOOLBOX_GROUPS" :key="group">
|
||||
<div class="group">{{ group }}</div>
|
||||
<div
|
||||
v-for="def in CONTROLS.filter((d) => d.group === group)"
|
||||
:key="def.type"
|
||||
class="tool"
|
||||
draggable="true"
|
||||
:title="`${def.type.split('.').at(-1)}:拖到窗体上,或双击添加`"
|
||||
@dragstart="onToolDragStart($event, def)"
|
||||
@dblclick="onToolDoubleClick(def)"
|
||||
>
|
||||
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span>{{ def.label }}
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="canvas">
|
||||
<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">拖动工具箱里的控件到窗体上;双击控件生成事件处理方法;Delete 删除</span>
|
||||
</div>
|
||||
<div class="window" :class="{ selected: selected === '' }">
|
||||
<div class="titlebar" @pointerdown="selected = ''">
|
||||
<span class="title">{{ displayText(formTitle) }}</span>
|
||||
<span class="buttons">─ ☐ ✕</span>
|
||||
</div>
|
||||
<div class="client wf-client-host" :style="formStyle">
|
||||
<ControlView v-for="child in childrenTopLast" :key="child.id" :node="child" :act="noop" />
|
||||
<!-- 操作层:盖在窗体客户区上,接管所有鼠标操作 -->
|
||||
<div
|
||||
ref="surface"
|
||||
class="surface"
|
||||
tabindex="0"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onKeyDown"
|
||||
@dragover="onDragOver"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<div
|
||||
v-if="selectedRect"
|
||||
class="selection"
|
||||
:style="{ left: `${selectedRect.x}px`, top: `${selectedRect.y}px`, width: `${selectedRect.width}px`, height: `${selectedRect.height}px` }"
|
||||
>
|
||||
<template v-if="!selectedAutoSize">
|
||||
<i v-for="h in HANDLES" :key="h" :class="['handle', h]" @pointerdown.stop="startResize($event, h)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<i class="form-resize" title="拖动改变窗体大小" @pointerdown.stop="startFormResize" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tray">
|
||||
<span class="tray-label">组件栏</span>
|
||||
<span v-if="components.length === 0" class="tray-empty">把 Timer 等不显示在窗体上的组件拖到窗体上,会出现在这里</span>
|
||||
<button
|
||||
v-for="c in components"
|
||||
:key="c.name"
|
||||
class="component"
|
||||
:class="{ active: selected === c.name }"
|
||||
@click="selected = c.name"
|
||||
@dblclick="onComponentDoubleClick(c.name)"
|
||||
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
|
||||
>
|
||||
⏱ {{ c.name }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PropertyGrid
|
||||
class="properties"
|
||||
:doc="doc"
|
||||
:selected="selected"
|
||||
@set-prop="onSetProp"
|
||||
@set-items="onSetItems"
|
||||
@rename="onRename"
|
||||
@open-event="openEvent"
|
||||
@clear-event="clearEvent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.designer {
|
||||
display: grid;
|
||||
grid-template-columns: 132px minmax(0, 1fr) 260px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.cannot-open {
|
||||
padding: 24px;
|
||||
color: #6b7280;
|
||||
border: 1px solid #e5e7eb;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toolbox {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
padding: 6px 8px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 6px 8px 2px;
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool {
|
||||
padding: 4px 8px 4px 14px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tool:hover {
|
||||
background: #e8f0fe;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
background: #e9edf2;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
padding: 2px 10px;
|
||||
border: 1px solid #c5ccd5;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
color: #b0b6bf;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tips {
|
||||
min-width: 0;
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: #7a838e;
|
||||
}
|
||||
|
||||
.window {
|
||||
align-self: flex-start;
|
||||
border: 1px solid #9aa3ad;
|
||||
background: #f0f0f0;
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 0.15);
|
||||
}
|
||||
|
||||
.window.selected {
|
||||
outline: 1px dashed #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
background: #fff;
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
color: #6b7280;
|
||||
letter-spacing: 12px;
|
||||
}
|
||||
|
||||
.client {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
outline: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.selection {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #2563eb;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #fff;
|
||||
border: 1px solid #2563eb;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.handle.nw { left: -4px; top: -4px; cursor: nwse-resize; }
|
||||
.handle.n { left: calc(50% - 4px); top: -4px; cursor: ns-resize; }
|
||||
.handle.ne { right: -4px; top: -4px; cursor: nesw-resize; }
|
||||
.handle.e { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
||||
.handle.se { right: -4px; bottom: -4px; cursor: nwse-resize; }
|
||||
.handle.s { left: calc(50% - 4px); bottom: -4px; cursor: ns-resize; }
|
||||
.handle.sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
|
||||
.handle.w { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
||||
|
||||
.form-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
cursor: nwse-resize;
|
||||
background: linear-gradient(135deg, transparent 50%, #9aa3ad 50%);
|
||||
}
|
||||
|
||||
.tray {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #d5dbe2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tray-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tray-empty {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.component {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.component.active {
|
||||
border-color: #2563eb;
|
||||
background: #e8f0fe;
|
||||
}
|
||||
|
||||
.properties {
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,389 @@
|
||||
<script setup lang="ts">
|
||||
// 属性窗口和事件窗口(仿 VS):显示选中控件的常用属性;改动立即写回 Designer.cs。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { type ControlDef, FORM_DEF, type PropDef, controlDef } from './catalog'
|
||||
import type { DesignerDocument } from './document'
|
||||
import { checkName, getEventHandler, getItems, getPropText, typeOf } from './edit'
|
||||
import { NAMED_COLORS, hexToRgb, parseValue, rgb, type Value } from './values'
|
||||
|
||||
const props = defineProps<{
|
||||
doc: DesignerDocument
|
||||
/** '' 表示窗体本身 */
|
||||
selected: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
setProp: [target: string, path: string, value: Value | null]
|
||||
setItems: [target: string, items: string[]]
|
||||
rename: [from: string, to: string]
|
||||
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
|
||||
openEvent: [target: string, event: string, handler?: string]
|
||||
clearEvent: [target: string, event: string]
|
||||
}>()
|
||||
|
||||
const tab = ref<'props' | 'events'>('props')
|
||||
const def = computed<ControlDef>(() => (props.selected === '' ? FORM_DEF : controlDef(typeOf(props.doc, props.selected) ?? '')))
|
||||
const typeName = computed(() => def.value.type.split('.').at(-1)!)
|
||||
const title = computed(() => `${props.selected || props.doc.formName}(${def.value.label})`)
|
||||
|
||||
function current(prop: PropDef): Value | undefined {
|
||||
const text = getPropText(props.doc, props.selected, prop.name)
|
||||
return text === undefined ? prop.default : parseValue(text)
|
||||
}
|
||||
|
||||
/** 设成默认值时删除这条属性语句(VS 不写默认值) */
|
||||
function set(prop: PropDef, value: Value) {
|
||||
const isDefault = prop.default && JSON.stringify(prop.default) === JSON.stringify(value)
|
||||
emit('setProp', props.selected, prop.name, isDefault ? null : value)
|
||||
}
|
||||
|
||||
function reset(prop: PropDef) {
|
||||
emit('setProp', props.selected, prop.name, null)
|
||||
}
|
||||
|
||||
// ---------- 名称 ----------
|
||||
const nameDraft = ref('')
|
||||
const nameError = ref<string | null>(null)
|
||||
// 切换选中的控件或切换到另一个窗体时刷新
|
||||
watch([() => props.selected, () => props.doc.formName], () => {
|
||||
nameDraft.value = props.selected || props.doc.formName
|
||||
nameError.value = null
|
||||
}, { immediate: true })
|
||||
|
||||
function commitName() {
|
||||
const name = nameDraft.value.trim()
|
||||
if (!props.selected || name === props.selected) return
|
||||
nameError.value = checkName(props.doc, name, props.selected)
|
||||
if (!nameError.value) emit('rename', props.selected, name)
|
||||
}
|
||||
|
||||
// ---------- 各类型的读写 ----------
|
||||
function str(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'string' || v?.kind === 'char' ? v.value : ''
|
||||
}
|
||||
function num(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'int' || v?.kind === 'float' ? v.value : 0
|
||||
}
|
||||
function boolOf(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'bool' ? v.value : false
|
||||
}
|
||||
function point(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'point' ? v : { x: 0, y: 0 }
|
||||
}
|
||||
function size(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'size' ? v : { width: 0, height: 0 }
|
||||
}
|
||||
function enumOf(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'enum' ? v.member : ''
|
||||
}
|
||||
function color(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'color' ? v : undefined
|
||||
}
|
||||
function font(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'font' ? v : undefined
|
||||
}
|
||||
function raw(prop: PropDef) {
|
||||
const v = current(prop)
|
||||
return v?.kind === 'raw' ? v.text : undefined
|
||||
}
|
||||
|
||||
function setColor(prop: PropDef, choice: string) {
|
||||
if (choice === '') return reset(prop)
|
||||
if (choice.startsWith('#')) {
|
||||
const [r, g, b] = hexToRgb(choice)
|
||||
return set(prop, rgb(r, g, b))
|
||||
}
|
||||
const css = NAMED_COLORS[choice]
|
||||
const [r, g, b] = css === 'transparent' ? [255, 255, 255] : hexToRgb(css)
|
||||
set(prop, { kind: 'color', css, name: choice, r, g, b, a: css === 'transparent' ? 0 : 255 })
|
||||
}
|
||||
|
||||
const FONT_FAMILIES = ['宋体', '黑体', '楷体', '微软雅黑', 'Arial', 'Times New Roman']
|
||||
|
||||
function setFont(prop: PropDef, change: Partial<{ family: string; size: number; bold: boolean; italic: boolean }>) {
|
||||
const base = font(prop) ?? { kind: 'font' as const, family: '宋体', size: 9, bold: false, italic: false, underline: false, strikeout: false }
|
||||
set(prop, { ...base, ...change })
|
||||
}
|
||||
|
||||
const itemsText = computed(() => getItems(props.doc, props.selected).map((item) => {
|
||||
const v = parseValue(item)
|
||||
return v.kind === 'string' ? v.value : item
|
||||
}).join('\n'))
|
||||
|
||||
function setItemsFromText(text: string) {
|
||||
emit('setItems', props.selected, text.split('\n').map((s) => s.trimEnd()).filter((s) => s !== ''))
|
||||
}
|
||||
|
||||
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
|
||||
|
||||
function renameHandler(event: string, value: string) {
|
||||
const name = value.trim()
|
||||
if (!name) emit('clearEvent', props.selected, event)
|
||||
else if (name !== handlerOf(event)) emit('openEvent', props.selected, event, name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid">
|
||||
<div class="grid-title" :title="`类型:${typeName}`">{{ title }}</div>
|
||||
<div class="tabs">
|
||||
<button :class="{ active: tab === 'props' }" @click="tab = 'props'">属性</button>
|
||||
<button :class="{ active: tab === 'events' }" title="事件" @click="tab = 'events'">⚡ 事件</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'props'" class="rows">
|
||||
<div class="row">
|
||||
<label title="Name:控件的名称,代码里用这个名字访问它">名称</label>
|
||||
<div class="editor">
|
||||
<input v-model="nameDraft" :disabled="selected === ''" @change="commitName" @keydown.enter="commitName" />
|
||||
<div v-if="nameError" class="error">{{ nameError }}</div>
|
||||
<div v-else-if="selected !== ''" class="note">改名后,代码里用到旧名字的地方要自己修改</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="prop in def.props" :key="prop.name" class="row">
|
||||
<label :title="`${prop.name}:${prop.description}`">{{ prop.label }}</label>
|
||||
<div class="editor">
|
||||
<code v-if="raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
|
||||
|
||||
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
|
||||
|
||||
<textarea v-else-if="prop.type === 'multiline'" rows="2" :value="str(prop)"
|
||||
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })" />
|
||||
|
||||
<input v-else-if="prop.type === 'char'" maxlength="1" class="short" :value="str(prop)"
|
||||
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)" />
|
||||
|
||||
<select v-else-if="prop.type === 'bool'" :value="String(boolOf(prop))"
|
||||
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })">
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
|
||||
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
|
||||
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })" />
|
||||
|
||||
<span v-else-if="prop.type === 'point'" class="pair">
|
||||
X <input type="number" :value="point(prop).x" @change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })" />
|
||||
Y <input type="number" :value="point(prop).y" @change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })" />
|
||||
</span>
|
||||
|
||||
<span v-else-if="prop.type === 'size'" class="pair">
|
||||
宽 <input type="number" min="1" :value="size(prop).width" @change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })" />
|
||||
高 <input type="number" min="1" :value="size(prop).height" @change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })" />
|
||||
</span>
|
||||
|
||||
<select v-else-if="prop.type === 'enum'" :value="enumOf(prop)"
|
||||
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })">
|
||||
<option v-for="option in prop.options" :key="option" :value="option">{{ prop.optionLabels?.[option] ?? option }}</option>
|
||||
</select>
|
||||
|
||||
<span v-else-if="prop.type === 'color'" class="color">
|
||||
<i class="swatch" :style="{ background: color(prop)?.css ?? 'transparent' }" />
|
||||
<select :value="color(prop)?.name ?? (color(prop) ? 'custom' : '')" @change="setColor(prop, ($event.target as HTMLSelectElement).value)">
|
||||
<option value="">(默认)</option>
|
||||
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
|
||||
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
|
||||
</select>
|
||||
<input type="color" title="自定义颜色" :value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
|
||||
@change="setColor(prop, ($event.target as HTMLInputElement).value)" />
|
||||
</span>
|
||||
|
||||
<span v-else-if="prop.type === 'font'" class="font">
|
||||
<select :value="font(prop)?.family ?? ''" @change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)">
|
||||
<option value="">(默认:宋体 9)</option>
|
||||
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
|
||||
</select>
|
||||
<template v-if="font(prop)">
|
||||
<input type="number" min="5" max="72" step="0.5" class="short" :value="font(prop)!.size"
|
||||
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })" />
|
||||
<label class="check"><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label>
|
||||
<label class="check"><input type="checkbox" :checked="font(prop)!.italic" @change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })" />斜</label>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
|
||||
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rows">
|
||||
<div class="note events-note">双击事件名生成事件处理方法,并跳到代码</div>
|
||||
<div v-for="event in def.events" :key="event.name" class="row">
|
||||
<label class="event-name" :title="`${event.name}:双击生成事件处理方法`" @dblclick="emit('openEvent', selected, event.name)">{{ event.label }}</label>
|
||||
<div class="editor">
|
||||
<span class="pair">
|
||||
<input :value="handlerOf(event.name)" placeholder="(未绑定)" @change="renameHandler(event.name, ($event.target as HTMLInputElement).value)" />
|
||||
<button class="go" title="生成或跳到事件处理方法" @click="emit('openEvent', selected, event.name)">→</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.grid-title {
|
||||
padding: 6px 8px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: #f7f7f8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: inset 0 -2px #2563eb;
|
||||
}
|
||||
|
||||
.rows {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid #f1f1f3;
|
||||
}
|
||||
|
||||
.row > label {
|
||||
padding-top: 3px;
|
||||
color: #374151;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-name:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor input:not([type="checkbox"]):not([type="color"]),
|
||||
.editor select,
|
||||
.editor textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pair,
|
||||
.color,
|
||||
.font {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pair input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.short {
|
||||
width: 56px !important;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid #9ca3af;
|
||||
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
|
||||
}
|
||||
|
||||
.color input[type="color"] {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.raw {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.note {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.events-note {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.go {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 3px;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,313 @@
|
||||
// 设计器支持的控件:工具箱分组、属性窗口里的属性、事件窗口里的事件、拖入窗体时的初始属性。
|
||||
// 只列出仿真运行时已经实现的控件和属性;默认值与 WinForms 一致(等于默认值的属性不写进 Designer.cs)。
|
||||
import type { Value } from './values'
|
||||
|
||||
export type PropType = 'string' | 'multiline' | 'bool' | 'int' | 'char' | 'point' | 'size' | 'color' | 'font' | 'enum' | 'items'
|
||||
|
||||
export interface PropDef {
|
||||
/** WinForms 的属性名,写进 Designer.cs,代码里也用它 */
|
||||
name: string
|
||||
/** 属性窗口里显示的中文名 */
|
||||
label: string
|
||||
/** 鼠标悬停时显示的用途说明 */
|
||||
description: string
|
||||
type: PropType
|
||||
/** 枚举属性的完整类型名和可选值 */
|
||||
enumType?: string
|
||||
options?: string[]
|
||||
/** 枚举选项的中文名(写进 Designer.cs 的仍是英文值) */
|
||||
optionLabels?: Record<string, string>
|
||||
/** 等于这个值时不写进 Designer.cs(WinForms 的默认值) */
|
||||
default?: Value
|
||||
category: '外观' | '行为' | '布局' | '数据'
|
||||
}
|
||||
|
||||
export interface EventDef {
|
||||
/** WinForms 的事件名,事件处理方法的名字也用它(如 button1_Click) */
|
||||
name: string
|
||||
/** 事件窗口里显示的中文名 */
|
||||
label: string
|
||||
delegate: string
|
||||
/** 事件处理方法第二个参数的类型 */
|
||||
args: string
|
||||
}
|
||||
|
||||
export interface ControlDef {
|
||||
/** 完整类型名,如 System.Windows.Forms.Button */
|
||||
type: string
|
||||
/** 工具箱和属性窗口里显示的中文名(英文类名在鼠标悬停时显示) */
|
||||
label: string
|
||||
group: '常用控件' | '容器' | '组件' | '物联网设备'
|
||||
/** 新控件的名字前缀:button → button1 */
|
||||
namePrefix: string
|
||||
/** 非可视组件(如 Timer):不在窗体上,显示在窗体下方的组件栏 */
|
||||
component?: boolean
|
||||
container?: boolean
|
||||
/** 双击控件时生成的事件 */
|
||||
defaultEvent: string
|
||||
props: PropDef[]
|
||||
events: EventDef[]
|
||||
/** 拖入窗体时写入的属性(不含 Location、Name、TabIndex,这些由设计器填写) */
|
||||
initial: (name: string) => [string, Value][]
|
||||
/** AutoSize 时根据文字计算大小 */
|
||||
autoSize?: (text: string) => { width: number; height: number }
|
||||
}
|
||||
|
||||
const str = (value: string): Value => ({ kind: 'string', value })
|
||||
const bool = (value: boolean): Value => ({ kind: 'bool', value })
|
||||
const int = (value: number): Value => ({ kind: 'int', value })
|
||||
const size = (width: number, height: number): Value => ({ kind: 'size', width, height })
|
||||
const enumValue = (type: string, member: string): Value => ({ kind: 'enum', type, member })
|
||||
|
||||
const EVENT = { delegate: 'System.EventHandler', args: 'EventArgs' }
|
||||
const MOUSE = { delegate: 'System.Windows.Forms.MouseEventHandler', args: 'MouseEventArgs' }
|
||||
|
||||
const COMMON_PROPS: PropDef[] = [
|
||||
{ name: 'Text', label: '文字', description: '控件上显示的文字', type: 'string', default: str(''), category: '外观' },
|
||||
{ name: 'BackColor', label: '背景色', description: '背景色', type: 'color', category: '外观' },
|
||||
{ name: 'ForeColor', label: '文字颜色', description: '前景色(文字颜色)', type: 'color', category: '外观' },
|
||||
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
|
||||
{ name: 'Location', label: '位置', description: '左上角的坐标(相对于所在的窗体或分组框)', type: 'point', category: '布局' },
|
||||
{ name: 'Size', label: '大小', description: '宽度和高度(像素)', type: 'size', category: '布局' },
|
||||
{ name: 'Enabled', label: '可用', description: '是否可用', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
|
||||
]
|
||||
|
||||
const COMMON_EVENTS: EventDef[] = [
|
||||
{ name: 'Click', label: '单击', ...EVENT },
|
||||
{ name: 'DoubleClick', label: '双击', ...EVENT },
|
||||
{ name: 'MouseDown', label: '鼠标按下', ...MOUSE },
|
||||
{ name: 'MouseUp', label: '鼠标松开', ...MOUSE },
|
||||
{ name: 'MouseEnter', label: '鼠标进入', ...EVENT },
|
||||
{ name: 'MouseLeave', label: '鼠标离开', ...EVENT },
|
||||
{ name: 'TextChanged', label: '文字改变', ...EVENT },
|
||||
{ name: 'Enter', label: '获得焦点', ...EVENT },
|
||||
{ name: 'Leave', label: '失去焦点', ...EVENT },
|
||||
]
|
||||
|
||||
const AUTO_SIZE: PropDef = { name: 'AutoSize', label: '自动大小', description: '根据文字自动调整大小', type: 'bool', default: bool(false), category: '布局' }
|
||||
|
||||
/** 宋体 9pt 下文字的大致宽度:半角约 6 像素,全角约 12 像素(与 VS 设计器算出的 Size 接近) */
|
||||
export function textWidth(text: string) {
|
||||
let width = 0
|
||||
for (const ch of text) width += ch.charCodeAt(0) > 0xff ? 12 : 6
|
||||
return width
|
||||
}
|
||||
|
||||
const FORMS = 'System.Windows.Forms'
|
||||
|
||||
export const CONTROLS: ControlDef[] = [
|
||||
{
|
||||
type: `${FORMS}.Button`,
|
||||
label: '按钮',
|
||||
group: '常用控件',
|
||||
namePrefix: 'button',
|
||||
defaultEvent: 'Click',
|
||||
props: [...COMMON_PROPS],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['Size', size(75, 23)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.Label`,
|
||||
label: '标签',
|
||||
group: '常用控件',
|
||||
namePrefix: 'label',
|
||||
defaultEvent: 'Click',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, {
|
||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
|
||||
}],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 5, 12)], ['Text', str(name)]],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.TextBox`,
|
||||
label: '文本框',
|
||||
group: '常用控件',
|
||||
namePrefix: 'textBox',
|
||||
defaultEvent: 'TextChanged',
|
||||
props: [
|
||||
...COMMON_PROPS.map((p) => (p.name === 'Text' ? { ...p, type: 'multiline' as const } : p)),
|
||||
{ name: 'Multiline', label: '多行', description: '多行', type: 'bool', default: bool(false), category: '行为' },
|
||||
{ name: 'ReadOnly', label: '只读', description: '只读', type: 'bool', default: bool(false), category: '行为' },
|
||||
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
|
||||
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
|
||||
{
|
||||
name: 'ScrollBars', label: '滚动条', description: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
|
||||
options: ['None', 'Horizontal', 'Vertical', 'Both'], optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' }, default: enumValue(`${FORMS}.ScrollBars`, 'None'), category: '外观',
|
||||
},
|
||||
],
|
||||
events: COMMON_EVENTS,
|
||||
initial: () => [['Size', size(100, 21)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.ComboBox`,
|
||||
label: '下拉框',
|
||||
group: '常用控件',
|
||||
namePrefix: 'comboBox',
|
||||
defaultEvent: 'SelectedIndexChanged',
|
||||
props: [
|
||||
...COMMON_PROPS,
|
||||
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
|
||||
{
|
||||
name: 'DropDownStyle', label: '下拉样式', description: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
|
||||
options: ['DropDown', 'DropDownList', 'Simple'], optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' }, default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'), category: '外观',
|
||||
},
|
||||
],
|
||||
events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }],
|
||||
initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.CheckBox`,
|
||||
label: '复选框',
|
||||
group: '常用控件',
|
||||
namePrefix: 'checkBox',
|
||||
defaultEvent: 'CheckedChanged',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '勾选', description: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
|
||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '勾选状态改变', ...EVENT }],
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 24, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.RadioButton`,
|
||||
label: '单选按钮',
|
||||
group: '常用控件',
|
||||
namePrefix: 'radioButton',
|
||||
defaultEvent: 'CheckedChanged',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '选中', description: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
|
||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '选中状态改变', ...EVENT }],
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 17, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.GroupBox`,
|
||||
label: '分组框',
|
||||
group: '容器',
|
||||
namePrefix: 'groupBox',
|
||||
container: true,
|
||||
defaultEvent: 'Enter',
|
||||
props: [...COMMON_PROPS],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['Size', size(200, 100)], ['TabStop', bool(false)], ['Text', str(name)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.Panel`,
|
||||
label: '面板',
|
||||
group: '容器',
|
||||
namePrefix: 'panel',
|
||||
container: true,
|
||||
defaultEvent: 'Click',
|
||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
initial: () => [['Size', size(200, 100)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.Timer`,
|
||||
label: '定时器',
|
||||
group: '组件',
|
||||
namePrefix: 'timer',
|
||||
component: true,
|
||||
defaultEvent: 'Tick',
|
||||
props: [
|
||||
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
|
||||
{ name: 'Enabled', label: '启用', description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()', type: 'bool', default: bool(false), category: '行为' },
|
||||
],
|
||||
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
|
||||
initial: () => [],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.LED',
|
||||
label: 'LED 显示屏',
|
||||
group: '物联网设备',
|
||||
namePrefix: 'led',
|
||||
defaultEvent: 'Load',
|
||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
|
||||
initial: () => [['Size', size(800, 130)]],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.Lamp',
|
||||
label: '灯泡',
|
||||
group: '物联网设备',
|
||||
namePrefix: 'lamp',
|
||||
defaultEvent: 'Load',
|
||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
|
||||
initial: () => [['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }], ['Size', size(105, 185)]],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.Alarm',
|
||||
label: '报警灯',
|
||||
group: '物联网设备',
|
||||
namePrefix: 'alarm',
|
||||
defaultEvent: 'Load',
|
||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
|
||||
initial: () => [['Size', size(150, 150)]],
|
||||
},
|
||||
]
|
||||
|
||||
/** 窗体本身 */
|
||||
export const FORM_DEF: ControlDef = {
|
||||
type: `${FORMS}.Form`,
|
||||
label: '窗体',
|
||||
group: '容器',
|
||||
namePrefix: 'Form',
|
||||
container: true,
|
||||
defaultEvent: 'Load',
|
||||
props: [
|
||||
{ name: 'Text', label: '标题', description: '标题栏文字', type: 'string', default: str(''), category: '外观' },
|
||||
{ name: 'BackColor', label: '背景色', description: '背景色', type: 'color', category: '外观' },
|
||||
{ name: 'ForeColor', label: '文字颜色', description: '前景色(文字颜色)', type: 'color', category: '外观' },
|
||||
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
|
||||
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', category: '布局' },
|
||||
{
|
||||
name: 'FormBorderStyle', label: '边框样式', description: '边框样式', type: 'enum', enumType: `${FORMS}.FormBorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D', 'FixedDialog', 'Sizable', 'FixedToolWindow', 'SizableToolWindow'],
|
||||
optionLabels: { None: '无边框', FixedSingle: '固定单线', Fixed3D: '固定立体', FixedDialog: '对话框', Sizable: '可调整大小', FixedToolWindow: '固定工具窗口', SizableToolWindow: '可调整工具窗口' },
|
||||
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'), category: '外观',
|
||||
},
|
||||
{
|
||||
name: 'StartPosition', label: '启动位置', description: '启动位置', type: 'enum', enumType: `${FORMS}.FormStartPosition`,
|
||||
options: ['Manual', 'CenterScreen', 'WindowsDefaultLocation', 'WindowsDefaultBounds', 'CenterParent'],
|
||||
optionLabels: { Manual: '手动指定', CenterScreen: '屏幕中央', WindowsDefaultLocation: '系统默认位置', WindowsDefaultBounds: '系统默认位置和大小', CenterParent: '父窗体中央' },
|
||||
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
|
||||
},
|
||||
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
|
||||
{ name: 'MinimizeBox', label: '最小化按钮', description: '显示最小化按钮', type: 'bool', default: bool(true), category: '外观' },
|
||||
],
|
||||
events: [
|
||||
{ name: 'Load', label: '加载', ...EVENT },
|
||||
{ name: 'Shown', label: '显示后', ...EVENT },
|
||||
{ name: 'FormClosing', label: '关闭前', delegate: `${FORMS}.FormClosingEventHandler`, args: 'FormClosingEventArgs' },
|
||||
{ name: 'FormClosed', label: '关闭后', delegate: `${FORMS}.FormClosedEventHandler`, args: 'FormClosedEventArgs' },
|
||||
{ name: 'Activated', label: '激活', ...EVENT },
|
||||
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
],
|
||||
initial: () => [],
|
||||
}
|
||||
|
||||
const BY_TYPE = new Map(CONTROLS.map((def) => [def.type, def]))
|
||||
|
||||
/** 按类型名查控件定义;设计器不认识的类型(如 PictureBox)返回一个只有通用属性的定义 */
|
||||
export function controlDef(type: string): ControlDef {
|
||||
return (
|
||||
BY_TYPE.get(type) ?? {
|
||||
type,
|
||||
label: type.split('.').at(-1)!,
|
||||
group: '常用控件',
|
||||
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
|
||||
defaultEvent: 'Click',
|
||||
props: COMMON_PROPS,
|
||||
events: COMMON_EVENTS,
|
||||
initial: () => [],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 设计器里会重新排列子控件的容器类型 */
|
||||
export function isContainerType(type: string) {
|
||||
return type === FORM_DEF.type || controlDef(type).container === true
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from 'bun:test'
|
||||
import { resolve } from 'node:path'
|
||||
import { ensureHandler } from './code'
|
||||
|
||||
const FORM1 = resolve(import.meta.dir, '../../../samples/p2-t1-LED显示屏/Form1.cs')
|
||||
|
||||
test('插入事件处理方法:放在类的最后,与上一个方法空一行,返回方法体内的行号', async () => {
|
||||
const code = await Bun.file(FORM1).text()
|
||||
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
|
||||
const lines = next.split('\n')
|
||||
expect(lines.slice(line - 4, line + 1)).toEqual([
|
||||
'',
|
||||
' private void Form1_Load(object sender, EventArgs e)',
|
||||
' {',
|
||||
'',
|
||||
' }',
|
||||
])
|
||||
// 方法的 "}" 之后就是类的 "}"
|
||||
expect(lines[line + 1]).toBe(' }')
|
||||
})
|
||||
|
||||
test('方法已经存在:不重复插入,只返回它的位置', async () => {
|
||||
const code = await Bun.file(FORM1).text()
|
||||
const { code: next, line } = ensureHandler(code, 'Form1', 'btn_Send_Click', 'EventArgs')
|
||||
expect(next).toBe(code)
|
||||
expect(next.split('\n')[line - 3]).toContain('private void btn_Send_Click')
|
||||
})
|
||||
|
||||
test('类里还没有成员时不留多余空行', () => {
|
||||
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
|
||||
const { code: next, line } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
|
||||
expect(next).toBe('namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n')
|
||||
expect(line).toBe(7)
|
||||
})
|
||||
|
||||
test('字符串和注释里的大括号不影响查找类的结尾', () => {
|
||||
const code = 'class F\n{\n string s = "}{";\n // }\n char c = \'}\';\n}\n'
|
||||
const { code: next } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
|
||||
expect(next.endsWith(" char c = '}';\n\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n}\n")).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
// 窗体代码文件(如 Form1.cs)的修改:双击控件或事件时,插入事件处理方法,并返回光标应放的行号。
|
||||
|
||||
export interface HandlerResult {
|
||||
code: string
|
||||
/** 方法体内空行的行号(从 1 开始),编辑器跳到这里 */
|
||||
line: number
|
||||
}
|
||||
|
||||
/** 方法已经存在时只返回它的位置;否则插在窗体类的最后,格式与 VS 一致 */
|
||||
export function ensureHandler(code: string, className: string, handler: string, args: string): HandlerResult {
|
||||
const existing = new RegExp(`void\\s+${handler}\\s*\\(`).exec(code)
|
||||
if (existing) return { code, line: lineOf(code, existing.index) + 2 }
|
||||
|
||||
const eol = code.includes('\r\n') ? '\r\n' : '\n'
|
||||
const close = classClosingBrace(code, className)
|
||||
if (close < 0) throw new Error(`在代码文件中找不到类 ${className}`)
|
||||
const lineStart = code.lastIndexOf('\n', close) + 1
|
||||
const classIndent = /^[ \t]*/.exec(code.slice(lineStart))![0]
|
||||
const indent = `${classIndent} `
|
||||
// 类里已有成员时,与上一个成员之间空一行
|
||||
const before = code.slice(0, lineStart)
|
||||
const hasMembers = !/\{\s*$/.test(before)
|
||||
const method = [
|
||||
...(hasMembers ? [''] : []),
|
||||
`${indent}private void ${handler}(object sender, ${args} e)`,
|
||||
`${indent}{`,
|
||||
'',
|
||||
`${indent}}`,
|
||||
].join(eol)
|
||||
const next = `${before}${method}${eol}${code.slice(lineStart)}`
|
||||
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
|
||||
}
|
||||
|
||||
function lineOf(text: string, index: number) {
|
||||
let line = 1
|
||||
for (let i = 0; i < index; i++) if (text[i] === '\n') line++
|
||||
return line
|
||||
}
|
||||
|
||||
/** 找到 class className 的类体结束 "}" 的位置(跳过字符串、字符和注释) */
|
||||
export function classClosingBrace(code: string, className: string): number {
|
||||
const decl = new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${className}(?![\\p{L}\\p{N}_])`, 'u').exec(code)
|
||||
if (!decl) return -1
|
||||
let i = code.indexOf('{', decl.index)
|
||||
if (i < 0) return -1
|
||||
let depth = 0
|
||||
while (i < code.length) {
|
||||
const c = code[i]
|
||||
if (code.startsWith('//', i)) {
|
||||
const end = code.indexOf('\n', i)
|
||||
i = end < 0 ? code.length : end
|
||||
continue
|
||||
}
|
||||
if (code.startsWith('/*', i)) {
|
||||
const end = code.indexOf('*/', i + 2)
|
||||
i = end < 0 ? code.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (c === '@' && code[i + 1] === '"') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i++
|
||||
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '{') depth++
|
||||
else if (c === '}' && --depth === 0) return i
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/// <reference types="bun" />
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '../../..')
|
||||
// 教材配套的参考工程不在仓库里(版权),本机有时才测
|
||||
const REFERENCE_DIR = resolve(ROOT, 'docs/物联网编程与应用配套/物联网编程与应用')
|
||||
|
||||
async function designerFiles(dir: string) {
|
||||
const files: string[] = []
|
||||
for await (const path of new Bun.Glob('**/*.Designer.cs').scan({ cwd: dir })) {
|
||||
if (path.includes('/obj/') || path.includes('/Properties/')) continue
|
||||
files.push(resolve(dir, path))
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
|
||||
const files = [
|
||||
...(await designerFiles(resolve(ROOT, 'samples'))),
|
||||
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
|
||||
]
|
||||
for (const file of files) {
|
||||
test(file.replace(`${ROOT}/`, ''), async () => {
|
||||
const text = await Bun.file(file).text()
|
||||
expect(generateDesigner(parseDesigner(text))).toBe(text)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('窗体属性 this.ClientSize = new Size(...) 不会被当成创建控件', async () => {
|
||||
const text = await Bun.file(resolve(ROOT, 'samples/p2-t2-模拟量采集/frm_analog.Designer.cs')).text()
|
||||
const doc = parseDesigner(text)
|
||||
const creates = doc.prologue.filter((s) => s.kind === 'create').map((s) => (s.kind === 'create' ? s.name : ''))
|
||||
expect(creates).not.toContain('ClientSize')
|
||||
expect(creates).not.toContain('AutoScaleDimensions')
|
||||
const formProps = doc.blocks.at(-1)!.stmts.flatMap((s) => (s.kind === 'prop' ? [s.path] : []))
|
||||
expect(formProps).toContain('ClientSize')
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
// Designer.cs 的读写:把 InitializeComponent 解析成一条条语句,每条保留原文。
|
||||
// 没修改过的语句原样输出,修改过的按 VS 的格式重新生成,所以 VS 生成的文件读入后再写出,内容逐字节不变。
|
||||
// 只认 VS 生成的标准结构(和 VS 一样);结构不对时抛出 DesignerParseError,设计器提示无法打开。
|
||||
|
||||
export class DesignerParseError extends Error {}
|
||||
|
||||
export type Stmt =
|
||||
/** this.x = new T(...) */
|
||||
| { kind: 'create'; name: string; type: string; args: string; text?: string }
|
||||
/** this[.x].Path = value;target 为 '' 表示窗体本身 */
|
||||
| { kind: 'prop'; target: string; path: string; value: string; text?: string }
|
||||
/** this[.x].Event += new Delegate(this.handler) */
|
||||
| { kind: 'event'; target: string; event: string; delegate: string; handler: string; text?: string }
|
||||
/** this[.x].Controls.Add(this.child) */
|
||||
| { kind: 'add'; target: string; child: string; text?: string }
|
||||
/** this.x.Items.AddRange(new object[] { ... }),items 是每一项的 C# 字面量原文 */
|
||||
| { kind: 'items'; target: string; items: string[]; text?: string }
|
||||
/** SuspendLayout / ResumeLayout(false) / PerformLayout / BeginInit / EndInit */
|
||||
| { kind: 'layout'; target: string; op: LayoutOp; text?: string }
|
||||
/** 其他语句(局部变量、图表的 Series 设置等),原样保留 */
|
||||
| { kind: 'raw'; text: string }
|
||||
|
||||
export type LayoutOp = 'SuspendLayout' | 'ResumeLayout' | 'PerformLayout' | 'BeginInit' | 'EndInit'
|
||||
|
||||
export interface Block {
|
||||
/** 控件名;窗体的块用窗体类名 */
|
||||
name: string
|
||||
stmts: Stmt[]
|
||||
}
|
||||
|
||||
export interface DesignerDocument {
|
||||
/** 窗体的 Name(窗体属性块的名字,即最后一个块);通常与类名相同,但也可能不同(如教材的 AddUser 窗体叫 frmExam_5_82) */
|
||||
formName: string
|
||||
/** partial class 后面的窗体类名 */
|
||||
className: string
|
||||
/** InitializeComponent 方法体之前的全部原文(含方法体的 "{" 和换行) */
|
||||
head: string
|
||||
/** 第一个注释块之前的语句:components、创建控件、SuspendLayout、BeginInit 等 */
|
||||
prologue: Stmt[]
|
||||
/** 各控件的属性块(含窗体),顺序与文件一致 */
|
||||
blocks: Block[]
|
||||
/** 窗体块之后的 ResumeLayout、PerformLayout、EndInit */
|
||||
epilogue: Stmt[]
|
||||
/** 最后一条语句之后到文件末尾的原文(含方法的 "}"、#endregion、字段声明) */
|
||||
tail: string
|
||||
eol: string
|
||||
indent: string
|
||||
}
|
||||
|
||||
// ---------- 语句切分 ----------
|
||||
|
||||
interface Piece {
|
||||
kind: 'stmt' | 'comment'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** 把方法体切成语句和注释行;语句以顶层的 ";" 结束,跳过字符串、字符和注释里的符号 */
|
||||
function splitBody(body: string): Piece[] {
|
||||
const pieces: Piece[] = []
|
||||
let i = 0
|
||||
while (i < body.length) {
|
||||
const ch = body[i]
|
||||
if (/\s/.test(ch)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (body.startsWith('//', i)) {
|
||||
const end = body.indexOf('\n', i)
|
||||
const stop = end < 0 ? body.length : end
|
||||
pieces.push({ kind: 'comment', text: body.slice(i, stop).replace(/\r$/, '') })
|
||||
i = stop
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
let depth = 0
|
||||
while (i < body.length) {
|
||||
const c = body[i]
|
||||
if (c === '"' || c === "'") {
|
||||
i = skipLiteral(body, i)
|
||||
continue
|
||||
}
|
||||
if (c === '@' && body[i + 1] === '"') {
|
||||
i = skipVerbatim(body, i + 1)
|
||||
continue
|
||||
}
|
||||
if (c === '(' || c === '{' || c === '[') depth++
|
||||
else if (c === ')' || c === '}' || c === ']') depth--
|
||||
else if (c === ';' && depth === 0) {
|
||||
pieces.push({ kind: 'stmt', text: body.slice(start, i + 1) })
|
||||
i++
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (i >= body.length && !body.slice(start).trim().endsWith(';') && body.slice(start).trim()) {
|
||||
throw new DesignerParseError(`语句没有以分号结束:${body.slice(start, start + 40)}`)
|
||||
}
|
||||
}
|
||||
return pieces
|
||||
}
|
||||
|
||||
function skipLiteral(text: string, i: number) {
|
||||
const quote = text[i]
|
||||
i++
|
||||
while (i < text.length && text[i] !== quote) {
|
||||
if (text[i] === '\\') i++
|
||||
if (text[i] === '\n') throw new DesignerParseError('字符串没有结束')
|
||||
i++
|
||||
}
|
||||
return i + 1
|
||||
}
|
||||
|
||||
function skipVerbatim(text: string, i: number) {
|
||||
i++
|
||||
while (i < text.length) {
|
||||
if (text[i] === '"') {
|
||||
if (text[i + 1] === '"') i += 2
|
||||
else return i + 1
|
||||
} else i++
|
||||
}
|
||||
throw new DesignerParseError('字符串没有结束')
|
||||
}
|
||||
|
||||
// ---------- 解析 ----------
|
||||
|
||||
const CREATE = /^this\.([\p{L}\p{N}_]+) = new ([\p{L}\p{N}_.]+)\(([^()]*)\);$/u
|
||||
const LAYOUT = /^this\.(?:([\p{L}\p{N}_]+)\.)?(SuspendLayout|ResumeLayout|PerformLayout)\((?:false)?\);$/u
|
||||
const INIT = /^\(\(System\.ComponentModel\.ISupportInitialize\)\(this\.([\p{L}\p{N}_]+)\)\)\.(BeginInit|EndInit)\(\);$/u
|
||||
const EVENT = /^this\.(?:([\p{L}\p{N}_]+)\.)?([\p{L}\p{N}_]+) \+= new ([\p{L}\p{N}_.]+)\(this\.([\p{L}\p{N}_]+)\);$/u
|
||||
const ADD = /^this\.(?:([\p{L}\p{N}_]+)\.)?Controls\.Add\(this\.([\p{L}\p{N}_]+)\);$/u
|
||||
const ITEMS = /^this\.([\p{L}\p{N}_]+)\.Items\.AddRange\(new object\[\] \{([\s\S]*)\}\);$/u
|
||||
const PROP = /^this\.([\p{L}\p{N}_.]+) = ([\s\S]+);$/u
|
||||
|
||||
/** 一个 Designer.cs 文件里窗体类的名字:partial class 后面的标识符 */
|
||||
function findFormName(text: string) {
|
||||
const match = /partial\s+class\s+([\p{L}\p{N}_]+)/u.exec(text)
|
||||
if (!match) throw new DesignerParseError('找不到 partial class 声明')
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export function parseDesigner(text: string): DesignerDocument {
|
||||
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
||||
const className = findFormName(text)
|
||||
const method = /private\s+void\s+InitializeComponent\s*\(\s*\)\s*\{[^\S\n]*\r?\n/.exec(text)
|
||||
if (!method) throw new DesignerParseError('找不到 InitializeComponent 方法')
|
||||
const bodyStart = method.index + method[0].length
|
||||
const bodyEnd = findMethodEnd(text, bodyStart)
|
||||
const body = text.slice(bodyStart, bodyEnd)
|
||||
const pieces = splitBody(body)
|
||||
|
||||
// 找到最后一条语句在 body 里的结束位置,之后的原文(空行 + "}")归入 tail
|
||||
const lastStmt = [...pieces].reverse().find((p) => p.kind === 'stmt')
|
||||
const lastEnd = lastStmt ? body.lastIndexOf(lastStmt.text) + lastStmt.text.length : 0
|
||||
const tail = body.slice(lastEnd).replace(/^[^\S\n]*\r?\n/, '') + text.slice(bodyEnd)
|
||||
const indentMatch = /^([ \t]*)\S/m.exec(body)
|
||||
const indent = indentMatch ? indentMatch[1] : ' '
|
||||
|
||||
// 第一遍:找出所有创建的控件。只有声明了字段的才算控件——
|
||||
// 窗体属性也可能写成 this.ClientSize = new System.Drawing.Size(...),形式和创建控件一样
|
||||
const fields = new Set([...text.matchAll(/^\s*private\s+[\p{L}\p{N}_.]+\s+([\p{L}\p{N}_]+)\s*;/gmu)].map((m) => m[1]))
|
||||
const controls = new Set<string>()
|
||||
for (const piece of pieces) {
|
||||
const m = piece.kind === 'stmt' ? CREATE.exec(piece.text) : null
|
||||
if (m && m[1] !== 'components' && fields.has(m[1])) controls.add(m[1])
|
||||
}
|
||||
|
||||
const doc: DesignerDocument = { formName: className, className, head: text.slice(0, bodyStart), prologue: [], blocks: [], epilogue: [], tail, eol, indent }
|
||||
let block: Block | undefined
|
||||
let headerLines: string[] = []
|
||||
|
||||
for (const piece of pieces) {
|
||||
if (piece.kind === 'comment') {
|
||||
headerLines.push(piece.text)
|
||||
// VS 的块头是三行注释:"// "、"// 名字"、"// "
|
||||
if (headerLines.length === 3) {
|
||||
const name = /^\/\/ ([\p{L}\p{N}_]+)$/u.exec(headerLines[1])?.[1]
|
||||
if (!name || headerLines[0].trim() !== '//' || headerLines[2].trim() !== '//') {
|
||||
throw new DesignerParseError(`无法识别的注释:${headerLines.join(' ')}`)
|
||||
}
|
||||
block = { name, stmts: [] }
|
||||
doc.blocks.push(block)
|
||||
headerLines = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (headerLines.length) throw new DesignerParseError(`无法识别的注释:${headerLines.join(' ')}`)
|
||||
const stmt = classify(piece.text, controls)
|
||||
if (block) block.stmts.push(stmt)
|
||||
else doc.prologue.push(stmt)
|
||||
}
|
||||
|
||||
// 最后一个块是窗体本身;块末尾的 ResumeLayout / PerformLayout / EndInit 归入 epilogue
|
||||
const formBlock = doc.blocks.at(-1)
|
||||
if (formBlock) {
|
||||
doc.formName = formBlock.name
|
||||
while (formBlock.stmts.length && formBlock.stmts.at(-1)!.kind === 'layout') {
|
||||
doc.epilogue.unshift(formBlock.stmts.pop()!)
|
||||
}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
function classify(text: string, controls: Set<string>): Stmt {
|
||||
let m: RegExpExecArray | null
|
||||
if ((m = CREATE.exec(text)) && controls.has(m[1])) return { kind: 'create', name: m[1], type: m[2], args: m[3], text }
|
||||
if ((m = LAYOUT.exec(text))) return { kind: 'layout', target: m[1] ?? '', op: m[2] as LayoutOp, text }
|
||||
if ((m = INIT.exec(text))) return { kind: 'layout', target: m[1], op: m[2] as LayoutOp, text }
|
||||
if ((m = EVENT.exec(text))) return { kind: 'event', target: targetOf(m[1], controls), event: m[2], delegate: m[3], handler: m[4], text }
|
||||
if ((m = ADD.exec(text))) return { kind: 'add', target: targetOf(m[1], controls), child: m[2], text }
|
||||
if ((m = ITEMS.exec(text)) && controls.has(m[1])) return { kind: 'items', target: m[1], items: splitItems(m[2]), text }
|
||||
if ((m = PROP.exec(text))) {
|
||||
const [first, ...rest] = m[1].split('.')
|
||||
if (controls.has(first) && rest.length) return { kind: 'prop', target: first, path: rest.join('.'), value: m[2], text }
|
||||
return { kind: 'prop', target: '', path: m[1], value: m[2], text }
|
||||
}
|
||||
return { kind: 'raw', text }
|
||||
}
|
||||
|
||||
/** this.x.Event:x 是控件名时是控件的事件;否则(如 this.Load)是窗体的事件 */
|
||||
function targetOf(name: string | undefined, controls: Set<string>) {
|
||||
if (!name) return ''
|
||||
if (controls.has(name)) return name
|
||||
throw new DesignerParseError(`代码引用了不存在的控件 ${name}`)
|
||||
}
|
||||
|
||||
/** 拆分 new object[] { "a", "b" } 里的各项(逗号分隔,跳过字符串里的逗号) */
|
||||
function splitItems(inner: string): string[] {
|
||||
const items: string[] = []
|
||||
let i = 0
|
||||
let start = 0
|
||||
while (i < inner.length) {
|
||||
const c = inner[i]
|
||||
if (c === '"' || c === "'") {
|
||||
i = skipLiteral(inner, i)
|
||||
continue
|
||||
}
|
||||
if (c === ',') {
|
||||
items.push(inner.slice(start, i).trim())
|
||||
start = i + 1
|
||||
}
|
||||
i++
|
||||
}
|
||||
const last = inner.slice(start).trim()
|
||||
if (last) items.push(last)
|
||||
return items
|
||||
}
|
||||
|
||||
/** 从方法体开始位置找到与之匹配的 "}"(跳过字符串和注释) */
|
||||
function findMethodEnd(text: string, start: number) {
|
||||
let depth = 1
|
||||
let i = start
|
||||
while (i < text.length) {
|
||||
const c = text[i]
|
||||
if (text.startsWith('//', i)) {
|
||||
const end = text.indexOf('\n', i)
|
||||
i = end < 0 ? text.length : end
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i = skipLiteral(text, i)
|
||||
continue
|
||||
}
|
||||
if (c === '{') depth++
|
||||
else if (c === '}') {
|
||||
depth--
|
||||
if (depth === 0) {
|
||||
// 回到这一行的行首,"}" 前的缩进归入 tail
|
||||
return text.lastIndexOf('\n', i) + 1
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
throw new DesignerParseError('InitializeComponent 方法没有结束')
|
||||
}
|
||||
|
||||
// ---------- 生成 ----------
|
||||
|
||||
export function renderStmt(stmt: Stmt, indent = ' ', eol = '\n'): string {
|
||||
if (stmt.text !== undefined) return stmt.text
|
||||
const self = (target: string) => (target ? `this.${target}` : 'this')
|
||||
switch (stmt.kind) {
|
||||
case 'create':
|
||||
return `this.${stmt.name} = new ${stmt.type}(${stmt.args});`
|
||||
case 'prop':
|
||||
return `${self(stmt.target)}.${stmt.path} = ${stmt.value};`
|
||||
case 'event':
|
||||
return `${self(stmt.target)}.${stmt.event} += new ${stmt.delegate}(this.${stmt.handler});`
|
||||
case 'add':
|
||||
return `${self(stmt.target)}.Controls.Add(this.${stmt.child});`
|
||||
case 'items': {
|
||||
// VS 的格式:每项单独一行,与语句同样缩进,最后一项后紧跟 "});"
|
||||
const lines = stmt.items.map((item, i) => indent + item + (i < stmt.items.length - 1 ? ',' : '});'))
|
||||
return `this.${stmt.target}.Items.AddRange(new object[] {${eol}${lines.join(eol)}`
|
||||
}
|
||||
case 'layout':
|
||||
if (stmt.op === 'BeginInit' || stmt.op === 'EndInit') {
|
||||
return `((System.ComponentModel.ISupportInitialize)(this.${stmt.target})).${stmt.op}();`
|
||||
}
|
||||
return `${self(stmt.target)}.${stmt.op}(${stmt.op === 'ResumeLayout' ? 'false' : ''});`
|
||||
case 'raw':
|
||||
return stmt.text
|
||||
}
|
||||
}
|
||||
|
||||
export function generateDesigner(doc: DesignerDocument): string {
|
||||
const { indent, eol } = doc
|
||||
const lines: string[] = []
|
||||
const emit = (stmt: Stmt) => lines.push(indent + renderStmt(stmt, indent, eol))
|
||||
doc.prologue.forEach(emit)
|
||||
for (const block of doc.blocks) {
|
||||
lines.push(`${indent}// `, `${indent}// ${block.name}`, `${indent}// `)
|
||||
block.stmts.forEach(emit)
|
||||
}
|
||||
doc.epilogue.forEach(emit)
|
||||
return doc.head + lines.join(eol) + eol + doc.tail
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/// <reference types="bun" />
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { controlDef } from './catalog'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { addControl, childrenOf, getPropText, listControls, moveControl, removeControl, renameControl, setEvent, setItems, setProp, syncLayout } from './edit'
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '../../..')
|
||||
const REFERENCE_DIR = resolve(ROOT, 'docs/物联网编程与应用配套/物联网编程与应用')
|
||||
|
||||
async function designerFiles(dir: string) {
|
||||
const files: string[] = []
|
||||
for await (const path of new Bun.Glob('**/*.Designer.cs').scan({ cwd: dir })) {
|
||||
if (path.includes('/obj/') || path.includes('/Properties/')) continue
|
||||
files.push(resolve(dir, path))
|
||||
}
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
describe('布局语句的规则与 VS 一致:对现有文件执行一遍不产生任何变化', async () => {
|
||||
const files = [...(await designerFiles(resolve(ROOT, 'samples'))), ...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : [])]
|
||||
for (const file of files) {
|
||||
test(file.replace(`${ROOT}/`, ''), async () => {
|
||||
const text = await Bun.file(file).text()
|
||||
const doc = parseDesigner(text)
|
||||
syncLayout(doc)
|
||||
expect(generateDesigner(doc)).toBe(text)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const LED_SAMPLE = resolve(ROOT, 'samples/p2-t1-LED显示屏/Form1.Designer.cs')
|
||||
|
||||
test('往分组框里添加按钮:创建语句、属性块、Controls.Add、字段声明都按 VS 格式写入', async () => {
|
||||
const doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
|
||||
const { doc: next, name } = addControl(doc, controlDef('System.Windows.Forms.Button'), 'groupBox1', { x: 20, y: 100 })
|
||||
expect(name).toBe('button1')
|
||||
const text = generateDesigner(next)
|
||||
expect(text).toContain(' this.button1 = new System.Windows.Forms.Button();\n')
|
||||
expect(text).toContain(
|
||||
[
|
||||
' // ',
|
||||
' // button1',
|
||||
' // ',
|
||||
' this.button1.Location = new System.Drawing.Point(20, 100);',
|
||||
' this.button1.Name = "button1";',
|
||||
' this.button1.Size = new System.Drawing.Size(75, 23);',
|
||||
' this.button1.TabIndex = 6;',
|
||||
' this.button1.Text = "button1";',
|
||||
' this.button1.UseVisualStyleBackColor = true;',
|
||||
].join('\n'),
|
||||
)
|
||||
// 新控件在最上层:Controls.Add 排在第一个
|
||||
expect(childrenOf(next, 'groupBox1')[0]).toBe('button1')
|
||||
expect(text).toContain(' private System.Windows.Forms.Button button1;\n')
|
||||
// 属性块在窗体块之前
|
||||
expect(text.indexOf('// button1')).toBeLessThan(text.indexOf('// Form1'))
|
||||
})
|
||||
|
||||
test('添加 Timer:自动加上 components 容器,不放进窗体', async () => {
|
||||
const doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
|
||||
const { doc: next } = addControl(doc, controlDef('System.Windows.Forms.Timer'), '', { x: 0, y: 0 })
|
||||
const text = generateDesigner(next)
|
||||
expect(text).toContain(' this.components = new System.ComponentModel.Container();\n')
|
||||
expect(text).toContain('this.timer1 = new System.Windows.Forms.Timer(this.components);')
|
||||
expect(listControls(next).find((c) => c.name === 'timer1')?.parent).toBeNull()
|
||||
})
|
||||
|
||||
test('第一次往空的分组框里放控件:补上 SuspendLayout / ResumeLayout / PerformLayout', async () => {
|
||||
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
|
||||
doc = addControl(doc, controlDef('System.Windows.Forms.GroupBox'), '', { x: 12, y: 150 }).doc
|
||||
expect(generateDesigner(doc)).not.toContain('this.groupBox2.SuspendLayout();')
|
||||
doc = addControl(doc, controlDef('System.Windows.Forms.Label'), 'groupBox2', { x: 6, y: 20 }).doc
|
||||
const text = generateDesigner(doc)
|
||||
expect(text).toContain('this.groupBox2.SuspendLayout();')
|
||||
expect(text).toContain(' this.groupBox2.ResumeLayout(false);\n this.groupBox2.PerformLayout();')
|
||||
// 标签是 AutoSize,窗体本身没有直接的 AutoSize 子控件
|
||||
expect(text).not.toContain(' this.PerformLayout();')
|
||||
})
|
||||
|
||||
test('设置、修改、删除属性;改动的语句按排序插入', async () => {
|
||||
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
|
||||
doc = setProp(doc, 'btn_Send', 'Text', { kind: 'string', value: '发送 "LED"' })
|
||||
doc = setProp(doc, 'btn_Send', 'BackColor', { kind: 'color', css: '#ff0000', name: 'Red', r: 255, g: 0, b: 0, a: 255 })
|
||||
const text = generateDesigner(doc)
|
||||
expect(text).toContain('this.btn_Send.Text = "发送 \\"LED\\"";')
|
||||
// BackColor 排在 Location 之前
|
||||
expect(text).toContain(' this.btn_Send.BackColor = System.Drawing.Color.Red;\n this.btn_Send.Location')
|
||||
doc = setProp(doc, 'btn_Send', 'BackColor', null)
|
||||
expect(generateDesigner(doc)).not.toContain('btn_Send.BackColor')
|
||||
})
|
||||
|
||||
test('改名:所有引用和字段声明一起改', async () => {
|
||||
const doc = renameControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'btn_Send', 'btnSend')
|
||||
const text = generateDesigner(doc)
|
||||
expect(text).not.toContain('btn_Send.')
|
||||
expect(text).toContain('this.btnSend.Name = "btnSend";')
|
||||
expect(text).toContain('this.groupBox1.Controls.Add(this.btnSend);')
|
||||
expect(text).toContain('private System.Windows.Forms.Button btnSend;')
|
||||
// 事件处理方法名不变(和 VS 一样)
|
||||
expect(text).toContain('this.btnSend.Click += new System.EventHandler(this.btn_Send_Click);')
|
||||
})
|
||||
|
||||
test('删除分组框:连同里面的控件和字段一起删除,布局语句同步', async () => {
|
||||
const doc = removeControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'groupBox1')
|
||||
const text = generateDesigner(doc)
|
||||
for (const name of ['groupBox1', 'btn_Send', 'com_Port', 'label1']) expect(text).not.toContain(`this.${name}`)
|
||||
expect(text).not.toContain('private System.Windows.Forms.Button btn_Send;')
|
||||
expect(listControls(doc).map((c) => c.name)).toEqual(['led'])
|
||||
})
|
||||
|
||||
test('把控件从分组框拖到窗体上', async () => {
|
||||
const doc = moveControl(parseDesigner(await Bun.file(LED_SAMPLE).text()), 'btn_Send', '', { x: 30, y: 200 })
|
||||
expect(childrenOf(doc, '')).toContain('btn_Send')
|
||||
expect(childrenOf(doc, 'groupBox1')).not.toContain('btn_Send')
|
||||
expect(getPropText(doc, 'btn_Send', 'Location')).toBe('new System.Drawing.Point(30, 200)')
|
||||
})
|
||||
|
||||
test('事件绑定与下拉框列表项', async () => {
|
||||
let doc = parseDesigner(await Bun.file(LED_SAMPLE).text())
|
||||
doc = setEvent(doc, '', 'Load', 'System.EventHandler', 'Form1_Load')
|
||||
doc = setItems(doc, 'com_Port', ['COM1', 'COM2'])
|
||||
const text = generateDesigner(doc)
|
||||
expect(text).toContain(' this.Text = "LED显示屏";\n this.Load += new System.EventHandler(this.Form1_Load);')
|
||||
expect(text).toContain('this.com_Port.Items.AddRange(new object[] {\n "COM1",\n "COM2"});')
|
||||
// 列表项排在 FormattingEnabled 之后、Location 之前
|
||||
expect(text.indexOf('Items.AddRange')).toBeGreaterThan(text.indexOf('com_Port.FormattingEnabled'))
|
||||
expect(text.indexOf('Items.AddRange')).toBeLessThan(text.indexOf('com_Port.Location'))
|
||||
})
|
||||
@@ -0,0 +1,365 @@
|
||||
// 对 Designer.cs 文档的编辑操作。每个操作返回新文档,不修改传入的文档。
|
||||
// 修改过的语句清掉原文(text),生成时按 VS 格式重写;其余语句原样保留。
|
||||
import { type ControlDef, controlDef, isContainerType } from './catalog'
|
||||
import type { Block, DesignerDocument, Stmt } from './document'
|
||||
import { renderValue, type Value } from './values'
|
||||
|
||||
export interface ControlInfo {
|
||||
name: string
|
||||
type: string
|
||||
/** 所在容器:'' 表示直接放在窗体上;null 表示不在窗体上(如 Timer 组件) */
|
||||
parent: string | null
|
||||
}
|
||||
|
||||
// ---------- 查询 ----------
|
||||
|
||||
export function listControls(doc: DesignerDocument): ControlInfo[] {
|
||||
const parents = new Map<string, string>()
|
||||
for (const block of doc.blocks) {
|
||||
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
|
||||
}
|
||||
return doc.prologue.flatMap((stmt) =>
|
||||
stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : [],
|
||||
)
|
||||
}
|
||||
|
||||
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
|
||||
export function childrenOf(doc: DesignerDocument, parent: string): string[] {
|
||||
return findBlock(doc, parent)?.stmts.flatMap((s) => (s.kind === 'add' ? [s.child] : [])) ?? []
|
||||
}
|
||||
|
||||
export function findBlock(doc: DesignerDocument, target: string): Block | undefined {
|
||||
const name = target || doc.formName
|
||||
return doc.blocks.find((b) => b.name === name)
|
||||
}
|
||||
|
||||
export function getPropText(doc: DesignerDocument, target: string, path: string): string | undefined {
|
||||
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'prop' && s.path === path)
|
||||
return stmt?.kind === 'prop' ? stmt.value : undefined
|
||||
}
|
||||
|
||||
export function getEventHandler(doc: DesignerDocument, target: string, event: string): string | undefined {
|
||||
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'event' && s.event === event)
|
||||
return stmt?.kind === 'event' ? stmt.handler : undefined
|
||||
}
|
||||
|
||||
export function getItems(doc: DesignerDocument, target: string): string[] {
|
||||
const stmt = findBlock(doc, target)?.stmts.find((s) => s.kind === 'items')
|
||||
return stmt?.kind === 'items' ? stmt.items : []
|
||||
}
|
||||
|
||||
export function typeOf(doc: DesignerDocument, name: string) {
|
||||
return listControls(doc).find((c) => c.name === name)?.type
|
||||
}
|
||||
|
||||
// ---------- 修改 ----------
|
||||
|
||||
function clone(doc: DesignerDocument): DesignerDocument {
|
||||
return structuredClone(doc)
|
||||
}
|
||||
|
||||
/** 取得控件(或窗体)的属性块;没有就新建,位置在窗体块之前(与 VS 一样按创建顺序排列) */
|
||||
function ensureBlock(doc: DesignerDocument, target: string): Block {
|
||||
const existing = findBlock(doc, target)
|
||||
if (existing) return existing
|
||||
const block: Block = { name: target || doc.formName, stmts: [] }
|
||||
const formIndex = doc.blocks.findIndex((b) => b.name === doc.formName)
|
||||
doc.blocks.splice(formIndex < 0 ? doc.blocks.length : formIndex, 0, block)
|
||||
return block
|
||||
}
|
||||
|
||||
/** VS 按属性名排序输出:Controls.Add 当作 "Controls"、Items.AddRange 当作 "Items",事件排在所有属性之后 */
|
||||
function sortKey(stmt: Stmt): string | undefined {
|
||||
if (stmt.kind === 'prop') return stmt.path
|
||||
if (stmt.kind === 'add') return 'Controls'
|
||||
if (stmt.kind === 'items') return 'Items'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function insertSorted(block: Block, stmt: Stmt) {
|
||||
const key = sortKey(stmt)!
|
||||
let index = block.stmts.findIndex((s) => s.kind === 'event' || (sortKey(s) !== undefined && sortKey(s)! > key))
|
||||
if (index < 0) index = block.stmts.length
|
||||
block.stmts.splice(index, 0, stmt)
|
||||
}
|
||||
|
||||
export function setProp(doc: DesignerDocument, target: string, path: string, value: Value | null): DesignerDocument {
|
||||
const next = clone(doc)
|
||||
applyProp(next, target, path, value)
|
||||
if (path === 'AutoSize') syncLayout(next)
|
||||
return next
|
||||
}
|
||||
|
||||
function applyProp(doc: DesignerDocument, target: string, path: string, value: Value | null) {
|
||||
const block = ensureBlock(doc, target)
|
||||
const index = block.stmts.findIndex((s) => s.kind === 'prop' && s.path === path)
|
||||
if (value === null) {
|
||||
if (index >= 0) block.stmts.splice(index, 1)
|
||||
return
|
||||
}
|
||||
const text = renderValue(value)
|
||||
if (index >= 0) {
|
||||
const stmt = block.stmts[index] as Extract<Stmt, { kind: 'prop' }>
|
||||
if (stmt.value !== text) block.stmts[index] = { kind: 'prop', target: stmt.target, path, value: text }
|
||||
return
|
||||
}
|
||||
insertSorted(block, { kind: 'prop', target, path, value: text })
|
||||
}
|
||||
|
||||
export function setItems(doc: DesignerDocument, target: string, items: string[]): DesignerDocument {
|
||||
const next = clone(doc)
|
||||
const block = ensureBlock(next, target)
|
||||
const index = block.stmts.findIndex((s) => s.kind === 'items')
|
||||
if (index >= 0) block.stmts.splice(index, 1)
|
||||
if (items.length) insertSorted(block, { kind: 'items', target, items: items.map((s) => renderValue({ kind: 'string', value: s })) })
|
||||
return next
|
||||
}
|
||||
|
||||
/** 设置事件处理方法;handler 为 null 时删除这个事件绑定 */
|
||||
export function setEvent(doc: DesignerDocument, target: string, event: string, delegate: string, handler: string | null): DesignerDocument {
|
||||
const next = clone(doc)
|
||||
const block = ensureBlock(next, target)
|
||||
const index = block.stmts.findIndex((s) => s.kind === 'event' && s.event === event)
|
||||
if (index >= 0) block.stmts.splice(index, 1)
|
||||
if (handler) {
|
||||
const stmt: Stmt = { kind: 'event', target, event, delegate, handler }
|
||||
// 事件排在属性之后,按事件名排序
|
||||
let at = block.stmts.findIndex((s) => s.kind === 'event' && s.event > event)
|
||||
if (at < 0) at = block.stmts.length
|
||||
block.stmts.splice(index >= 0 ? index : at, 0, stmt)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set(
|
||||
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' '),
|
||||
)
|
||||
|
||||
/** 检查控件名是否可用;可用返回 null,否则返回原因 */
|
||||
export function checkName(doc: DesignerDocument, name: string, current?: string): string | null {
|
||||
if (!/^[A-Za-z_一-龥][A-Za-z0-9_一-龥]*$/.test(name)) return '名称只能由字母、数字、下划线组成,且不能以数字开头'
|
||||
if (KEYWORDS.has(name)) return `“${name}”是 C# 的关键字,不能作为名称`
|
||||
if (name !== current && (name === doc.formName || name === doc.className || name === 'components' || listControls(doc).some((c) => c.name === name))) {
|
||||
return `已经有名为“${name}”的控件`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function uniqueName(doc: DesignerDocument, prefix: string) {
|
||||
for (let n = 1; ; n++) if (checkName(doc, `${prefix}${n}`) === null) return `${prefix}${n}`
|
||||
}
|
||||
|
||||
/** 往容器里添加控件(parent 为 '' 表示窗体);组件(如 Timer)不放进容器。返回新文档和控件名 */
|
||||
export function addControl(
|
||||
doc: DesignerDocument,
|
||||
def: ControlDef,
|
||||
parent: string,
|
||||
location: { x: number; y: number },
|
||||
): { doc: DesignerDocument; name: string } {
|
||||
const next = clone(doc)
|
||||
const name = uniqueName(next, def.namePrefix)
|
||||
|
||||
// 组件需要 components 容器:this.timer1 = new System.Windows.Forms.Timer(this.components);
|
||||
if (def.component && !next.prologue.some((s) => s.kind === 'raw' && s.text.startsWith('this.components = new'))) {
|
||||
next.prologue.unshift({ kind: 'raw', text: 'this.components = new System.ComponentModel.Container();' })
|
||||
}
|
||||
const create: Stmt = { kind: 'create', name, type: def.type, args: def.component ? 'this.components' : '' }
|
||||
const lastCreate = next.prologue.findLastIndex((s) => s.kind === 'create')
|
||||
const componentsAt = next.prologue.findIndex((s) => s.kind === 'raw' && s.text.startsWith('this.components = new'))
|
||||
next.prologue.splice(lastCreate >= 0 ? lastCreate + 1 : componentsAt + 1, 0, create)
|
||||
|
||||
const block = ensureBlock(next, name)
|
||||
const props: [string, Value][] = [...def.initial(name)]
|
||||
if (!def.component) {
|
||||
props.push(['Location', { kind: 'point', x: Math.round(location.x), y: Math.round(location.y) }])
|
||||
props.push(['Name', { kind: 'string', value: name }])
|
||||
props.push(['TabIndex', { kind: 'int', value: childrenOf(next, parent).length }])
|
||||
}
|
||||
for (const [path, value] of props) insertSorted(block, { kind: 'prop', target: name, path, value: renderValue(value) })
|
||||
|
||||
if (!def.component) insertChild(next, parent, name)
|
||||
next.tail = addField(next.tail, def.type, name, next.eol)
|
||||
syncLayout(next)
|
||||
return { doc: next, name }
|
||||
}
|
||||
|
||||
/** 新加入的子控件在最上层:Controls.Add 放在已有的 Controls.Add 之前 */
|
||||
function insertChild(doc: DesignerDocument, parent: string, child: string) {
|
||||
const block = ensureBlock(doc, parent)
|
||||
const stmt: Stmt = { kind: 'add', target: parent, child }
|
||||
const first = block.stmts.findIndex((s) => s.kind === 'add')
|
||||
if (first >= 0) block.stmts.splice(first, 0, stmt)
|
||||
else insertSorted(block, stmt)
|
||||
}
|
||||
|
||||
/** 删除控件及其包含的全部子控件。事件处理方法留在代码里(和 VS 一样) */
|
||||
export function removeControl(doc: DesignerDocument, name: string): DesignerDocument {
|
||||
const next = clone(doc)
|
||||
const doomed = new Set<string>()
|
||||
const collect = (n: string) => {
|
||||
doomed.add(n)
|
||||
for (const child of childrenOf(next, n)) collect(child)
|
||||
}
|
||||
collect(name)
|
||||
const keep = (s: Stmt) =>
|
||||
!(
|
||||
(s.kind === 'create' && doomed.has(s.name)) ||
|
||||
(s.kind === 'add' && doomed.has(s.child)) ||
|
||||
(s.kind === 'layout' && doomed.has(s.target))
|
||||
)
|
||||
next.prologue = next.prologue.filter(keep)
|
||||
next.epilogue = next.epilogue.filter(keep)
|
||||
next.blocks = next.blocks.filter((b) => !doomed.has(b.name))
|
||||
for (const block of next.blocks) block.stmts = block.stmts.filter(keep)
|
||||
for (const n of doomed) next.tail = removeField(next.tail, n)
|
||||
syncLayout(next)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 改名:更新所有引用这个控件的语句和字段声明。代码文件里的引用需要学生自己改 */
|
||||
export function renameControl(doc: DesignerDocument, from: string, to: string): DesignerDocument {
|
||||
const next = clone(doc)
|
||||
const fix = (s: Stmt): Stmt => {
|
||||
switch (s.kind) {
|
||||
case 'create':
|
||||
return s.name === from ? { ...s, name: to, text: undefined } : s
|
||||
case 'prop':
|
||||
if (s.target !== from) return s
|
||||
return s.path === 'Name' ? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined } : { ...s, target: to, text: undefined }
|
||||
case 'event':
|
||||
case 'items':
|
||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
||||
case 'add':
|
||||
return s.target === from || s.child === from
|
||||
? { ...s, target: s.target === from ? to : s.target, child: s.child === from ? to : s.child, text: undefined }
|
||||
: s
|
||||
case 'layout':
|
||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
||||
case 'raw':
|
||||
return s.text.includes(`this.${from}`) ? { kind: 'raw', text: s.text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])this\\.${from}(?![\\p{L}\\p{N}_])`, 'gu'), `this.${to}`) } : s
|
||||
}
|
||||
}
|
||||
next.prologue = next.prologue.map(fix)
|
||||
next.epilogue = next.epilogue.map(fix)
|
||||
for (const block of next.blocks) {
|
||||
if (block.name === from) block.name = to
|
||||
block.stmts = block.stmts.map(fix)
|
||||
}
|
||||
next.tail = next.tail.replace(new RegExp(`(private [\\p{L}\\p{N}_.]+ )${from};`, 'u'), `$1${to};`)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 移动控件:可以换到另一个容器里(location 是相对新容器的坐标) */
|
||||
export function moveControl(doc: DesignerDocument, name: string, parent: string, location: { x: number; y: number }): DesignerDocument {
|
||||
let next = clone(doc)
|
||||
const current = listControls(next).find((c) => c.name === name)?.parent
|
||||
if (current !== null && current !== undefined && current !== parent) {
|
||||
for (const block of next.blocks) block.stmts = block.stmts.filter((s) => !(s.kind === 'add' && s.child === name))
|
||||
insertChild(next, parent, name)
|
||||
syncLayout(next)
|
||||
}
|
||||
applyProp(next, name, 'Location', { kind: 'point', x: Math.round(location.x), y: Math.round(location.y) })
|
||||
return next
|
||||
}
|
||||
|
||||
// ---------- 布局语句 ----------
|
||||
|
||||
/**
|
||||
* 按 VS 的规则维护 SuspendLayout / ResumeLayout / PerformLayout:
|
||||
* 有子控件的容器在开头 SuspendLayout、末尾 ResumeLayout(false);
|
||||
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
|
||||
*/
|
||||
export function syncLayout(doc: DesignerDocument) {
|
||||
const containers = ['', ...listControls(doc).filter((c) => isContainerType(c.type)).map((c) => c.name)]
|
||||
for (const container of containers) {
|
||||
const children = childrenOf(doc, container)
|
||||
const hasChildren = children.length > 0 || container === ''
|
||||
const autoChild = children.some((child) => isAutoSized(doc, child))
|
||||
if (container === '') {
|
||||
// 窗体:SuspendLayout / ResumeLayout 总是有;PerformLayout 取决于直接子控件
|
||||
ensureLayout(doc, '', 'SuspendLayout', true)
|
||||
ensureLayout(doc, '', 'ResumeLayout', true)
|
||||
ensureLayout(doc, '', 'PerformLayout', autoChild)
|
||||
continue
|
||||
}
|
||||
ensureLayout(doc, container, 'SuspendLayout', hasChildren)
|
||||
ensureLayout(doc, container, 'ResumeLayout', hasChildren)
|
||||
ensureLayout(doc, container, 'PerformLayout', hasChildren && autoChild)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 子控件是否自动调整大小(VS 据此决定是否输出 PerformLayout)。
|
||||
* 除了 AutoSize = true,TextBox、TrackBar 的 AutoSize 默认就是 true(多行 TextBox 也算,只是不生效),
|
||||
* PictureBox 的 SizeMode 也可以是 AutoSize。
|
||||
*/
|
||||
function isAutoSized(doc: DesignerDocument, name: string) {
|
||||
const autoSize = getPropText(doc, name, 'AutoSize')
|
||||
if (autoSize !== undefined) return autoSize === 'true'
|
||||
switch (typeOf(doc, name)) {
|
||||
case 'System.Windows.Forms.TextBox':
|
||||
case 'System.Windows.Forms.TrackBar':
|
||||
return true
|
||||
case 'System.Windows.Forms.PictureBox':
|
||||
return getPropText(doc, name, 'SizeMode')?.endsWith('.AutoSize') === true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout' | 'ResumeLayout' | 'PerformLayout', wanted: boolean) {
|
||||
const list = op === 'SuspendLayout' ? doc.prologue : doc.epilogue
|
||||
const index = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === op)
|
||||
if (!wanted) {
|
||||
if (index >= 0) list.splice(index, 1)
|
||||
return
|
||||
}
|
||||
if (index >= 0) return
|
||||
const stmt: Stmt = { kind: 'layout', target, op }
|
||||
if (op === 'SuspendLayout') {
|
||||
// 窗体的 SuspendLayout 在最后;容器的排在 BeginInit 和窗体的 SuspendLayout 之前
|
||||
const at = target === '' ? -1 : list.findIndex((s) => s.kind === 'layout' && (s.op === 'BeginInit' || s.target === ''))
|
||||
list.splice(at < 0 ? list.length : at, 0, stmt)
|
||||
} else if (target === '') {
|
||||
// 窗体的 ResumeLayout(false) 在 EndInit 之后,PerformLayout 紧跟其后
|
||||
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === '' && s.op === 'ResumeLayout')
|
||||
list.splice(op === 'PerformLayout' && resume >= 0 ? resume + 1 : list.length, 0, stmt)
|
||||
} else {
|
||||
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
|
||||
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
|
||||
const at = op === 'PerformLayout' && resume >= 0
|
||||
? resume + 1
|
||||
: list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
|
||||
list.splice(at < 0 ? list.length : at, 0, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 字段声明 ----------
|
||||
|
||||
const FIELD = /^([ \t]*)private ([\p{L}\p{N}_.]+) ([\p{L}\p{N}_]+);[ \t]*$/gmu
|
||||
|
||||
function addField(tail: string, type: string, name: string, eol: string) {
|
||||
const fields = [...tail.matchAll(FIELD)]
|
||||
const last = fields.at(-1)
|
||||
if (last) {
|
||||
const lineEnd = tail.indexOf('\n', last.index! + last[0].length)
|
||||
const at = lineEnd < 0 ? tail.length : lineEnd + 1
|
||||
return `${tail.slice(0, at)}${last[1]}private ${type} ${name};${eol}${tail.slice(at)}`
|
||||
}
|
||||
// 还没有字段:放在 #endregion 之后
|
||||
const region = /#endregion[^\n]*\n/.exec(tail)
|
||||
if (!region) return tail
|
||||
const at = region.index + region[0].length
|
||||
return `${tail.slice(0, at)}${eol} private ${type} ${name};${eol}${tail.slice(at)}`
|
||||
}
|
||||
|
||||
function removeField(tail: string, name: string) {
|
||||
return tail.replace(new RegExp(`^[ \\t]*private [\\p{L}\\p{N}_.]+ ${name};[ \\t]*\\r?\\n`, 'mu'), '')
|
||||
}
|
||||
|
||||
/** 窗体或控件的显示名:窗体用类名 */
|
||||
export function displayType(doc: DesignerDocument, name: string) {
|
||||
if (name === '') return 'Form'
|
||||
const type = typeOf(doc, name)
|
||||
return type ? controlDef(type).label : ''
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from 'bun:test'
|
||||
import { createDesignerHistory } from './history'
|
||||
|
||||
function setup() {
|
||||
const files = new Map([
|
||||
['F.Designer.cs', 'd0'],
|
||||
['F.cs', 'c0'],
|
||||
])
|
||||
const history = createDesignerHistory({ read: (p) => files.get(p) ?? '', write: (p, c) => files.set(p, c) })
|
||||
/** 模拟设计器的一次改动 */
|
||||
const change = (designer: string, code?: string) => {
|
||||
history.record('F.Designer.cs', {
|
||||
designerBefore: files.get('F.Designer.cs')!,
|
||||
designerAfter: designer,
|
||||
...(code !== undefined ? { codeBefore: files.get('F.cs')!, codeAfter: code } : {}),
|
||||
})
|
||||
files.set('F.Designer.cs', designer)
|
||||
if (code !== undefined) files.set('F.cs', code)
|
||||
}
|
||||
return { files, history, change }
|
||||
}
|
||||
|
||||
test('撤销、重做依次恢复 Designer.cs;新的改动清空重做', () => {
|
||||
const { files, history, change } = setup()
|
||||
change('d1')
|
||||
change('d2')
|
||||
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(true)
|
||||
expect(files.get('F.Designer.cs')).toBe('d1')
|
||||
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(true)
|
||||
expect(files.get('F.Designer.cs')).toBe('d0')
|
||||
expect(history.canUndo('F.Designer.cs')).toBe(false)
|
||||
expect(history.redo('F.Designer.cs', 'F.cs')).toBe(true)
|
||||
expect(files.get('F.Designer.cs')).toBe('d1')
|
||||
change('d3')
|
||||
expect(history.canRedo('F.Designer.cs')).toBe(false)
|
||||
})
|
||||
|
||||
test('撤销"生成事件处理方法":事件绑定和插入的方法一起撤掉', () => {
|
||||
const { files, history, change } = setup()
|
||||
change('d1+event', 'c0+handler')
|
||||
history.undo('F.Designer.cs', 'F.cs')
|
||||
expect(files.get('F.Designer.cs')).toBe('d0')
|
||||
expect(files.get('F.cs')).toBe('c0')
|
||||
history.redo('F.Designer.cs', 'F.cs')
|
||||
expect(files.get('F.cs')).toBe('c0+handler')
|
||||
})
|
||||
|
||||
test('学生已经在生成的方法里写了代码:撤销只回退设计,不动代码', () => {
|
||||
const { files, history, change } = setup()
|
||||
change('d1+event', 'c0+handler')
|
||||
files.set('F.cs', 'c0+handler+学生写的代码')
|
||||
history.undo('F.Designer.cs', 'F.cs')
|
||||
expect(files.get('F.Designer.cs')).toBe('d0')
|
||||
expect(files.get('F.cs')).toBe('c0+handler+学生写的代码')
|
||||
})
|
||||
|
||||
test('Designer.cs 在代码页里被手动改过:历史失效并清空,不覆盖手改的内容', () => {
|
||||
const { files, history, change } = setup()
|
||||
change('d1')
|
||||
files.set('F.Designer.cs', 'd1 手动改过')
|
||||
expect(history.undo('F.Designer.cs', 'F.cs')).toBe(false)
|
||||
expect(files.get('F.Designer.cs')).toBe('d1 手动改过')
|
||||
expect(history.canUndo('F.Designer.cs')).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
// 窗体设计器的撤销 / 重做。每个窗体(按 Designer.cs 路径)一份历史,保存在页面层,切换标签页不会丢。
|
||||
// 每一步记录 Designer.cs 改动前后的内容;如果这一步还往窗体代码里插入了事件处理方法,代码文件的前后内容也一起记下。
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface Step {
|
||||
designerBefore: string
|
||||
designerAfter: string
|
||||
/** 只有这一步改了窗体代码(插入事件处理方法)时才有 */
|
||||
codeBefore?: string
|
||||
codeAfter?: string
|
||||
}
|
||||
|
||||
interface Stacks {
|
||||
undo: Step[]
|
||||
redo: Step[]
|
||||
}
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export interface FileAccess {
|
||||
read(path: string): string
|
||||
write(path: string, content: string): void
|
||||
}
|
||||
|
||||
export function createDesignerHistory(files: FileAccess) {
|
||||
const stacks = reactive(new Map<string, Stacks>())
|
||||
|
||||
function of(designer: string): Stacks {
|
||||
let s = stacks.get(designer)
|
||||
if (!s) {
|
||||
s = { undo: [], redo: [] }
|
||||
stacks.set(designer, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function record(designer: string, step: Step) {
|
||||
const s = of(designer)
|
||||
s.undo.push(step)
|
||||
if (s.undo.length > LIMIT) s.undo.shift()
|
||||
s.redo = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销或重做一步。Designer.cs 在代码页里被手动改过(和历史记录对不上)时,这个窗体的历史失效、清空,返回 false。
|
||||
* 代码文件只在内容仍是这一步的结果时才回退——学生已经在生成的方法里写了代码,就不去动它。
|
||||
*/
|
||||
function apply(designer: string, code: string, direction: 'undo' | 'redo'): boolean {
|
||||
const s = of(designer)
|
||||
const from = direction === 'undo' ? s.undo : s.redo
|
||||
const to = direction === 'undo' ? s.redo : s.undo
|
||||
const step = from.at(-1)
|
||||
if (!step) return false
|
||||
const [expected, target] = direction === 'undo' ? [step.designerAfter, step.designerBefore] : [step.designerBefore, step.designerAfter]
|
||||
if (files.read(designer) !== expected) {
|
||||
clear(designer)
|
||||
return false
|
||||
}
|
||||
from.pop()
|
||||
files.write(designer, target)
|
||||
if (step.codeBefore !== undefined && step.codeAfter !== undefined) {
|
||||
const [codeExpected, codeTarget] = direction === 'undo' ? [step.codeAfter, step.codeBefore] : [step.codeBefore, step.codeAfter]
|
||||
if (files.read(code) === codeExpected) files.write(code, codeTarget)
|
||||
}
|
||||
to.push(step)
|
||||
return true
|
||||
}
|
||||
|
||||
function clear(designer?: string) {
|
||||
if (designer) stacks.delete(designer)
|
||||
else stacks.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
undo: (designer: string, code: string) => apply(designer, code, 'undo'),
|
||||
redo: (designer: string, code: string) => apply(designer, code, 'redo'),
|
||||
canUndo: (designer: string) => (stacks.get(designer)?.undo.length ?? 0) > 0,
|
||||
canRedo: (designer: string) => (stacks.get(designer)?.redo.length ?? 0) > 0,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
|
||||
export type DesignerHistory = ReturnType<typeof createDesignerHistory>
|
||||
@@ -0,0 +1,87 @@
|
||||
// 把设计器文档转换成与运行时相同格式的控件树,直接用运行时的 ControlView 渲染,
|
||||
// 保证设计时看到的样子和运行时一致。
|
||||
import type { ControlNode } from '@iot/shared'
|
||||
import { controlDef } from './catalog'
|
||||
import type { DesignerDocument } from './document'
|
||||
import { childrenOf, findBlock, getItems, listControls } from './edit'
|
||||
import { parseValue, type Value } from './values'
|
||||
|
||||
/** 窗体在预览树里的 id;控件的 id 就是控件名 */
|
||||
export const FORM_ID = '__form'
|
||||
|
||||
/** 运行时控件树里的类型名:System.Windows.Forms.Button → Button,教材控件库保留完整类名 */
|
||||
function clientType(type: string) {
|
||||
return type.startsWith('System.Windows.Forms.') ? type.slice('System.Windows.Forms.'.length) : type
|
||||
}
|
||||
|
||||
/** 把 Value 转换成运行时消息里的属性值格式(见 web/src/winforms/format.ts) */
|
||||
export function runtimeValue(value: Value): unknown {
|
||||
switch (value.kind) {
|
||||
case 'string':
|
||||
case 'char':
|
||||
return value.value
|
||||
case 'bool':
|
||||
case 'int':
|
||||
case 'float':
|
||||
return value.value
|
||||
case 'point':
|
||||
return { x: value.x, y: value.y }
|
||||
case 'size':
|
||||
return { width: value.width, height: value.height }
|
||||
case 'color':
|
||||
return value.css
|
||||
case 'font':
|
||||
return { family: value.family, size: value.size, bold: value.bold, italic: value.italic, underline: value.underline, strikeout: value.strikeout }
|
||||
case 'enum':
|
||||
return value.member
|
||||
case 'raw':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function propsOf(doc: DesignerDocument, target: string): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const stmt of findBlock(doc, target)?.stmts ?? []) {
|
||||
if (stmt.kind !== 'prop' || stmt.path.includes('.')) continue
|
||||
const value = runtimeValue(parseValue(stmt.value))
|
||||
if (value !== undefined) props[stmt.path] = value
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
|
||||
const DEVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
||||
'WinFormsDeviceControlLibrary.LED': { BackColor: '#000000', ForeColor: '#ff0000', Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false } },
|
||||
'WinFormsDeviceControlLibrary.Lamp': { BackColor: '#ffffff' },
|
||||
'WinFormsDeviceControlLibrary.Alarm': { BackColor: '#ffffff' },
|
||||
}
|
||||
|
||||
export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
const types = new Map(listControls(doc).map((c) => [c.name, c.type]))
|
||||
const build = (name: string): ControlNode => {
|
||||
const type = types.get(name)!
|
||||
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
|
||||
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
|
||||
props.Visible = true
|
||||
if (type === 'System.Windows.Forms.ComboBox') props.Items = getItems(doc, name).map((item) => {
|
||||
const v = parseValue(item)
|
||||
return v.kind === 'string' ? v.value : item
|
||||
})
|
||||
// 设计器里不需要滚动:LED 只显示黑底
|
||||
if (type === 'WinFormsDeviceControlLibrary.LED') props.LedText = ''
|
||||
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
|
||||
}
|
||||
const formProps = propsOf(doc, '')
|
||||
return {
|
||||
id: FORM_ID,
|
||||
type: 'Form',
|
||||
name: doc.formName,
|
||||
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
|
||||
children: childrenOf(doc, '').filter((c) => types.has(c)).map(build),
|
||||
}
|
||||
}
|
||||
|
||||
/** 非可视组件(Timer 等),显示在窗体下方的组件栏 */
|
||||
export function componentsOf(doc: DesignerDocument) {
|
||||
return listControls(doc).filter((c) => c.parent === null && controlDef(c.type).component === true)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from 'bun:test'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { addControl } from './edit'
|
||||
import { controlDef } from './catalog'
|
||||
import { formNameTaken, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
|
||||
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
|
||||
{ path: 'Login.cs', content: 'namespace 登录\n{\n public partial class Login : Form\n {\n }\n}\n' },
|
||||
]
|
||||
|
||||
test('新窗体沿用工程的命名空间,名字不与已有窗体重复', () => {
|
||||
expect(projectNamespace(files)).toBe('登录')
|
||||
expect(nextFormName(files)).toBe('Form2')
|
||||
expect(formNameTaken(files, 'Login')).toBe(true)
|
||||
expect(startupForm(files)).toBe('Login')
|
||||
})
|
||||
|
||||
test('新窗体的 Designer.cs 能被设计器打开,添加控件后格式正确', () => {
|
||||
const [, designer] = newFormFiles('登录', 'AddUser')
|
||||
const doc = parseDesigner(designer.content)
|
||||
expect(generateDesigner(doc)).toBe(designer.content)
|
||||
expect(doc.formName).toBe('AddUser')
|
||||
const text = generateDesigner(addControl(doc, controlDef('System.Windows.Forms.Button'), '', { x: 10, y: 10 }).doc)
|
||||
expect(text).toContain(' this.button1 = new System.Windows.Forms.Button();\n this.SuspendLayout();')
|
||||
expect(text).toContain(' this.Controls.Add(this.button1);\n this.Name = "AddUser";')
|
||||
expect(text).toContain(' #endregion\n\n private System.Windows.Forms.Button button1;\n }')
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// 新建窗体:生成与 VS(中文版、.NET Framework 4.5)新建窗体并保存一次后相同的两个文件。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
|
||||
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
|
||||
export function projectNamespace(files: SourceFile[]): string {
|
||||
const ordered = [...files].sort((a, b) => Number(b.path === 'Program.cs') - Number(a.path === 'Program.cs'))
|
||||
for (const file of ordered) {
|
||||
const m = /^\s*namespace\s+([\p{L}\p{N}_.]+)/mu.exec(file.content)
|
||||
if (m) return m[1]
|
||||
}
|
||||
return 'WindowsFormsApplication1'
|
||||
}
|
||||
|
||||
/** 下一个可用的窗体名:Form2、Form3…… */
|
||||
export function nextFormName(files: SourceFile[]): string {
|
||||
for (let n = 2; ; n++) if (!formNameTaken(files, `Form${n}`)) return `Form${n}`
|
||||
}
|
||||
|
||||
/** 窗体名是否已被使用(同名文件或同名类) */
|
||||
export function formNameTaken(files: SourceFile[], name: string): boolean {
|
||||
return files.some((f) => f.path === `${name}.cs` || f.path === `${name}.Designer.cs` || new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${name}(?![\\p{L}\\p{N}_])`, 'u').test(f.content))
|
||||
}
|
||||
|
||||
/** Program.cs 里 Application.Run(new X()) 启动的窗体类名 */
|
||||
export function startupForm(files: SourceFile[]): string | undefined {
|
||||
const program = files.find((f) => f.path === 'Program.cs')
|
||||
return program && /Application\.Run\(\s*new\s+([\p{L}\p{N}_]+)\s*\(/u.exec(program.content)?.[1]
|
||||
}
|
||||
|
||||
/** VS 的属性块头:三行注释,第一、三行是 "// "(末尾带空格) */
|
||||
function blockHeader(name: string) {
|
||||
return [' // ', ` // ${name}`, ' // '].join('\n')
|
||||
}
|
||||
|
||||
export function newFormFiles(namespace: string, name: string): SourceFile[] {
|
||||
const code = `using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Data;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace ${namespace}
|
||||
{
|
||||
public partial class ${name} : Form
|
||||
{
|
||||
public ${name}()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const designer = `namespace ${namespace}
|
||||
{
|
||||
partial class ${name}
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.SuspendLayout();
|
||||
${blockHeader(name)}
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.ClientSize = new System.Drawing.Size(284, 261);
|
||||
this.Name = "${name}";
|
||||
this.Text = "${name}";
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
`
|
||||
return [
|
||||
{ path: `${name}.cs`, content: code },
|
||||
{ path: `${name}.Designer.cs`, content: designer },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 属性值的 C# 表达式 ⇄ 结构化值。读入时尽量识别;识别不了的保留原文(属性窗口里只读显示)。
|
||||
// 写出时按 VS 设计器的格式,例如 new System.Drawing.Point(12, 12)、"文字"、System.Drawing.Color.White。
|
||||
|
||||
export type Value =
|
||||
| { kind: 'string'; value: string }
|
||||
| { kind: 'char'; value: string }
|
||||
| { kind: 'bool'; value: boolean }
|
||||
| { kind: 'int'; value: number }
|
||||
| { kind: 'float'; value: number }
|
||||
| { kind: 'point'; x: number; y: number }
|
||||
| { kind: 'size'; width: number; height: number }
|
||||
| { kind: 'color'; css: string; name?: string; r: number; g: number; b: number; a: number }
|
||||
| { kind: 'font'; family: string; size: number; bold: boolean; italic: boolean; underline: boolean; strikeout: boolean; charset?: number }
|
||||
| { kind: 'enum'; type: string; member: string }
|
||||
| { kind: 'raw'; text: string }
|
||||
|
||||
// ---------- 读 ----------
|
||||
|
||||
export function parseValue(text: string): Value {
|
||||
const t = text.trim()
|
||||
let m: RegExpExecArray | null
|
||||
if (/^"(?:[^"\\]|\\.)*"$/.test(t)) return { kind: 'string', value: unescapeString(t.slice(1, -1)) }
|
||||
if (/^@"(?:[^"]|"")*"$/.test(t)) return { kind: 'string', value: t.slice(2, -1).replace(/""/g, '"') }
|
||||
if (/^'(?:[^'\\]|\\.)'$/.test(t)) return { kind: 'char', value: unescapeString(t.slice(1, -1)) }
|
||||
if (t === 'true' || t === 'false') return { kind: 'bool', value: t === 'true' }
|
||||
if (/^-?\d+$/.test(t)) return { kind: 'int', value: Number(t) }
|
||||
if ((m = /^(-?\d+(?:\.\d+)?)[Ff]$/.exec(t))) return { kind: 'float', value: Number(m[1]) }
|
||||
if ((m = /^new System\.Drawing\.Point\((-?\d+), (-?\d+)\)$/.exec(t))) return { kind: 'point', x: +m[1], y: +m[2] }
|
||||
if ((m = /^new System\.Drawing\.Size\((-?\d+), (-?\d+)\)$/.exec(t))) return { kind: 'size', width: +m[1], height: +m[2] }
|
||||
if ((m = /^System\.Drawing\.(?:Color|SystemColors)\.(\w+)$/.exec(t))) return namedColor(m[1])
|
||||
if ((m = /^System\.Drawing\.Color\.FromArgb\(([\s\S]+)\)$/.exec(t))) {
|
||||
// VS 的写法是 ((int)(((byte)(53)))),手写的可能是 53
|
||||
const parts = m[1].includes('(byte)') ? byteParts(m[1]) : m[1].split(',').map((n) => Number(n.trim()))
|
||||
if (parts.length === 3) return rgb(parts[0], parts[1], parts[2])
|
||||
if (parts.length === 4) return rgb(parts[1], parts[2], parts[3], parts[0])
|
||||
}
|
||||
if ((m = /^new System\.Drawing\.Font\("((?:[^"\\]|\\.)*)", (\d+(?:\.\d+)?)F(.*)\)$/.exec(t))) {
|
||||
const rest = m[3]
|
||||
const charset = /\(\(byte\)\((\d+)\)\)/.exec(rest)
|
||||
return {
|
||||
kind: 'font',
|
||||
family: unescapeString(m[1]),
|
||||
size: Number(m[2]),
|
||||
bold: rest.includes('FontStyle.Bold'),
|
||||
italic: rest.includes('FontStyle.Italic'),
|
||||
underline: rest.includes('FontStyle.Underline'),
|
||||
strikeout: rest.includes('FontStyle.Strikeout'),
|
||||
charset: charset ? Number(charset[1]) : undefined,
|
||||
}
|
||||
}
|
||||
if ((m = /^((?:System|WinFormsDeviceControlLibrary)(?:\.\w+)+)\.(\w+)$/.exec(t))) return { kind: 'enum', type: m[1], member: m[2] }
|
||||
return { kind: 'raw', text: t }
|
||||
}
|
||||
|
||||
/** 从 ((int)(((byte)(53)))) 这样的写法中逐个分量取出数值 */
|
||||
function byteParts(args: string) {
|
||||
return [...args.matchAll(/\(byte\)\((\d+)\)/g)].map((x) => Number(x[1]))
|
||||
}
|
||||
|
||||
function unescapeString(s: string) {
|
||||
return s.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
|
||||
if (c.length === 5) return String.fromCharCode(parseInt(c.slice(1), 16))
|
||||
return ({ n: '\n', r: '\r', t: '\t', '0': '\0', '\\': '\\', '"': '"', "'": "'" } as Record<string, string>)[c] ?? c
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- 写 ----------
|
||||
|
||||
export function renderValue(value: Value): string {
|
||||
switch (value.kind) {
|
||||
case 'string':
|
||||
return `"${escapeString(value.value)}"`
|
||||
case 'char':
|
||||
return `'${value.value === "'" ? "\\'" : escapeString(value.value)}'`
|
||||
case 'bool':
|
||||
return String(value.value)
|
||||
case 'int':
|
||||
return String(Math.round(value.value))
|
||||
case 'float':
|
||||
return `${value.value}F`
|
||||
case 'point':
|
||||
return `new System.Drawing.Point(${value.x}, ${value.y})`
|
||||
case 'size':
|
||||
return `new System.Drawing.Size(${value.width}, ${value.height})`
|
||||
case 'color':
|
||||
if (value.name) return `System.Drawing.${SYSTEM_COLORS[value.name] ? 'SystemColors' : 'Color'}.${value.name}`
|
||||
return value.a === 255
|
||||
? `System.Drawing.Color.FromArgb(${[value.r, value.g, value.b].map(byteArg).join(', ')})`
|
||||
: `System.Drawing.Color.FromArgb(${[value.a, value.r, value.g, value.b].map(byteArg).join(', ')})`
|
||||
case 'font': {
|
||||
const styles = [
|
||||
value.bold && 'Bold',
|
||||
value.italic && 'Italic',
|
||||
value.underline && 'Underline',
|
||||
value.strikeout && 'Strikeout',
|
||||
].filter(Boolean) as string[]
|
||||
const style = styles.length ? styles.map((s) => `System.Drawing.FontStyle.${s}`).join(' | ') : 'System.Drawing.FontStyle.Regular'
|
||||
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
|
||||
return `new System.Drawing.Font("${escapeString(value.family)}", ${value.size}F, ${style}, System.Drawing.GraphicsUnit.Point, ((byte)(${value.charset ?? 134})))`
|
||||
}
|
||||
case 'enum':
|
||||
return `${value.type}.${value.member}`
|
||||
case 'raw':
|
||||
return value.text
|
||||
}
|
||||
}
|
||||
|
||||
const byteArg = (n: number) => `((int)(((byte)(${n}))))`
|
||||
|
||||
function escapeString(s: string) {
|
||||
return s.replace(/[\\"\r\n\t\0]/g, (c) => ({ '\\': '\\\\', '"': '\\"', '\r': '\\r', '\n': '\\n', '\t': '\\t', '\0': '\\0' })[c]!)
|
||||
}
|
||||
|
||||
// ---------- 颜色 ----------
|
||||
|
||||
/** 常用的命名颜色(System.Drawing.Color),属性窗口里提供这些选项 */
|
||||
export const NAMED_COLORS: Record<string, string> = {
|
||||
Transparent: 'transparent',
|
||||
Black: '#000000',
|
||||
White: '#ffffff',
|
||||
Red: '#ff0000',
|
||||
Green: '#008000',
|
||||
Blue: '#0000ff',
|
||||
Yellow: '#ffff00',
|
||||
Orange: '#ffa500',
|
||||
Gray: '#808080',
|
||||
DimGray: '#696969',
|
||||
Silver: '#c0c0c0',
|
||||
LightGray: '#d3d3d3',
|
||||
DarkBlue: '#00008b',
|
||||
SkyBlue: '#87ceeb',
|
||||
LightGreen: '#90ee90',
|
||||
Lime: '#00ff00',
|
||||
Purple: '#800080',
|
||||
Brown: '#a52a2a',
|
||||
Pink: '#ffc0cb',
|
||||
Cyan: '#00ffff',
|
||||
}
|
||||
|
||||
/** 系统颜色(System.Drawing.SystemColors),按 Windows 10 默认主题取值 */
|
||||
const SYSTEM_COLORS: Record<string, string> = {
|
||||
Control: '#f0f0f0',
|
||||
ControlText: '#000000',
|
||||
Window: '#ffffff',
|
||||
WindowText: '#000000',
|
||||
ControlDark: '#a0a0a0',
|
||||
ControlLight: '#e3e3e3',
|
||||
Highlight: '#0078d7',
|
||||
HighlightText: '#ffffff',
|
||||
GrayText: '#6d6d6d',
|
||||
InactiveCaption: '#bfcddb',
|
||||
}
|
||||
|
||||
function namedColor(name: string): Value {
|
||||
const css = NAMED_COLORS[name] ?? SYSTEM_COLORS[name] ?? cssNamed(name)
|
||||
if (css === 'transparent') return { kind: 'color', css, name, r: 255, g: 255, b: 255, a: 0 }
|
||||
const [r, g, b] = hexToRgb(css)
|
||||
return { kind: 'color', css, name, r, g, b, a: 255 }
|
||||
}
|
||||
|
||||
/** 不在列表里的颜色名(如 Color.Gold)交给浏览器:CSS 颜色名和 .NET 的颜色名基本一致 */
|
||||
function cssNamed(name: string) {
|
||||
return name.toLowerCase()
|
||||
}
|
||||
|
||||
export function rgb(r: number, g: number, b: number, a = 255): Value {
|
||||
const css = a === 255 ? `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}` : `rgba(${r},${g},${b},${(a / 255).toFixed(3)})`
|
||||
return { kind: 'color', css, r, g, b, a }
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = Number.parseInt(hex.replace('#', ''), 16)
|
||||
return Number.isNaN(n) ? [0, 0, 0] : [(n >> 16) & 255, (n >> 8) & 255, n & 255]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
// 代码编辑器:每个文件一个 Monaco 模型(各自保留撤销记录),切换标签页时换模型。
|
||||
// 编译错误以波浪线标在代码里。F5 / Shift+F5 由页面统一处理(见 App.vue),这里不再单独绑定。
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { Diagnostic, SourceFile } from '@iot/shared'
|
||||
import { monaco } from './monaco'
|
||||
|
||||
const props = defineProps<{
|
||||
files: SourceFile[]
|
||||
activeFile?: string
|
||||
diagnostics: Diagnostic[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [path: string, content: string]
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor>()
|
||||
const models = new Map<string, monaco.editor.ITextModel>()
|
||||
|
||||
function modelFor(file: SourceFile) {
|
||||
let model = models.get(file.path)
|
||||
if (!model) {
|
||||
model = monaco.editor.createModel(file.content, 'csharp', monaco.Uri.file(file.path))
|
||||
model.onDidChangeContent(() => emit('change', file.path, model!.getValue()))
|
||||
models.set(file.path, model)
|
||||
} else if (model.getValue() !== file.content) {
|
||||
// 外部换了内容(切换示例、恢复原始代码)
|
||||
model.setValue(file.content)
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
/** 文件列表变化时:为新文件建模型,删掉已不存在的文件的模型 */
|
||||
function syncModels() {
|
||||
const paths = new Set(props.files.map((f) => f.path))
|
||||
for (const [path, model] of models) {
|
||||
if (!paths.has(path)) {
|
||||
model.dispose()
|
||||
models.delete(path)
|
||||
}
|
||||
}
|
||||
for (const file of props.files) modelFor(file)
|
||||
}
|
||||
|
||||
function showActive() {
|
||||
const file = props.files.find((f) => f.path === props.activeFile)
|
||||
if (!editor.value || !file) return
|
||||
const model = modelFor(file)
|
||||
if (editor.value.getModel() !== model) editor.value.setModel(model)
|
||||
}
|
||||
|
||||
function applyMarkers() {
|
||||
for (const [path, model] of models) {
|
||||
const markers = props.diagnostics
|
||||
.filter((d) => d.file === path)
|
||||
.map((d) => ({
|
||||
severity: d.severity === 'error' ? monaco.MarkerSeverity.Error : monaco.MarkerSeverity.Warning,
|
||||
message: d.hint ? `${d.message}\n提示:${d.hint}` : d.message,
|
||||
code: d.code,
|
||||
startLineNumber: d.line,
|
||||
startColumn: d.column,
|
||||
endLineNumber: d.line,
|
||||
// 编译器只给出起点;标到该行的下一个词结尾,至少一个字符
|
||||
endColumn: (model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1),
|
||||
}))
|
||||
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳到某个文件的某一行(点击下方的错误信息时调用) */
|
||||
function reveal(path: string, line: number, column: number) {
|
||||
const file = props.files.find((f) => f.path === path)
|
||||
if (!editor.value || !file) return
|
||||
editor.value.setModel(modelFor(file))
|
||||
editor.value.setPosition({ lineNumber: line, column })
|
||||
editor.value.revealLineInCenter(line)
|
||||
editor.value.focus()
|
||||
}
|
||||
|
||||
defineExpose({ reveal })
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = monaco.editor.create(container.value!, {
|
||||
language: 'csharp',
|
||||
theme: 'vs',
|
||||
automaticLayout: true,
|
||||
fontFamily: '"JetBrains Mono", "Cascadia Code", Consolas, "Noto Sans Mono CJK SC", monospace',
|
||||
fontSize: 14,
|
||||
// 关闭连字:避免 >= 显示成 ≥,学生照着输入时出错
|
||||
fontLigatures: false,
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: 'selection',
|
||||
unicodeHighlight: { ambiguousCharacters: false },
|
||||
})
|
||||
syncModels()
|
||||
showActive()
|
||||
applyMarkers()
|
||||
})
|
||||
|
||||
watch(() => props.files, () => {
|
||||
syncModels()
|
||||
showActive()
|
||||
applyMarkers()
|
||||
})
|
||||
watch(() => props.activeFile, showActive)
|
||||
watch(() => props.diagnostics, applyMarkers)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.dispose()
|
||||
for (const model of models.values()) model.dispose()
|
||||
models.clear()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-editor">
|
||||
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
|
||||
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
|
||||
</div>
|
||||
<div ref="container" class="monaco-host" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.designer-note {
|
||||
padding: 4px 8px;
|
||||
background: #fffbe6;
|
||||
border-bottom: 1px solid #f5e6a8;
|
||||
color: #8a6d00;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.monaco-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
// 学生改过的代码保存在浏览器本地(localStorage),刷新页面不丢失。
|
||||
// 这只是方便功能:隐私窗口、清除网站数据等情况下可能读写失败,失败时当作没有草稿。
|
||||
// 作业的正式保存以后放到服务器上。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
|
||||
const PREFIX = 'iot-sim:draft:'
|
||||
|
||||
export function loadDraft(sampleId: string): SourceFile[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + sampleId)
|
||||
return raw ? (JSON.parse(raw) as SourceFile[]) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDraft(sampleId: string, files: SourceFile[]) {
|
||||
try {
|
||||
localStorage.setItem(PREFIX + sampleId, JSON.stringify(files))
|
||||
} catch {
|
||||
// 存储不可用或已满:忽略
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDraft(sampleId: string) {
|
||||
try {
|
||||
localStorage.removeItem(PREFIX + sampleId)
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 按需加载 Monaco(0.57 的模块布局):编辑器 API + 全部编辑功能(查找、折叠、括号匹配等)+ C# 语法高亮,
|
||||
// 不加载其他几十种语言和 JS/TS/CSS 语言服务,体积小很多。
|
||||
import * as monaco from 'monaco-editor/editor/editor.api'
|
||||
import 'monaco-editor/features/register.all'
|
||||
import 'monaco-editor/languages/definitions/csharp/register'
|
||||
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker: () => new EditorWorker(),
|
||||
}
|
||||
|
||||
export { monaco }
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import AccessGate from './AccessGate.vue'
|
||||
import App from './App.vue'
|
||||
import './winforms/winforms.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
// 服务器设置了访问口令而浏览器还没输过时,先显示口令页
|
||||
const auth = await fetch('/api/auth')
|
||||
.then((response) => response.json() as Promise<{ ok: boolean }>)
|
||||
.catch(() => ({ ok: true }))
|
||||
|
||||
createApp(auth.ok ? App : AccessGate).mount('#app')
|
||||
@@ -20,6 +20,27 @@ const DIGITAL_INPUTS: { key: DigitalKey; label: string }[] = [
|
||||
|
||||
const showMore = ref(false)
|
||||
|
||||
// 收起后只留一行状态,给窗体显示区腾地方;收起与否记在浏览器本地
|
||||
const COLLAPSED_KEY = 'iot-sim:board-collapsed'
|
||||
const collapsed = ref(readCollapsed())
|
||||
|
||||
function readCollapsed() {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSED_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setCollapsed(value: boolean) {
|
||||
collapsed.value = value
|
||||
try {
|
||||
localStorage.setItem(COLLAPSED_KEY, value ? '1' : '0')
|
||||
} catch {
|
||||
// 浏览器禁用本地存储时只是记不住,不影响使用
|
||||
}
|
||||
}
|
||||
|
||||
// 拖动滑块时先显示本地值,避免服务器回传前滑块跳动;发送做节流
|
||||
const dragging = ref<Partial<Record<AnalogKey, number>>>({})
|
||||
let pending: Partial<Record<AnalogKey, number>> = {}
|
||||
@@ -59,7 +80,37 @@ const ledText = computed(() => props.board?.ledText ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board" :class="{ disconnected: !board }">
|
||||
<!-- 收起时:一行状态,传感器开关仍然可以点 -->
|
||||
<div v-if="collapsed" class="board collapsed" :class="{ disconnected: !board }">
|
||||
<span class="board-name">虚拟实训台</span>
|
||||
<span class="io out warn" :class="{ lit: alarmOn }"><i />警示灯</span>
|
||||
<span class="io out" :class="{ lit: streetLampOn }"><i />照明灯 1</span>
|
||||
<span class="summary-readings">
|
||||
<span v-for="key in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
|
||||
{{ ANALOG_INFO[key].label }} <b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
v-for="input in DIGITAL_INPUTS"
|
||||
:key="input.key"
|
||||
class="io chip"
|
||||
:class="{ lit: digital[input.key] }"
|
||||
:disabled="!board"
|
||||
:title="`点击切换${input.label}`"
|
||||
@click="toggle(input.key)"
|
||||
>
|
||||
<i />{{ input.label }}
|
||||
</button>
|
||||
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
|
||||
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="board" :class="{ disconnected: !board }">
|
||||
<div class="board-header">
|
||||
<span class="board-name">虚拟实训台</span>
|
||||
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
|
||||
</div>
|
||||
|
||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||
<div class="ceiling">
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
@@ -218,6 +269,62 @@ const ledText = computed(() => props.board?.ledText ?? '')
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
/* 布局按实训台自身宽度调整(打开设计器时右栏会变窄) */
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.board-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: -2px 0 -4px;
|
||||
}
|
||||
|
||||
.board-name {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.board.collapsed {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.summary-readings {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-readings b {
|
||||
color: #9fd3ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 1px 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.summary-led {
|
||||
min-width: 0;
|
||||
max-width: 16em;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.expand {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.board.disconnected {
|
||||
@@ -319,14 +426,33 @@ button {
|
||||
animation: led-scroll 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 传感器 */
|
||||
/* 传感器:中等宽度时 4 个开关卡片一行、两个滑块卡片一行;够宽时按实训台顺序排成一行;更窄时每行 2 格 */
|
||||
.sensors {
|
||||
display: grid;
|
||||
/* 照明灯、人体红外、温湿度(2 格)、光照(2 格)、烟雾、火焰排成一行 */
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sensors .wide {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
@container (min-width: 1100px) {
|
||||
.sensors {
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sensors .wide {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.sensors {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -424,11 +550,15 @@ button {
|
||||
|
||||
.slider {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr 4.5em;
|
||||
grid-template-columns: 4.5em minmax(60px, 1fr) 4.5em;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slider span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slider input {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
@@ -446,6 +576,21 @@ button {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 窄时串口服务器单独占一行,四个串口横排 */
|
||||
@container (max-width: 760px) {
|
||||
.modules {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.ports {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ports .port-list {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -505,6 +650,11 @@ button {
|
||||
box-shadow: 0 0 6px #51cf66;
|
||||
}
|
||||
|
||||
.io.warn.lit i {
|
||||
background: #ff3b30;
|
||||
box-shadow: 0 0 6px #ff3b30;
|
||||
}
|
||||
|
||||
.port-list {
|
||||
flex-direction: column;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -120,12 +120,12 @@ function selectItem(index: number) {
|
||||
<span class="wf-check-text">{{ displayText(p.Text) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 教材控件库:灯泡、报警灯(图片沿用教材控件库) -->
|
||||
<!-- 教材控件库:灯泡、报警灯(自绘图片,尺寸与教材控件一致) -->
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Lamp'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/LampOn.png' : '/devices/LampOff.png'" alt="" draggable="false" />
|
||||
<img :src="p.IsOn ? '/devices/lamp-on.svg' : '/devices/lamp-off.svg'" alt="" draggable="false" />
|
||||
</div>
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Alarm'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/AlarmOn.jpg' : '/devices/AlarmOff.jpg'" alt="" draggable="false" />
|
||||
<img :src="p.IsOn ? '/devices/alarm-on.svg' : '/devices/alarm-off.svg'" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<!-- GroupBox -->
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
<script lang="ts">
|
||||
let topZ = 1
|
||||
const nextZ = () => ++topZ
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 一个 WinForms 窗体:标题栏 + 客户区。窗体可以拖动标题栏移动。
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -19,6 +24,12 @@ 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 })
|
||||
|
||||
// 点哪个窗口,哪个窗口到最前面(所有窗体共用一个递增的层级)
|
||||
const zIndex = ref(nextZ())
|
||||
function activate() {
|
||||
zIndex.value = nextZ()
|
||||
}
|
||||
let dragStart: { x: number; y: number; px: number; py: number } | undefined
|
||||
|
||||
function startDrag(event: PointerEvent) {
|
||||
@@ -41,7 +52,8 @@ function close() {
|
||||
v-show="p.Visible !== false"
|
||||
class="wf-window"
|
||||
:class="{ 'wf-window-modal': modal }"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px`, zIndex: modal ? undefined : zIndex }"
|
||||
@pointerdown.capture="activate"
|
||||
>
|
||||
<div v-if="!borderless" class="wf-titlebar" @pointerdown="startDrag" @pointermove="drag" @pointerup="dragStart = undefined">
|
||||
<span class="wf-title">{{ displayText(p.Text) }}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.wf-window-modal {
|
||||
z-index: 10;
|
||||
z-index: 100000;
|
||||
}
|
||||
|
||||
.wf-titlebar {
|
||||
@@ -244,7 +244,8 @@
|
||||
.wf-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
/* 在所有窗体(包括模态窗体)之上 */
|
||||
z-index: 200000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2023",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["vite/client"]
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../shared"]
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts",
|
||||
"../shared"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||