用 bun fmt 格式化全部代码和文档
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 14:02:33 +08:00
1 parent d4b8bf9241
commit de277d3e9d
102 files changed
+1971 -1059

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# bun fmt 不格式化的文件
# 书上的摘录,保持原样
docs/教材摘要/
+14 -14
View File
@@ -5,15 +5,15 @@
## 目录
| 目录 | 内容 |
|---|---|
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
| 目录 | 内容 |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
| `runtime/` | .NET 10:仿真 WinForms(`Sim.WinForms`)、教材设备类库(`Sim.Devices`)、仿冒 SqlClient(`Sim.Data`,T-SQL 转写成 SQLite)、运行器(`Sim.Runner`) |
| `samples/` | 示例工程(按教材任务整理) |
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
| `samples/` | 示例工程(按教材任务整理) |
| `docs/` | 设计方案(`设计方案.md`)、教材逐任务摘要(`教材摘要/`) |
| `deploy/` | 部署:Caddyfile、部署脚本和说明(`deploy/README.md`) |
## 本地开发
@@ -55,13 +55,13 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
本机实测(16 核、28 GB):
| 指标 | 结果 |
|---|---|
| 编译(编译服务预热后) | 约 10–300 ms |
| 启动到窗体显示 | 约 100 ms |
| 单次操作往返 | 约 15 ms |
| 指标 | 结果 |
| ------------------------ | ----------------------------------------------------------- |
| 编译(编译服务预热后) | 约 10–300 ms |
| 启动到窗体显示 | 约 100 ms |
| 单次操作往返 | 约 15 ms |
| 100 个程序同时运行:内存 | 合计约 1.1 GB(PSS,平均每个 11 MB);另加编译服务约 150 MB |
| 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms |
| 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms |
## 第 1 阶段进行中
+5
View File
@@ -0,0 +1,5 @@
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
[fmt]
printWidth = 160
semi = false
singleQuote = true
+3 -3
View File
@@ -7,10 +7,10 @@ services:
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
build: .
user: root
command: ["sh", "-c", "chown -R \"$$APP_UID\" /data"]
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
volumes:
- data:/data
restart: "no"
restart: 'no'
networks: [default]
app:
@@ -51,7 +51,7 @@ services:
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
mem_limit: ${APP_MEM_LIMIT:-4g}
pids_limit: 4096
expose: ["3000"]
expose: ['3000']
networks: [default]
caddy:
+20 -20
View File
@@ -96,15 +96,15 @@
在服务器的 `~/IOT_SIM` 目录下:
| 做什么 | 命令 |
|---|---|
| 做什么 | 命令 |
| ------------ | --------------------------------------------------------------------------------------- |
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
| 看资源占用 | `docker stats` |
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
| 看日志 | `docker compose logs -f app` |
| 看资源占用 | `docker stats` |
| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" |
| 重启 / 停止 | `docker compose restart` / `docker compose down` |
## 备份与恢复
@@ -124,15 +124,15 @@ deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部
写在 `.env` 里,改完 `docker compose up -d` 生效。
| 变量 | 默认 | 说明 |
|---|---|---|
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
| `TEACHER_USERNAME` | `admin` | 老师账号 |
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
| `AI_MODEL` | `deepseek-flash` | AI 评阅用的模型(需要支持图片输入) |
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
| 变量 | 默认 | 说明 |
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
| `TEACHER_USERNAME` | `admin` | 老师账号 |
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
| `AI_MODEL` | `deepseek-flash` | AI 评阅用的模型(需要支持图片输入) |
| `APP_MEM_LIMIT` | `4g` | 整个平台的内存上限 |
| `SIM_MAX_RUNS` | `150` | 全站同时运行的学生程序上限,超过时提示"服务器繁忙" |
| `SIM_HEAP_LIMIT_MB` | `128` | 每个学生程序的内存上限 |
| `SIM_CPU_LIMIT_SECONDS` | `120` | 每次运行累计 CPU 时间上限(防死循环) |
| `SIM_MAX_RUN_MINUTES` | `60` | 每次运行最长时间 |
| `SIM_GRADE_CONCURRENCY` | `16` | 全站同时跑几个评分检查项(提交评分和"自己检查"共用)。2 核实测:45 人同时自测项目 8 任务 3,最后一人约 1.5 分钟出结果;调到 24 以上 CPU 不够,定时器来不及触发,正确的程序会被误判 |
+90 -90
View File
@@ -14,27 +14,27 @@
## 已确定的关键决策
| 事项 | 决定 | 理由 |
| --- | --- | --- |
| 学生编程语言 | C# | 课程要求 |
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
| AI 评阅 | DeepSeek Flash 给老师评分项(界面、注释、命名)出建议分和评语,加上自动评分就是建议的最终成绩;老师采纳(或修改后保存)才生效,学生在老师确认前看不到 | 减轻老师批改负担,成绩仍由老师负责;服务器在国内,用国内可合规使用的模型 |
| 设备写法 | 兼容教材类库(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 |
| 开发人力 | 一人 | 范围和路线图按此控制 |
| 事项 | 决定 | 理由 |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| 学生编程语言 | C# | 课程要求 |
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
| 实验模板与参考答案 | 以书为准,修正书中错误;配套工程只作参考 | 学生手里拿的是书 |
| 评测分工 | 功能项自动评;命名、缩进自动提示;注释和界面美观由老师评;分值由老师设定 | 书上评价表只有指标,没有分值 |
| 账号 | 老师只有一个账号(配置文件设定);学生账号由老师上传 CSV 名单导入,密码由老师发放(可留空自动生成 6 位数字),学生不能改,老师能查看和重置 | 中职学生容易忘密码,老师能直接告诉学生 |
| 实验起点 | 布置作业时老师选:空白工程(从界面做起)或界面已做好(只写代码) | 不同课时侧重点不同 |
| 评分反馈 | 提交后立即显示每个功能项是否通过和原因,截止前可以改了再交 | 边做边学 |
| AI 评阅 | DeepSeek Flash 给老师评分项(界面、注释、命名)出建议分和评语,加上自动评分就是建议的最终成绩;老师采纳(或修改后保存)才生效,学生在老师确认前看不到 | 减轻老师批改负担,成绩仍由老师负责;服务器在国内,用国内可合规使用的模型 |
| 设备写法 | 兼容教材类库(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 |
| 开发人力 | 一人 | 范围和路线图按此控制 |
## 学生使用体验
@@ -63,14 +63,14 @@
平台提供与教材同名、同签名的仿真类库(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 尚未实现 |
| 对象 | 主要成员 | 平台仿真行为 |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `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 屏发送信息):
@@ -150,37 +150,37 @@ IOT/
网站主体用 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`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS |
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
| 工程 | 仓库 | Bun workspaces 单仓库 |
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
| 层 | 用途 | 选择 |
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 前端 | 框架 | 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`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM |
| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` |
| 后端 | 参数校验 | 计划:Zod |
| C# 运行时 | 平台 | .NET 10 LTS |
| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) |
| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 |
| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 |
| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 |
| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 |
| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp |
| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 |
| 工程 | 仓库 | Bun workspaces 单仓库 |
| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright |
| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB |
| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 |
| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 |
| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 |
| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 |
| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 |
## 安全与服务器资源
@@ -206,16 +206,16 @@ IOT/
实验直接对应教材的 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 控件、图片保存 | 已完成 |
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
| 项目 | 教材内容 | 平台需要支持 | 状态 |
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
| 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 控件、图片保存 | 已完成 |
| 5 灾情监测 | 火焰(Timer)、烟雾(Thread)、灾情记录与推送 | Thread(含 Suspend / Resume / Abort)、跨线程改界面、ListBox、FormClosing | 已完成 |
| 6 数据存储 | 建库建表、采集入库与增删改、按时间段查询 | SQL 窗口(代替 SSMS)、SqlClient 仿冒(SQLite)、DataGridView、DateTimePicker | 已完成 |
| 7 情景控制 | 智慧教室、寝室、会议室三种情景联动 | TrackBar、资源图片、PictureBox 当按钮、mp3 播放、虚拟时钟 | 已完成 |
| 8 管理平台 | 主界面、校园环境(自动控制、7 天温度图表)、校园安防(抓拍入库、历史图片) | Panel Dock、无边框窗体、透明背景、Chart、ShowDialog、图片存数据库 | 已完成 |
每个实验以数据形式存放(目前在 `samples/`,以后迁到 `content/`),由以下部分组成:
@@ -249,13 +249,13 @@ flowchart TB
**第 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 |
| 指标 | 结果 |
| ---------------------- | ---------------------------------------------------- |
| 编译(编译服务预热后) | 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 核环境下的复测见上文"资源估算"。
@@ -336,19 +336,19 @@ flowchart TB
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
| 风险 | 担心什么 | 应对 |
| --- | --- | --- |
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
| ICP 备案 | 办理需要数周 | 已备案 |
| 风险 | 担心什么 | 应对 |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
| T-SQL 转 SQLite | 教材用 SQL Server 写法(`TOP`、`CONVERT`、DataAdapter 回写、日期字符串) | 已实现:ScriptDom 解析后按白名单转写;连接串只按库名打开学生自己的库;日期统一存为标准格式;STRICT 表和 CHECK 约束让类型、长度错误和 SQL Server 一样报出 |
| 旧版线程 API | `Thread.Suspend` / `Resume` / `Abort` 在新版 .NET 上会报错 | 已实现:编译时改写成平台的协作式实现,被挂起或终止的线程在 Sleep、改控件、调设备类库时停下 |
| 图片处理 | System.Drawing 在 Linux 上不受支持 | 已实现:自己的 `Image` / `Bitmap` 只保存图片文件数据和尺寸,能显示、保存、读回,不能逐像素绘制 |
| 时间相关逻辑 | 项目 3 起就有按时刻开关灯的练习,部分定时器间隔长达 2 小时 | 已实现:实训台上的虚拟时钟可拨时间、1×/10×/60× 倍速,编译时把 DateTime.Now / Today / UtcNow 改写成虚拟时钟 |
| 事件延迟 | 浏览器和服务器之间来回传递事件,点击是否有卡顿感 | 已实测:单次约 15 ms,100 个程序同时操作中位数约 60 ms |
| 并发资源 | 100 个程序同时运行时的内存和 CPU | 已压测:100 个程序合计约 1.1 GB;2 核下 100 人同时启动约 8 秒;CPU 时间上限防死循环,运行超时回收 |
| 公网安全 | 恶意或无意的危险代码 | bubblewrap 沙箱 + 编译检查 + 账号登录(已完成) |
| ICP 备案 | 办理需要数周 | 已备案 |
**待确认**
+5 -1
View File
@@ -1,7 +1,11 @@
{
"name": "iot-sim",
"private": true,
"workspaces": ["shared", "server", "web"],
"workspaces": [
"shared",
"server",
"web"
],
"scripts": {
"dev:server": "bun run --cwd server dev",
"dev:web": "bun run --cwd web dev",
+4 -1
View File
@@ -30,7 +30,10 @@ await rm(framesDir, { recursive: true, force: true })
await mkdir(framesDir, { recursive: true })
await mkdir(work, { recursive: true })
try {
for (const [flag, svg] of [[0, scene], [1, intruder]] as const) {
for (const [flag, svg] of [
[0, scene],
[1, intruder],
] as const) {
const svgPath = join(work, `scene-${flag}.svg`)
const pngPath = join(work, `scene-${flag}.png`)
await Bun.write(svgPath, svg)
+193 -25
View File
@@ -1,27 +1,195 @@
[
{"name": "switch_on", "file": "switch_on.png", "label": "开关:开", "kind": "image", "key": "6e660e1ff6fc2ff3167919bc386c300112bbce72", "hidpi": "switch_on.2x.png"},
{"name": "switch_off", "file": "switch_off.png", "label": "开关:关", "kind": "image", "key": "5d748b614084e878d6189d22f27813477cb8f4d9", "hidpi": "switch_off.2x.png"},
{"name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio"},
{"name": "ico_title", "file": "ico_title.png", "label": "标题:智慧校园", "kind": "image", "key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f", "hidpi": "ico_title.2x.png"},
{"name": "bg_home", "file": "bg_home.png", "label": "主界面背景:校园", "kind": "image", "key": "e56bcc13f7128dec305b6012b255d63fb0f94450", "hidpi": "bg_home.2x.png"},
{"name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image", "key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d", "hidpi": "icon_02.2x.png"},
{"name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
{"name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image", "key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3", "hidpi": "icon_04.2x.png"},
{"name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image", "key": "05065abdb4538906bd7c86e940ab5fb02d51469d", "hidpi": "day_01.2x.png"},
{"name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image", "key": "470d523e90e188fe5c423d8cffeae6993d5528ff", "hidpi": "day_02.2x.png"},
{"name": "bg_from1", "file": "bg_from1.png", "label": "面板背景(宽)", "kind": "image", "key": "7a8c0a16fcab37b28a66e041024b25341cc69462", "hidpi": "bg_from1.2x.png"},
{"name": "bg_from2", "file": "bg_from2.png", "label": "面板背景(窄)", "kind": "image", "key": "52497df301d2b7e4eac0c297ce2316ef24863c47", "hidpi": "bg_from2.2x.png"},
{"name": "icon_monitor", "file": "icon_monitor.png", "label": "监控:无画面", "kind": "image", "key": "23e6f1efc0f382c17d8a091b76531a3b5879349d", "hidpi": "icon_monitor.2x.png"},
{"name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image", "key": "396443a1d8c141942c0f447e0c69786cf6ad3350", "hidpi": "panel_video_ctrl.2x.png"},
{"name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
{"name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image", "key": "51e1b9926d2bd611e3fda74a01231b46a930c460", "hidpi": "btn_arrow_up_press.2x.png"},
{"name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image", "key": "caf46f81c15aaca788462ebc47e58df299f097d9", "hidpi": "btn_arrow_down_normal.2x.png"},
{"name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image", "key": "88c1bea8d6fad99b14658d8265a88b04bc595006", "hidpi": "btn_arrow_down_press.2x.png"},
{"name": "btn_arrow_left_normal", "file": "btn_arrow_left_normal.png", "label": "方向键:左", "kind": "image", "key": "a38beb2373154d0474ad1715035aec5e2adf8c3f", "hidpi": "btn_arrow_left_normal.2x.png"},
{"name": "btn_arrow_left_press", "file": "btn_arrow_left_press.png", "label": "方向键:左(按下)", "kind": "image", "key": "47bd7a8ffc077130a8ceca627383a4647729c638", "hidpi": "btn_arrow_left_press.2x.png"},
{"name": "btn_arrow_right_normal", "file": "btn_arrow_right_normal.png", "label": "方向键:右", "kind": "image", "key": "e69e459f54d67e2c52128015bd6cfcf0e800a893", "hidpi": "btn_arrow_right_normal.2x.png"},
{"name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image", "key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170", "hidpi": "btn_arrow_right_press.2x.png"},
{"name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
{"name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image", "key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9", "hidpi": "arrow_right_normal.2x.png"},
{"name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.2x.png"}
{
"name": "switch_on",
"file": "switch_on.png",
"label": "开关:开",
"kind": "image",
"key": "6e660e1ff6fc2ff3167919bc386c300112bbce72",
"hidpi": "switch_on.2x.png"
},
{
"name": "switch_off",
"file": "switch_off.png",
"label": "开关:关",
"kind": "image",
"key": "5d748b614084e878d6189d22f27813477cb8f4d9",
"hidpi": "switch_off.2x.png"
},
{ "name": "ring", "file": "ring.mp3", "label": "铃声", "kind": "audio" },
{
"name": "ico_title",
"file": "ico_title.png",
"label": "标题:智慧校园",
"kind": "image",
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
"hidpi": "ico_title.2x.png"
},
{
"name": "bg_home",
"file": "bg_home.png",
"label": "主界面背景:校园",
"kind": "image",
"key": "e56bcc13f7128dec305b6012b255d63fb0f94450",
"hidpi": "bg_home.2x.png"
},
{
"name": "icon_02",
"file": "icon_02.png",
"label": "图标:校园环境",
"kind": "image",
"key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d",
"hidpi": "icon_02.2x.png"
},
{
"name": "icon_03",
"file": "icon_03.png",
"label": "图标:校园安防",
"kind": "image",
"key": "d085064a505544968278a85233b7d9ac23661169",
"hidpi": "icon_03.2x.png"
},
{
"name": "icon_04",
"file": "icon_04.png",
"label": "图标:退出",
"kind": "image",
"key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3",
"hidpi": "icon_04.2x.png"
},
{
"name": "day_01",
"file": "day_01.png",
"label": "路灯场景:亮灯",
"kind": "image",
"key": "05065abdb4538906bd7c86e940ab5fb02d51469d",
"hidpi": "day_01.2x.png"
},
{
"name": "day_02",
"file": "day_02.png",
"label": "路灯场景:灭灯",
"kind": "image",
"key": "470d523e90e188fe5c423d8cffeae6993d5528ff",
"hidpi": "day_02.2x.png"
},
{
"name": "bg_from1",
"file": "bg_from1.png",
"label": "面板背景(宽)",
"kind": "image",
"key": "7a8c0a16fcab37b28a66e041024b25341cc69462",
"hidpi": "bg_from1.2x.png"
},
{
"name": "bg_from2",
"file": "bg_from2.png",
"label": "面板背景(窄)",
"kind": "image",
"key": "52497df301d2b7e4eac0c297ce2316ef24863c47",
"hidpi": "bg_from2.2x.png"
},
{
"name": "icon_monitor",
"file": "icon_monitor.png",
"label": "监控:无画面",
"kind": "image",
"key": "23e6f1efc0f382c17d8a091b76531a3b5879349d",
"hidpi": "icon_monitor.2x.png"
},
{
"name": "panel_video_ctrl",
"file": "panel_video_ctrl.png",
"label": "监控:方向盘底座",
"kind": "image",
"key": "396443a1d8c141942c0f447e0c69786cf6ad3350",
"hidpi": "panel_video_ctrl.2x.png"
},
{
"name": "btn_arrow_up_normal",
"file": "btn_arrow_up_normal.png",
"label": "方向键:上",
"kind": "image",
"key": "3c8b75ad465c8a61d0279facc233a0c64b62205a",
"hidpi": "btn_arrow_up_normal.2x.png"
},
{
"name": "btn_arrow_up_press",
"file": "btn_arrow_up_press.png",
"label": "方向键:上(按下)",
"kind": "image",
"key": "51e1b9926d2bd611e3fda74a01231b46a930c460",
"hidpi": "btn_arrow_up_press.2x.png"
},
{
"name": "btn_arrow_down_normal",
"file": "btn_arrow_down_normal.png",
"label": "方向键:下",
"kind": "image",
"key": "caf46f81c15aaca788462ebc47e58df299f097d9",
"hidpi": "btn_arrow_down_normal.2x.png"
},
{
"name": "btn_arrow_down_press",
"file": "btn_arrow_down_press.png",
"label": "方向键:下(按下)",
"kind": "image",
"key": "88c1bea8d6fad99b14658d8265a88b04bc595006",
"hidpi": "btn_arrow_down_press.2x.png"
},
{
"name": "btn_arrow_left_normal",
"file": "btn_arrow_left_normal.png",
"label": "方向键:左",
"kind": "image",
"key": "a38beb2373154d0474ad1715035aec5e2adf8c3f",
"hidpi": "btn_arrow_left_normal.2x.png"
},
{
"name": "btn_arrow_left_press",
"file": "btn_arrow_left_press.png",
"label": "方向键:左(按下)",
"kind": "image",
"key": "47bd7a8ffc077130a8ceca627383a4647729c638",
"hidpi": "btn_arrow_left_press.2x.png"
},
{
"name": "btn_arrow_right_normal",
"file": "btn_arrow_right_normal.png",
"label": "方向键:右",
"kind": "image",
"key": "e69e459f54d67e2c52128015bd6cfcf0e800a893",
"hidpi": "btn_arrow_right_normal.2x.png"
},
{
"name": "btn_arrow_right_press",
"file": "btn_arrow_right_press.png",
"label": "方向键:右(按下)",
"kind": "image",
"key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170",
"hidpi": "btn_arrow_right_press.2x.png"
},
{
"name": "arrow_left_normal",
"file": "arrow_left_normal.png",
"label": "翻页:上一张",
"kind": "image",
"key": "5a9036955f791f0c41ba52afffb5ecc640d137f1",
"hidpi": "arrow_left_normal.2x.png"
},
{
"name": "arrow_right_normal",
"file": "arrow_right_normal.png",
"label": "翻页:下一张",
"kind": "image",
"key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9",
"hidpi": "arrow_right_normal.2x.png"
},
{
"name": "windowstate_closed_hover",
"file": "windowstate_closed_hover.png",
"label": "关闭按钮",
"kind": "image",
"key": "e361965dbd881dc21511208ce11f649517e8ddb6",
"hidpi": "windowstate_closed_hover.2x.png"
}
]
+5 -5
View File
@@ -4,11 +4,11 @@
steps:
- click: btnAdd
- text: { control: txtNewUser1, value: zhangsan }
- text: { control: txtPass1, value: "123" }
- text: { control: txtPass2, value: "123" }
- text: { control: txtPass1, value: '123' }
- text: { control: txtPass2, value: '123' }
- click: btnOk
- text: { control: txtUserName, value: zhangsan }
- text: { control: txtPassword, value: "123" }
- text: { control: txtPassword, value: '123' }
- click: btnOk
- expect: { messageBox: 登录成功 }
@@ -23,7 +23,7 @@
steps:
- click: btnAdd
- text: { control: txtNewUser1, value: lisi }
- text: { control: txtPass1, value: "123" }
- text: { control: txtPass2, value: "456" }
- text: { control: txtPass1, value: '123' }
- text: { control: txtPass2, value: '456' }
- click: btnOk
- expect: { messageBox: 用户名或密码错误 }
+15 -15
View File
@@ -10,14 +10,14 @@
登录窗体:
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Login` | 登录 |
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------- | -------------------------------------------------------------------- |
| 窗体 | `Login` | 登录 |
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
| 文本框 | `txtPassword` | 输入密码,PasswordChar 设为星号 *(前面放标签 `LblPwd`,文字"密码") |
| 按钮 | `btnOk` | 登录 |
| 按钮 | `btnCancel` | 取消 |
| 按钮 | `btnAdd` | 增加用户 |
| 按钮 | `btnOk` | 登录 |
| 按钮 | `btnCancel` | 取消 |
| 按钮 | `btnAdd` | 增加用户 |
用户名前面的标签 `LblUsername` 文字是"用户名称"。
@@ -25,14 +25,14 @@
增加用户窗体(点"+ 新建窗体",名字填 AddUser):
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `AddUser` | 增加用户 |
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
| 文本框 | `txtPass2` | 确认密码,PasswordChar 设为星号 * |
| 按钮 | `btnOk` | 确定 |
| 按钮 | `btnCancel` | 取消 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------- | --------------------------------- |
| 窗体 | `AddUser` | 增加用户 |
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
| 文本框 | `txtPass2` | 确认密码,PasswordChar 设为星号 * |
| 按钮 | `btnOk` | 确定 |
| 按钮 | `btnCancel` | 取消 |
> 书上的控件表和代码对不上:表里写 `txtPwd`、`BtnOk`,增加用户窗体写 `txtUserName`、`txtPwd`、`txtCheckPwd`,代码里用的是上面这些名字。请按上面的表起名,自动评分按这些名字操作。
+6 -6
View File
@@ -2,14 +2,14 @@
# 格式说明见 server/src/grading/checks.ts
- title: 计算器能正确计算(装箱与拆箱)
steps:
- text: { control: txtNum1, value: "7" }
- text: { control: txtNum2, value: "2" }
- select: { control: cmboxType, item: "+" }
- text: { control: txtNum1, value: '7' }
- text: { control: txtNum2, value: '2' }
- select: { control: cmboxType, item: '+' }
- click: btnCalculate
- expect: { control: txtResult, text: "9" }
- select: { control: cmboxType, item: "/" }
- expect: { control: txtResult, text: '9' }
- select: { control: cmboxType, item: '/' }
- click: btnCalculate
- expect: { control: txtResult, text: "3.5" }
- expect: { control: txtResult, text: '3.5' }
- title: 多态:不同国家的人说不同的语言
steps:
+12 -12
View File
@@ -6,18 +6,18 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | C#入门基础 |
| 分组框 | `groupBox1` | 简易计算器 |
| 文本框 | `txtNum1` | 第一个数 |
| 下拉框 | `cmboxType` | 运算符,Items 填 `+`、`-`、`*`、`/` 四项 |
| 文本框 | `txtNum2` | 第二个数 |
| 按钮 | `btnCalculate` | = |
| 文本框 | `txtResult` | 计算结果,ReadOnly 设为 True |
| 分组框 | `groupBox2` | 类的多态性 |
| 文本框 | `txtDispaly` | 输出说话内容,Multiline 设为 True |
| 按钮 | `btnDisplay` | 显示 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | -------------- | ---------------------------------------- |
| 窗体 | `Form1` | C#入门基础 |
| 分组框 | `groupBox1` | 简易计算器 |
| 文本框 | `txtNum1` | 第一个数 |
| 下拉框 | `cmboxType` | 运算符,Items 填 `+`、`-`、`*`、`/` 四项 |
| 文本框 | `txtNum2` | 第二个数 |
| 按钮 | `btnCalculate` | = |
| 文本框 | `txtResult` | 计算结果,ReadOnly 设为 True |
| 分组框 | `groupBox2` | 类的多态性 |
| 文本框 | `txtDispaly` | 输出说话内容,Multiline 设为 True |
| 按钮 | `btnDisplay` | 显示 |
> `txtDispaly` 是书上的拼法(不是 Display),代码和自动评分都用这个名字,照着写。书上的表格没写下拉框的 Items 和文本框的 Multiline,要自己在属性里设置。
+9 -9
View File
@@ -6,15 +6,15 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | LED显示屏 |
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
| 分组框 | `groupBox1` | LED控制 |
| 下拉框 | `com_Port` | 串口(前面放标签"串口") |
| 按钮 | `btn_Open` | 打开 |
| 文本框 | `txt_SendText` | 要推送的文字(前面放标签"推送LED信息") |
| 按钮 | `btn_Send` | 推送 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ---------- | -------------- | ----------------------------------------------- |
| 窗体 | `Form1` | LED显示屏 |
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
| 分组框 | `groupBox1` | LED控制 |
| 下拉框 | `com_Port` | 串口(前面放标签"串口") |
| 按钮 | `btn_Open` | 打开 |
| 文本框 | `txt_SendText` | 要推送的文字(前面放标签"推送LED信息") |
| 按钮 | `btn_Send` | 推送 |
> 书上的表格把 LED 控件写成 `LED`,代码里用的是小写的 `led`,请按代码起名。
+5 -5
View File
@@ -6,9 +6,9 @@
- click: btn_Open
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
- click: btn_get
- expect: { control: txt_temperature, text: "25" }
- expect: { control: txt_humidity, text: "60" }
- expect: { control: txt_light, text: "500" }
- expect: { control: txt_temperature, text: '25' }
- expect: { control: txt_humidity, text: '60' }
- expect: { control: txt_light, text: '500' }
- title: 环境变化后能获取到新的数值
steps:
@@ -16,5 +16,5 @@
- click: btn_Open
- board: { temperature: 31, light: 300 }
- click: btn_get
- expect: { control: txt_temperature, text: "31" }
- expect: { control: txt_light, text: "300" }
- expect: { control: txt_temperature, text: '31' }
- expect: { control: txt_light, text: '300' }
+9 -9
View File
@@ -6,16 +6,16 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `frm_analog` | 模拟量采集 |
| 分组框 | `grp_show` | 数据显示 |
| 标签 | `lbl_light`、`lbl_temperature`、`lbl_humidity` | 光照度、温度、湿度 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ---------------------------------------------- | -------------------------- |
| 窗体 | `frm_analog` | 模拟量采集 |
| 分组框 | `grp_show` | 数据显示 |
| 标签 | `lbl_light`、`lbl_temperature`、`lbl_humidity` | 光照度、温度、湿度 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `grp_config` | 配置 |
| 下拉框 | `com_port` | 串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `grp_config` | 配置 |
| 下拉框 | `com_port` | 串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_analog`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_analog_Load`。
+5 -5
View File
@@ -6,10 +6,10 @@
- select: { control: com_ledPort, item: COM5 }
- click: btn_open
- click: btn_get
- expect: { control: txt_temperature, text: "25" }
- expect: { led: "25", desc: 显示温度 25 }
- expect: { led: "60", desc: 显示湿度 60 }
- expect: { led: "500", desc: 显示光照度 500 }
- expect: { control: txt_temperature, text: '25' }
- expect: { led: '25', desc: 显示温度 25 }
- expect: { led: '60', desc: 显示湿度 60 }
- expect: { led: '500', desc: 显示光照度 500 }
- title: 能推送最新的环境信息
steps:
@@ -19,4 +19,4 @@
- click: btn_get
- board: { temperature: 31 }
- click: btn_get
- expect: { led: "31", desc: 显示最新的温度 31 }
- expect: { led: '31', desc: 显示最新的温度 31 }
+11 -11
View File
@@ -6,17 +6,17 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `frm_LED` | LED推送模拟量 |
| LED 显示屏 | `led` | 窗体上方的 LED 控件 |
| 分组框 | `grp_show` | 数据显示 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值(前面放标签) |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `grp_config` | 配置 |
| 下拉框 | `com_adamPort` | 模拟量串口(ADAM-4017+) |
| 下拉框 | `com_ledPort` | LED串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ---------- | ---------------------------------------------- | ---------------------------------------- |
| 窗体 | `frm_LED` | LED推送模拟量 |
| LED 显示屏 | `led` | 窗体上方的 LED 控件 |
| 分组框 | `grp_show` | 数据显示 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值(前面放标签) |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `grp_config` | 配置 |
| 下拉框 | `com_adamPort` | 模拟量串口(ADAM-4017+) |
| 下拉框 | `com_ledPort` | LED串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`(小写 o);表格里也没有列 LED 控件,代码里叫 `led`。请按代码起名,自动评分按这些名字找控件。
+10 -10
View File
@@ -6,16 +6,16 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | ZigBee路灯 |
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
| 单选按钮 | `rb_Open` | 开(开灯) |
| 单选按钮 | `rb_Close` | 关(关灯) |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `lbl_Lamp` | 路灯 |
| 下拉框 | `com_port` | ZigBee 协调器的串口 |
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| -------- | ------------ | ------------------------------------------------------ |
| 窗体 | `Form1` | ZigBee路灯 |
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
| 单选按钮 | `rb_Open` | 开(开灯) |
| 单选按钮 | `rb_Close` | 关(关灯) |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `lbl_Lamp` | 路灯 |
| 下拉框 | `com_port` | ZigBee 协调器的串口 |
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。
+12 -12
View File
@@ -6,18 +6,18 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 人体红外ZigBee联动 |
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 |
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
| 按钮 | `btn_lampClose` | 关(手动关灯) |
| 分组框 | `groupBox1` | 配置 |
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
| 复选框 | `ch_bodyInfrared` | 人体开关 |
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
| 定时器 | `timer1` | Interval 设为 5000(5 秒),Enabled 保持 False |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ----------------- | ---------------------------------------------- |
| 窗体 | `Form1` | 人体红外ZigBee联动 |
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 |
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
| 按钮 | `btn_lampClose` | 关(手动关灯) |
| 分组框 | `groupBox1` | 配置 |
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
| 复选框 | `ch_bodyInfrared` | 人体开关 |
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
| 定时器 | `timer1` | Interval 设为 5000(5 秒),Enabled 保持 False |
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。
+12 -12
View File
@@ -6,18 +6,18 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 红外对射 |
| 标签 | `lbl_port` | 环境串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 入侵 |
| 文本框 | `txt_even` | 显示"发现入侵"或"正常" |
| 定时器 | `timer1` | Interval 设为 2000(2 秒) |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------ | -------------------------- |
| 窗体 | `Form1` | 红外对射 |
| 标签 | `lbl_port` | 环境串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 入侵 |
| 文本框 | `txt_even` | 显示"发现入侵"或"正常" |
| 定时器 | `timer1` | Interval 设为 2000(2 秒) |
> 书上的表格里没有分组框,看书上的界面图,"时间""入侵"两行放在"数据显示"分组框里。
+11 -11
View File
@@ -6,18 +6,18 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 网络摄像机 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------- | -------------------------------------------------------------- |
| 窗体 | `Form1` | 网络摄像机 |
| 图片框 | `pictureBox1` | 显示画面;背景色设为黑色,显示方式选"拉伸填满"(StretchImage) |
| 标签 | `label1` | 摄像机IP |
| 文本框 | `txt_ip` | 输入摄像机的 IP |
| 按钮 | `btn_open` | 开(打开后变成"关") |
| 按钮 | `btn_up` | ↑(向上转) |
| 按钮 | `btn_down` | ↓(向下转) |
| 按钮 | `btn_left` | ←(向左转) |
| 按钮 | `btn_right` | →(向右转) |
| 按钮 | `btn_camera` | 拍照 |
| 标签 | `label1` | 摄像机IP |
| 文本框 | `txt_ip` | 输入摄像机的 IP |
| 按钮 | `btn_open` | 开(打开后变成"关") |
| 按钮 | `btn_up` | ↑(向上转) |
| 按钮 | `btn_down` | ↓(向下转) |
| 按钮 | `btn_left` | ←(向左转) |
| 按钮 | `btn_right` | →(向右转) |
| 按钮 | `btn_camera` | 拍照 |
## 实现步骤
+10 -10
View File
@@ -6,16 +6,16 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 报警灯 |
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
| 按钮 | `btn_alarmClose` | 关(关闭报警灯) |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `label1` | 报警灯 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ---------------- | ---------------------------------------------------------- |
| 窗体 | `Form1` | 报警灯 |
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
| 按钮 | `btn_alarmClose` | 关(关闭报警灯) |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `label1` | 报警灯 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
> 书上的表格把报警灯控件的名称写成 `Alarm`,那是控件的类型名;代码里用的名字是 `alarm1`,请按代码写,否则自动评分找不到它。
+12 -12
View File
@@ -6,18 +6,18 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 火焰 |
| 标签 | `lbl_port` | 串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 事件 |
| 文本框 | `txt_even` | 显示有没有火情 |
| 定时器 | `timer1` | Interval 设为 2000(2 秒),Enabled 保持 False |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------ | ---------------------------------------------- |
| 窗体 | `Form1` | 火焰 |
| 标签 | `lbl_port` | 串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 事件 |
| 文本框 | `txt_even` | 显示有没有火情 |
| 定时器 | `timer1` | Interval 设为 2000(2 秒),Enabled 保持 False |
> 书上的表格里没有分组框,照图加一个 `groupBox1`(数据显示),把时间、事件两行放进去。
+11 -11
View File
@@ -6,17 +6,17 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 烟雾 |
| 标签 | `lbl_port` | 串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 事件 |
| 文本框 | `txt_even` | 显示有没有烟情 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------ | ------------------------ |
| 窗体 | `Form1` | 烟雾 |
| 标签 | `lbl_port` | 串口 |
| 下拉框 | `com_port` | ADAM-4150 的串口 |
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 标签 | `lbl_time` | 时间 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_even` | 事件 |
| 文本框 | `txt_even` | 显示有没有烟情 |
> 界面和任务 1 一样,但**没有定时器**。书上的表格里没有分组框,照图加一个 `groupBox1`(数据显示)。
+19 -19
View File
@@ -8,28 +8,28 @@
主窗体 Form1:
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 火焰烟雾 |
| LED 显示屏 | `led1` | 窗体顶部,显示推送的灾情 |
| 分组框 | `groupBox2` | 数据采集 |
| 标签 | `lbl_time`、`lbl_fire`、`lbl_smoke` | 时间、火焰、烟雾 |
| 文本框 | `txt_time`、`txt_fire`、`txt_smoke` | 显示时间、火焰状态、烟雾状态 |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `lbl_Ledport` | LED |
| 下拉框 | `com_ledPort` | LED 屏的串口 |
| 标签 | `lbl_4150port` | 环境采集 |
| 下拉框 | `com_environmentPort` | ADAM-4150 的串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
| 按钮 | `OpenRecord` | 历史灾情信息 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ---------- | ----------------------------------- | ---------------------------- |
| 窗体 | `Form1` | 火焰烟雾 |
| LED 显示屏 | `led1` | 窗体顶部,显示推送的灾情 |
| 分组框 | `groupBox2` | 数据采集 |
| 标签 | `lbl_time`、`lbl_fire`、`lbl_smoke` | 时间、火焰、烟雾 |
| 文本框 | `txt_time`、`txt_fire`、`txt_smoke` | 显示时间、火焰状态、烟雾状态 |
| 按钮 | `btn_get` | 获取 |
| 分组框 | `groupBox1` | 配置 |
| 标签 | `lbl_Ledport` | LED |
| 下拉框 | `com_ledPort` | LED 屏的串口 |
| 标签 | `lbl_4150port` | 环境采集 |
| 下拉框 | `com_environmentPort` | ADAM-4150 的串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
| 按钮 | `OpenRecord` | 历史灾情信息 |
历史记录窗体 Form2:
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form2` | 历史灾情记录 |
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ------------- | ------------------ |
| 窗体 | `Form2` | 历史灾情记录 |
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
> 书上表 5-6 没列 `btn_get`、`OpenRecord`、`groupBox2`,照图加上。`lstbRecored` 的拼写就是这样(不是 Record),照着写。
+14 -14
View File
@@ -12,20 +12,20 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 校园灾情数据存储 |
| 分组框 | `groupBox2` | 配置 |
| 下拉框 | `com_port` | ADAM-4017+ 的串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 按钮 | `btn_get` | 获取 |
| 按钮 | `btn_save` | 保存 |
| 按钮 | `btnUpdate` | 修改 |
| 按钮 | `btnDelete` | 删除 |
| 分组框 | `groupBox3` | 历史记录 |
| 数据表格 | `dataGridView1` | 显示数据库里的记录 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| -------- | ---------------------------------------------- | ------------------------ |
| 窗体 | `Form1` | 校园灾情数据存储 |
| 分组框 | `groupBox2` | 配置 |
| 下拉框 | `com_port` | ADAM-4017+ 的串口 |
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
| 分组框 | `groupBox1` | 数据显示 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 按钮 | `btn_get` | 获取 |
| 按钮 | `btn_save` | 保存 |
| 按钮 | `btnUpdate` | 修改 |
| 按钮 | `btnDelete` | 删除 |
| 分组框 | `groupBox3` | 历史记录 |
| 数据表格 | `dataGridView1` | 显示数据库里的记录 |
> 注意按钮名字的写法不统一:`btn_Open`、`btn_get`、`btn_save` 带下划线,`btnUpdate`、`btnDelete` 不带,照书上的写,自动评分按这些名字找按钮。
+10 -10
View File
@@ -12,16 +12,16 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Form1` | 查询校园灾情数据 |
| 标签 | `LblStartTime` | 开始日期 |
| 日期时间控件 | `StartTime` | 选开始日期 |
| 标签 | `LblEndTime` | 结束日期 |
| 日期时间控件 | `EndTime` | 选结束日期 |
| 按钮 | `btnCx` | 查询 |
| 分组框 | `groupBox4` | 历史记录 |
| 数据表格 | `dataGridView1` | 显示查到的记录 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------------ | --------------- | ------------------ |
| 窗体 | `Form1` | 查询校园灾情数据 |
| 标签 | `LblStartTime` | 开始日期 |
| 日期时间控件 | `StartTime` | 选开始日期 |
| 标签 | `LblEndTime` | 结束日期 |
| 日期时间控件 | `EndTime` | 选结束日期 |
| 按钮 | `btnCx` | 查询 |
| 分组框 | `groupBox4` | 历史记录 |
| 数据表格 | `dataGridView1` | 显示查到的记录 |
## 实现步骤
+15 -15
View File
@@ -12,21 +12,21 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `frm_control` | 智慧教室情景控制 |
| 分组框 | `grp_mode` | 模式选择 |
| 标签 | `lbl_mode` | 智慧教室将开启(启用后变成"智慧教室已开启") |
| 按钮 | `btn_apply` | 启用(启用后变成"关闭") |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 分组框 | `grp_status` | 状态栏 |
| 图片框 | `pic_airconditioneStatus` | 空调开关状态,图片选 switch_off |
| 滑动条 | `trb_airconditione` | 模拟空调温度:最小值 18,最大值 32,值 18 |
| 图片框 | `pic_lightsStatus` | 灯光开关状态,图片选 switch_off;点它手动开关灯 |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 定时器 | `timer` | 间隔 2000(2 秒) |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ---------------------------------------------- | ----------------------------------------------- |
| 窗体 | `frm_control` | 智慧教室情景控制 |
| 分组框 | `grp_mode` | 模式选择 |
| 标签 | `lbl_mode` | 智慧教室将开启(启用后变成"智慧教室已开启") |
| 按钮 | `btn_apply` | 启用(启用后变成"关闭") |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 分组框 | `grp_status` | 状态栏 |
| 图片框 | `pic_airconditioneStatus` | 空调开关状态,图片选 switch_off |
| 滑动条 | `trb_airconditione` | 模拟空调温度:最小值 18,最大值 32,值 18 |
| 图片框 | `pic_lightsStatus` | 灯光开关状态,图片选 switch_off;点它手动开关灯 |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 定时器 | `timer` | 间隔 2000(2 秒) |
其余标签(串口、光照度、温度、湿度、空调、灯光、18℃、32℃)照书上的图摆好,名字不影响评分。
+22 -22
View File
@@ -2,12 +2,12 @@
做一个"智慧寝室"控制窗体:点"开启情景模式"后,程序每秒显示当前时间和光照度、温度、湿度,并按时间段自动切换四种情景模式,控制灯光、窗帘和广播:
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
|---|---|---|---|---|
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
| 其他时间 | 熄灯模式 | 关 | 10(全关) | 关 |
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
| ----------- | -------- | ---- | -------------- | ------ |
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
| 其他时间 | 熄灯模式 | 关 | 10(全关) | 关 |
练习重点:DateTime 的比较,自己写方法(CheckMode、PlayMp3),用线程(Thread)播放声音,媒体播放器控件。详细步骤见书 p141–145。
@@ -19,22 +19,22 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `frm_control` | 智慧寝室情景控制 |
| 分组框 | `grp_mode` | 模式选择 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_mode` | 显示现在是哪种模式(文字先空着) |
| 按钮 | `btn_apply` | 开启情景模式(开启后变成"停止情景模式") |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 分组框 | `grp_status` | 状态栏 |
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关(最小值 0、最大值 10 用默认的就行) |
| 图片框 | `pic_boardcastStatus` | 广播状态,图片选 switch_off |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 定时器 | `timer` | 间隔 1000(1 秒) |
| 媒体播放器 | `axWindowsMediaPlayer1` | 播放铃声;可见设为 False |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ---------- | ---------------------------------------------- | ------------------------------------------------------------- |
| 窗体 | `frm_control` | 智慧寝室情景控制 |
| 分组框 | `grp_mode` | 模式选择 |
| 文本框 | `txt_time` | 显示当前时间 |
| 标签 | `lbl_mode` | 显示现在是哪种模式(文字先空着) |
| 按钮 | `btn_apply` | 开启情景模式(开启后变成"停止情景模式") |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 分组框 | `grp_status` | 状态栏 |
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关(最小值 0、最大值 10 用默认的就行) |
| 图片框 | `pic_boardcastStatus` | 广播状态,图片选 switch_off |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 定时器 | `timer` | 间隔 1000(1 秒) |
| 媒体播放器 | `axWindowsMediaPlayer1` | 播放铃声;可见设为 False |
其余标签(当前时间、串口、光照度、温度、湿度、窗帘、全开、全关、广播、灯光)照书上的图摆好,名字不影响评分。
+27 -27
View File
@@ -3,10 +3,10 @@
做一个"智慧会议室"预约窗体:程序一启动就每秒显示当前时间和光照度、温度、湿度、气压、空气质量。填好时、分、秒,选一种模式(会议、讨论、展示),点"预约";到了预约时间,程序自动开启这种模式,控制灯光、窗帘和主、客话筒:
| 模式 | 灯光 | 窗帘(滑动条) | 主话筒 | 客话筒 |
|---|---|---|---|---|
| 会议 | 开 | 0(全开) | 开 | 关 |
| 讨论 | 开 | 0(全开) | 开 | 开 |
| 展示 | 开 | 10(全关) | 开 | 关 |
| ---- | ---- | -------------- | ------ | ------ |
| 会议 | 开 | 0(全开) | 开 | 关 |
| 讨论 | 开 | 0(全开) | 开 | 开 |
| 展示 | 开 | 10(全关) | 开 | 关 |
勾选"拍照记录"时,预约时间到了还要打开网络摄像头,每 5 分钟存一张照片。点"退出"关掉所有设备。
@@ -18,29 +18,29 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `frm_control` | 智慧会议室情景模式 |
| 分组框 | `grp_mode` | 模式选择 |
| 标签 | `lbl_time` | 当前时间:00:00:00(运行时显示当前时间) |
| 标签 | `lbl_mode` | 智慧会议室未开启情景模式 |
| 复选框 | `chk_record` | 拍照记录 |
| 文本框 | `txt_hour`、`txt_minute`、`txt_second` | 预约的时、分、秒,默认 13、20、00 |
| 标签 | `lbl_hour`、`lbl_minute`、`lbl_second` | 时、分、秒将会有场 |
| 下拉框 | `com_mode` | 选择模式,默认文字"会议" |
| 按钮 | `btn_reserve` | 预约 |
| 按钮 | `btn_exit` | 退出 |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 文本框 | `txt_airPressure`、`txt_airQuality` | 气压、空气质量 |
| 文本框 | `txt_camera` | 摄像头 IP 地址,默认 192.168.14.131 |
| 分组框 | `grp_status` | 状态栏 |
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关 |
| 图片框 | `pic_lightsStatus` | 灯光开关,点它手动开关灯 |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 图片框 | `pic_mainMicrophone`、`pic_subMicrophone` | 主、客话筒开关,点它手动开关 |
| 定时器 | `timer` | 间隔 1000(1 秒) |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ---------------------------------------------- | ---------------------------------------- |
| 窗体 | `frm_control` | 智慧会议室情景模式 |
| 分组框 | `grp_mode` | 模式选择 |
| 标签 | `lbl_time` | 当前时间:00:00:00(运行时显示当前时间) |
| 标签 | `lbl_mode` | 智慧会议室未开启情景模式 |
| 复选框 | `chk_record` | 拍照记录 |
| 文本框 | `txt_hour`、`txt_minute`、`txt_second` | 预约的时、分、秒,默认 13、20、00 |
| 标签 | `lbl_hour`、`lbl_minute`、`lbl_second` | 时、分、秒将会有场 |
| 下拉框 | `com_mode` | 选择模式,默认文字"会议" |
| 按钮 | `btn_reserve` | 预约 |
| 按钮 | `btn_exit` | 退出 |
| 分组框 | `grp_input` | 采集量 |
| 下拉框 | `com_import` | 采集器的串口 |
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 光照度、温度、湿度 |
| 文本框 | `txt_airPressure`、`txt_airQuality` | 气压、空气质量 |
| 文本框 | `txt_camera` | 摄像头 IP 地址,默认 192.168.14.131 |
| 分组框 | `grp_status` | 状态栏 |
| 滑动条 | `trb_curtain` | 模拟窗帘:0 全开,10 全关 |
| 图片框 | `pic_lightsStatus` | 灯光开关,点它手动开关灯 |
| 灯泡 | `lamp` | 窗体上显示灯的亮灭 |
| 图片框 | `pic_mainMicrophone`、`pic_subMicrophone` | 主、客话筒开关,点它手动开关 |
| 定时器 | `timer` | 间隔 1000(1 秒) |
其余标签(串口、光照度、温度、湿度、气压、空气质量、摄像头IP地址、窗帘、全开、全关、灯光、话筒、主、客)照书上的图摆好,名字不影响评分。书上表格里还有 CO₂ 浓度一行,但代码里没有用到,可以不做。
+9 -9
View File
@@ -6,15 +6,15 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Main` | 主界面;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 53, 147, 237 |
| 图片框 | `pictureBox1` | 放在 panel1 上,图片 `ico_title`("智慧校园"标题),SizeMode 设为 StretchImage |
| 面板 | `panel2` | 停靠 Fill,背景图片 `bg_home`(校园),背景排列 Stretch |
| 图片框 | `pic_environment` | "校园环境"入口,图片 `icon_02` |
| 图片框 | `pic_security` | "校园安防"入口,图片 `icon_03` |
| 图片框 | `pic_exit` | "退出",图片 `icon_04` |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ----------------- | ------------------------------------------------------------------------------ |
| 窗体 | `Main` | 主界面;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 53, 147, 237 |
| 图片框 | `pictureBox1` | 放在 panel1 上,图片 `ico_title`("智慧校园"标题),SizeMode 设为 StretchImage |
| 面板 | `panel2` | 停靠 Fill,背景图片 `bg_home`(校园),背景排列 Stretch |
| 图片框 | `pic_environment` | "校园环境"入口,图片 `icon_02` |
| 图片框 | `pic_security` | "校园安防"入口,图片 `icon_03` |
| 图片框 | `pic_exit` | "退出",图片 `icon_04` |
> 三个图标放在 panel2 的背景图上,背景色都设成 **Transparent**,否则图标四周会露出一块灰色。
+24 -24
View File
@@ -8,29 +8,29 @@
## 界面和控件名
| 控件 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 窗体 | `Environment` | 校园环境;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 100, 116, 133,放标题图 `ico_title` |
| 面板 | `panel2` | 停靠 Fill,下面的面板都放在它里面 |
| 面板 | `panel3` | panel2 里停靠 Top,背景色 229, 229, 229 |
| 标签 | `lb_return` | < 返回主窗体(放在 panel3 上) |
| 面板 | `panel_bg` | panel3 下面停靠 Fill,背景图片 `day_02`(路灯没亮的场景),背景排列 Stretch |
| 面板 | `panel5` | panel_bg 里停靠 Bottom:环境数据栏 |
| 文本框 | `txt_fire`、`txt_smoke`、`txt_light`、`txt_temperature`、`txt_humidity` | 火焰、烟雾、光照、温度、湿度(左边各配一个标签) |
| 按钮 | `btn_dataAnalyse` | 数据分析 |
| 按钮 | `btn_ledEnvironment1` | 推送天气信息(环境数据栏里的那个) |
| 面板 | `panel_form` | panel_bg 里停靠 Right,背景色 Transparent,背景图片 `bg_from2` |
| 分组框 | `groupBox2` | 路灯控制 |
| 复选框 | `ch_autoLamp` | 自动控制 |
| 文本框 | `txt_autoLight` | 设定开灯光照值(后面配标签"lx") |
| 图片框 | `pic_lampOpen` | 灯光控制的开关:背景色 Transparent,背景图片 `switch_off` |
| 分组框 | `groupBox3` | LED推送 |
| 文本框 | `txt_ledMsg` | LED信息(Multiline 设为 True) |
| 复选框 | `ch_autoLedFire` | 自动推送火情 |
| 按钮 | `btn_ledSendMsg` | 推送LED信息 |
| 按钮 | `btn_ledEnvironment2` | 推送天气信息(LED推送组里的那个) |
| 图表 | `chart1` | 放在 panel_bg 左下方,显示最高温度和平均温度 |
| 控件 | 名称(Name) | 文字(Text)和用途 |
| ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| 窗体 | `Environment` | 校园环境;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 100, 116, 133,放标题图 `ico_title` |
| 面板 | `panel2` | 停靠 Fill,下面的面板都放在它里面 |
| 面板 | `panel3` | panel2 里停靠 Top,背景色 229, 229, 229 |
| 标签 | `lb_return` | < 返回主窗体(放在 panel3 上) |
| 面板 | `panel_bg` | panel3 下面停靠 Fill,背景图片 `day_02`(路灯没亮的场景),背景排列 Stretch |
| 面板 | `panel5` | panel_bg 里停靠 Bottom:环境数据栏 |
| 文本框 | `txt_fire`、`txt_smoke`、`txt_light`、`txt_temperature`、`txt_humidity` | 火焰、烟雾、光照、温度、湿度(左边各配一个标签) |
| 按钮 | `btn_dataAnalyse` | 数据分析 |
| 按钮 | `btn_ledEnvironment1` | 推送天气信息(环境数据栏里的那个) |
| 面板 | `panel_form` | panel_bg 里停靠 Right,背景色 Transparent,背景图片 `bg_from2` |
| 分组框 | `groupBox2` | 路灯控制 |
| 复选框 | `ch_autoLamp` | 自动控制 |
| 文本框 | `txt_autoLight` | 设定开灯光照值(后面配标签"lx") |
| 图片框 | `pic_lampOpen` | 灯光控制的开关:背景色 Transparent,背景图片 `switch_off` |
| 分组框 | `groupBox3` | LED推送 |
| 文本框 | `txt_ledMsg` | LED信息(Multiline 设为 True) |
| 复选框 | `ch_autoLedFire` | 自动推送火情 |
| 按钮 | `btn_ledSendMsg` | 推送LED信息 |
| 按钮 | `btn_ledEnvironment2` | 推送天气信息(LED推送组里的那个) |
| 图表 | `chart1` | 放在 panel_bg 左下方,显示最高温度和平均温度 |
> 书上表 8-6、表 8-7 把四个按钮的"名称"写成了"数据分析""推送天气信息""推送LED信息",那是按钮上的文字。名称按上表写,任务 3 的代码和自动评分都用这些名字。
@@ -53,4 +53,4 @@
## 在平台上怎么试
1. 运行后在主界面点"校园环境",打开 Environment 窗体:背景是路灯场景,底部有五个文本框和"数据分析""推送天气信息",右边有"路灯控制""LED推送"两组,左下方有图表(现在还没有数据)。
2. 点"< 返回主窗体",Environment 关闭,主界面还在。
2. 点"< 返回主窗体",Environment 关闭,主界面还在。
+16 -16
View File
@@ -14,22 +14,22 @@
界面在任务 2 已经做好,这次要写代码的控件:
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 主界面 | `Main` | Load 里打开所有设备的串口 |
| 主界面 | `pic_environment` | "校园环境":打开 Environment 窗体 |
| 校园环境 | `Environment` | Load 里启动采集线程,FormClosed 里停止线程 |
| 校园环境 | `txt_fire`、`txt_smoke` | 显示"发生火情"/"发生烟情"或"正常" |
| 校园环境 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照、温度、湿度 |
| 校园环境 | `btn_dataAnalyse` | 数据分析 / 关闭图表 |
| 校园环境 | `chart1` | 最近 7 天的最高温度、平均温度 |
| 校园环境 | `btn_ledEnvironment1`、`btn_ledEnvironment2` | 推送天气信息(两个按钮做同一件事) |
| 校园环境 | `pic_lampOpen` | 灯光控制开关:点一下开灯,再点关灯 |
| 校园环境 | `ch_autoLamp` | 自动控制路灯 |
| 校园环境 | `txt_autoLight` | 设定开灯光照值:光照低于它就开灯 |
| 校园环境 | `txt_ledMsg` | 要推送的 LED 信息 |
| 校园环境 | `btn_ledSendMsg` | 推送LED信息 |
| 校园环境 | `ch_autoLedFire` | 自动推送火情 |
| 窗体 | 名称(Name) | 文字(Text)和用途 |
| -------- | ---------------------------------------------- | ------------------------------------------ |
| 主界面 | `Main` | Load 里打开所有设备的串口 |
| 主界面 | `pic_environment` | "校园环境":打开 Environment 窗体 |
| 校园环境 | `Environment` | Load 里启动采集线程,FormClosed 里停止线程 |
| 校园环境 | `txt_fire`、`txt_smoke` | 显示"发生火情"/"发生烟情"或"正常" |
| 校园环境 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照、温度、湿度 |
| 校园环境 | `btn_dataAnalyse` | 数据分析 / 关闭图表 |
| 校园环境 | `chart1` | 最近 7 天的最高温度、平均温度 |
| 校园环境 | `btn_ledEnvironment1`、`btn_ledEnvironment2` | 推送天气信息(两个按钮做同一件事) |
| 校园环境 | `pic_lampOpen` | 灯光控制开关:点一下开灯,再点关灯 |
| 校园环境 | `ch_autoLamp` | 自动控制路灯 |
| 校园环境 | `txt_autoLight` | 设定开灯光照值:光照低于它就开灯 |
| 校园环境 | `txt_ledMsg` | 要推送的 LED 信息 |
| 校园环境 | `btn_ledSendMsg` | 推送LED信息 |
| 校园环境 | `ch_autoLedFire` | 自动推送火情 |
## 实现步骤
+15 -15
View File
@@ -10,22 +10,22 @@
## 界面和控件名
接着任务 3 的程序做。Security 窗体顶部和 Environment 一样:标题栏 + "< 返回主窗体"。
接着任务 3 的程序做。Security 窗体顶部和 Environment 一样:标题栏 + "< 返回主窗体"。
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 主界面 | `pic_security` | "校园安防":打开 Security 窗体 |
| 校园安防 | `Security` | 无边框、屏幕居中 |
| 校园安防 | `panel1`、`panel3` | 标题栏(放标题图 `ico_title`)、返回栏,都停靠 Top |
| 校园安防 | `lb_return` | < 返回主窗体 |
| 校园安防 | `panel_bg_video` | 停靠 Fill,白色背景,下面的控件都放在它上面 |
| 校园安防 | `panel2` | 灰色(DimGray)的视频区 |
| 校园安防 | `pic_video` | 放在 panel2 上,显示摄像机画面;背景图片 `icon_monitor`,背景排列 Center |
| 校园安防 | `lb_infrared` | 放在 panel2 上,标签"入侵:"的右边,开始是 N/A,之后显示"发现入侵"或"正常" |
| 校园安防 | `panel_video_ctrl` | 方向盘:背景图片 `panel_video_ctrl`,背景排列 Stretch |
| 窗体 | 名称(Name) | 文字(Text)和用途 |
| -------- | --------------------------------------------- | ----------------------------------------------------------------------------- |
| 主界面 | `pic_security` | "校园安防":打开 Security 窗体 |
| 校园安防 | `Security` | 无边框、屏幕居中 |
| 校园安防 | `panel1`、`panel3` | 标题栏(放标题图 `ico_title`)、返回栏,都停靠 Top |
| 校园安防 | `lb_return` | < 返回主窗体 |
| 校园安防 | `panel_bg_video` | 停靠 Fill,白色背景,下面的控件都放在它上面 |
| 校园安防 | `panel2` | 灰色(DimGray)的视频区 |
| 校园安防 | `pic_video` | 放在 panel2 上,显示摄像机画面;背景图片 `icon_monitor`,背景排列 Center |
| 校园安防 | `lb_infrared` | 放在 panel2 上,标签"入侵:"的右边,开始是 N/A,之后显示"发现入侵"或"正常" |
| 校园安防 | `panel_video_ctrl` | 方向盘:背景图片 `panel_video_ctrl`,背景排列 Stretch |
| 校园安防 | `pic_up`、`pic_down`、`pic_left`、`pic_right` | 放在方向盘上的四个方向键,背景图片 `btn_arrow_up_normal` 等,背景排列 Stretch |
| 校园安防 | `btn_camera` | 拍照 |
| 校园安防 | `btn_history` | 查看历史图像(任务 5 再写代码) |
| 校园安防 | `btn_camera` | 拍照 |
| 校园安防 | `btn_history` | 查看历史图像(任务 5 再写代码) |
## 实现步骤
@@ -55,4 +55,4 @@
2. 点实训台的**红外对射**遮挡光束,"入侵:"后面显示"发现入侵",画面里出现翻墙的人;再点一次恢复,显示"正常"。
3. 点四个方向键,画面跟着转;松开后方向键换回原来的图片。把鼠标放在实训台的**网络摄像头**上能看到云台的位置。
4. 点"拍照",运行区下面的"程序保存的图片"里多出一张 Camera 文件夹下的照片(文件名是拍照的日期和时间);到**数据库**页面查询 SecurityData 表,能看到拍照和入侵时存的记录。
5. 点"< 返回主窗体",Security 关闭,摄像机也关闭。
5. 点"< 返回主窗体",Security 关闭,摄像机也关闭。
+13 -13
View File
@@ -12,19 +12,19 @@
接着任务 4 的程序做。
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|---|---|---|
| 校园安防 | `btn_history` | 查看历史图像:打开 SecurityCameraData 窗体 |
| 历史报警记录 | `SecurityCameraData` | 无边框、屏幕居中 |
| 历史报警记录 | `panel1` | 标题栏:背景色 100, 116, 133,左边标签 `label1`"历史报警记录"(白字) |
| 历史报警记录 | `pic_close` | 标题栏右边的关闭按钮:背景图片 `windowstate_closed_hover`,背景排列 Center |
| 历史报警记录 | `panel_toolBar` | 工具栏:背景色 229, 229, 229 |
| 历史报警记录 | `dateTimePicker1`、`dateTimePicker2` | 开始时间、结束时间(左边各配一个标签) |
| 历史报警记录 | `btn_get` | 查询 |
| 历史报警记录 | `pic_camera` | 显示照片:背景色 64, 64, 64 |
| 历史报警记录 | `lb_noData` | NO DATA(红字,背景色 64, 64, 64,字号 48),叠在 pic_camera 中间 |
| 历史报警记录 | `lb_time` | TIME(红字,背景色 64, 64, 64),叠在 pic_camera 右下角,显示拍摄时间 |
| 历史报警记录 | `pic_up`、`pic_down` | 左、右翻页箭头:图片 `arrow_left_normal`、`arrow_right_normal`,SizeMode 设为 CenterImage |
| 窗体 | 名称(Name) | 文字(Text)和用途 |
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- |
| 校园安防 | `btn_history` | 查看历史图像:打开 SecurityCameraData 窗体 |
| 历史报警记录 | `SecurityCameraData` | 无边框、屏幕居中 |
| 历史报警记录 | `panel1` | 标题栏:背景色 100, 116, 133,左边标签 `label1`"历史报警记录"(白字) |
| 历史报警记录 | `pic_close` | 标题栏右边的关闭按钮:背景图片 `windowstate_closed_hover`,背景排列 Center |
| 历史报警记录 | `panel_toolBar` | 工具栏:背景色 229, 229, 229 |
| 历史报警记录 | `dateTimePicker1`、`dateTimePicker2` | 开始时间、结束时间(左边各配一个标签) |
| 历史报警记录 | `btn_get` | 查询 |
| 历史报警记录 | `pic_camera` | 显示照片:背景色 64, 64, 64 |
| 历史报警记录 | `lb_noData` | NO DATA(红字,背景色 64, 64, 64,字号 48),叠在 pic_camera 中间 |
| 历史报警记录 | `lb_time` | TIME(红字,背景色 64, 64, 64),叠在 pic_camera 右下角,显示拍摄时间 |
| 历史报警记录 | `pic_up`、`pic_down` | 左、右翻页箭头:图片 `arrow_left_normal`、`arrow_right_normal`,SizeMode 设为 CenterImage |
> 注意 `pic_up` 是**左**箭头(上一张)、`pic_down` 是**右**箭头(下一张),名字照书上写。
+11 -7
View File
@@ -64,7 +64,10 @@ function userPrompt(input: {
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
: '编译没有通过。'
: '(还没有自动评分结果)'
const hints = conventionHints(input.files).map((h) => `- ${h.text}`).join('\n') || '- 无'
const hints =
conventionHints(input.files)
.map((h) => `- ${h.text}`)
.join('\n') || '- 无'
const code = input.files
.filter((f) => f.path.endsWith('.cs'))
.map((f) => `===== ${f.path} =====\n${f.content}`)
@@ -75,8 +78,12 @@ function userPrompt(input: {
${items}
${BOOK_CRITERIA}
${input.bookNames.length ? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
` : ''}
${
input.bookNames.length
? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
`
: ''
}
平台自动评分的结果(功能,供参考,不用再评):
${auto}
@@ -101,10 +108,7 @@ async function callModel(system: string, user: string, screenshots: Screenshot[]
{ role: 'system', content: system },
{
role: 'user',
content: [
{ type: 'text', text: user },
...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } })),
],
content: [{ type: 'text', text: user }, ...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } }))],
},
],
}),
+27 -4
View File
@@ -259,7 +259,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
await next()
})
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
app.post('/api/teacher/classes', async (c) => {
@@ -308,7 +307,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
)
app.post('/api/teacher/assignments', async (c) => {
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c)
const a = await body<{
classId: number
sampleId: string
title: string
startMode: StartMode
template: unknown
scoring: Scoring
deadline: number | null
task: string | null
}>(c)
if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404)
const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
@@ -352,7 +360,13 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
classSubmissions(db, id(c)).map((s) => {
const review = getReview(db, id(c), s.userId)
const ai = s.submissionId ? getAiReview(db, s.submissionId) : null
return { ...s, manualScore: manualScore(review), review, aiStatus: ai?.status ?? null, aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null }
return {
...s,
manualScore: manualScore(review),
review,
aiStatus: ai?.status ?? null,
aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null,
}
}),
),
)
@@ -378,7 +392,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
if (!s.submissionId || getReview(db, id(c), s.userId)) continue
const ai = getAiReview(db, s.submissionId)
if (ai?.status !== 'done') continue
if (!saveReview(db, id(c), s.userId, ai.scores.map((x) => x.score), ai.comment).error) adopted++
if (
!saveReview(
db,
id(c),
s.userId,
ai.scores.map((x) => x.score),
ai.comment,
).error
)
adopted++
}
return c.json({ adopted })
})
+17 -3
View File
@@ -121,7 +121,9 @@ export function updateAssignment(
id,
)
// 分值变了,已评分的提交重新算分
for (const s of db.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`).all(id)) {
for (const s of db
.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`)
.all(id)) {
db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id)
}
return {}
@@ -180,7 +182,10 @@ export function createSubmission(db: Database, assignmentId: number, userId: num
}
export function pendingSubmissionIds(db: Database): number[] {
return db.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`).all().map((r) => r.id)
return db
.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`)
.all()
.map((r) => r.id)
}
export function submissionForGrading(db: Database, id: number) {
@@ -211,7 +216,16 @@ export function saveGrade(db: Database, id: number, result: GradeResult | null)
export function classSubmissions(db: Database, assignmentId: number) {
return db
.query<
{ user_id: number; username: string; name: string; submission_id: number | null; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number },
{
user_id: number
username: string
name: string
submission_id: number | null
status: Submission['status'] | null
auto_score: number | null
created_at: number | null
count: number
},
[number]
>(
`SELECT u.id AS user_id, u.username, u.name, s.id AS submission_id, s.status, s.auto_score, s.created_at,
+12 -3
View File
@@ -67,7 +67,8 @@ export class Board {
* 恢复真实时间是 clock = null。
*/
setClock(time: string | undefined, speed: number | undefined) {
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`)))) return false
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`))))
return false
if (speed !== undefined && (typeof speed !== 'number' || !Number.isFinite(speed) || speed < 1 || speed > 3600)) return false
const now = Date.now()
this.state.clock = {
@@ -124,6 +125,14 @@ export class Board {
export function wallNow(now = new Date()) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: config.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).format(now).replace(' ', 'T')
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
.format(now)
.replace(' ', 'T')
}
+1 -3
View File
@@ -68,9 +68,7 @@ export function deleteClass(db: Database, id: number): { error?: string } {
}
export function listStudents(db: Database, classId: number): Student[] {
return db
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.all(classId)
return db.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId)
}
/**
+15 -3
View File
@@ -61,7 +61,15 @@ export type Expectation =
*/
const DEFAULT_WITHIN = 5000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
const PROP_KEYS: Record<string, string> = {
text: 'Text',
ledText: 'LedText',
enabled: 'Enabled',
checked: 'Checked',
visible: 'Visible',
on: 'IsOn',
value: 'Value',
}
const STRING_PROPS = new Set(['Text', 'LedText'])
const NUMBER_PROPS = new Set(['Value'])
@@ -96,7 +104,8 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
if ('window' in raw) return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('window' in raw)
return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('exited' in raw) {
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
return { kind: 'exited' }
@@ -122,7 +131,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
return { kind: 'row', control, cells, present: 'row' in raw }
}
// image: true 或 image: 资源名;background: 资源名
for (const [key, prop] of [['image', 'Image'], ['background', 'BackgroundImage']] as const) {
for (const [key, prop] of [
['image', 'Image'],
['background', 'BackgroundImage'],
] as const) {
if (!(key in raw)) continue
const value = raw[key]
if (value === true) return { kind: 'image', control, prop }
+25 -8
View File
@@ -135,7 +135,8 @@ async function runStep(driver: ProgramDriver, step: Step) {
const index = items.indexOf(step.item)
if (index < 0) {
// 串口列表是空的:最常见的原因是没有填充
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
const hint =
items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
}
driver.act(node, { action: 'select', index })
@@ -151,7 +152,14 @@ async function runStep(driver: ProgramDriver, step: Step) {
const rows = gridRows(node)
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column)
if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`)
if (column < 0)
throw new Failure(
`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${
gridColumns(node)
.map((c) => c.header)
.join('、') || '没有'
})`,
)
driver.act(node, { action: 'cellClick', row: step.row, column })
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
await driver.settle()
@@ -302,8 +310,10 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'prop': {
const actual = need(driver, e.control).props[e.prop]
if (e.prop === 'Text' || e.prop === 'LedText') return `期望${prefix} ${e.control} ${desc ?? `显示"${e.value}"`},实际是${quote(actual)}`
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
if (e.prop === 'Enabled')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
if (e.prop === 'Value') return `期望${prefix} ${e.control} ${desc ?? `的值是 ${String(e.value)}`},实际是 ${String(actual)}`
@@ -312,7 +322,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'contains': {
const node = need(driver, e.control)
const items = listItems(node)
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
if (items)
return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
}
case 'matches':
@@ -354,12 +365,18 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
case 'row': {
const node = need(driver, e.control)
const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',')
const wanted = Object.entries(e.cells)
.map(([k, v]) => `${k}=${v}`)
.join(',')
const columns = gridColumns(node)
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
if (missing.length)
return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
const rows = gridRows(node)
const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';')
const shown = rows
.slice(0, 5)
.map((r) => r.join(' | '))
.join(';')
return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}`
}
}
-1
View File
@@ -9,7 +9,6 @@ import { config } from '../config'
import { safeJoin } from '../run-session'
import { grade } from './grader'
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
await rm(dir, { recursive: true, force: true })
+9 -2
View File
@@ -30,7 +30,11 @@ if (sandboxError) console.error(sandboxError)
const db = openDatabase()
ensureTeacher(db)
const aiQueue = createAiQueue(db)
const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue)
const app = createApp(
db,
createGradingQueue(db, (id) => aiQueue.enqueue(id)),
aiQueue,
)
interface SocketData {
userId?: number
@@ -58,7 +62,10 @@ const server = Bun.serve({
sendPings: true,
open(ws) {
const userId = ws.data.userId!
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
ws.data.session = new RunSession(
(message) => ws.send(JSON.stringify(message)),
() => userDatabaseDir(userId),
)
},
async message(ws, raw) {
let message: ClientMessage
+17 -5
View File
@@ -39,7 +39,8 @@ export function saveReview(db: Database, assignmentId: number, userId: number, s
if (!Array.isArray(scores) || scores.length !== manual.length) return { error: `老师评分项应有 ${manual.length} 项` }
for (const [i, s] of scores.entries()) {
if (s === null) continue
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points) return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points)
return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
}
db.query(
`INSERT INTO reviews (assignment_id, user_id, scores, comment, updated_at) VALUES (?, ?, ?, ?, ?)
@@ -53,10 +54,13 @@ export function studentDetail(db: Database, assignmentId: number, userId: number
const student = db.query<{ id: number; username: string; name: string }, [number]>('SELECT id, username, name FROM users WHERE id = ?').get(userId)
if (!student) return null
const latest = latestSubmission(db, assignmentId, userId)
const row = latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const row =
latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const files = row ? (JSON.parse(row.files) as SourceFile[]) : null
const screenshots = row?.screenshots ? (JSON.parse(row.screenshots) as Screenshot[]) : []
const count = db.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?').get(assignmentId, userId)!.n
const count = db
.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?')
.get(assignmentId, userId)!.n
return {
student,
submission: latest,
@@ -77,7 +81,13 @@ export function submissionFiles(db: Database, submissionId: number) {
)
.get(submissionId)
if (!row) return null
return { files: JSON.parse(row.files) as SourceFile[], assignmentId: row.assignment_id, assignmentTitle: row.title, studentName: row.name, username: row.username }
return {
files: JSON.parse(row.files) as SourceFile[],
assignmentId: row.assignment_id,
assignmentTitle: row.title,
studentName: row.name,
username: row.username,
}
}
/** 全班成绩表:每个学生在每个作业上的自动评分、老师评分和总分 */
@@ -91,7 +101,9 @@ export function gradebook(db: Database, classId: number) {
return { id: a.id, title: a.title, scoring, total: sum(scoring.auto) + sum(scoring.manual.map((m) => m.points)) }
})
const students = db
.query<{ id: number; username: string; name: string }, [number]>(`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.query<{ id: number; username: string; name: string }, [number]>(
`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`,
)
.all(classId)
return {
assignments: assignments.map(({ id, title, total }) => ({ id, title, total })),
+30 -11
View File
@@ -75,13 +75,20 @@ export function runCommand(projectDir: string, extraEnv: Record<string, string>,
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
const cmd = [
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
'nice', '-n', '10',
'nice',
'-n',
'10',
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
'prlimit',
`--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`,
'--core=0',
...bwrapArgs(projectDir),
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
'dotnet',
`${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`,
'run',
APP_DIR,
]
return { cmd, env }
}
@@ -105,15 +112,27 @@ function bwrapArgs(projectDir: string) {
'--unshare-all',
'--die-with-parent',
'--new-session',
'--ro-bind', '/usr', '/usr',
'--ro-bind',
'/usr',
'/usr',
...rootLinks(),
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
'--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,
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/tmp',
'--ro-bind',
dirname(config.runnerDll),
RUNNER_DIR,
'--ro-bind',
projectDir,
APP_DIR,
'--chdir',
APP_DIR,
]
}
+8 -4
View File
@@ -25,9 +25,7 @@ export interface SqlColumnInfo {
}
export type SqlRequest =
| { type: 'exec'; dir: string; database: string; sql: string }
| { type: 'script'; dir: string; sql: string }
| { type: 'explorer'; dir: string }
{ type: 'exec'; dir: string; database: string; sql: string } | { type: 'script'; dir: string; sql: string } | { type: 'explorer'; dir: string }
/** SQL 报错(编号和信息与 SQL Server 一致) */
export class SqlError extends Error {
@@ -78,7 +76,13 @@ class SqlService {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'inherit',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', TZ: config.timeZone },
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: process.env.HOME ?? '/tmp',
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
TZ: config.timeZone,
},
})
this.proc = proc
this.ready = new Promise<void>((resolveReady) => {
+108 -20
View File
@@ -33,9 +33,7 @@ class Browser {
const headers: Record<string, string> = { 'x-real-ip': this.ip }
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
if (json !== undefined) headers['content-type'] = 'application/json'
const response = await app.fetch(
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
)
const response = await app.fetch(new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }))
const body = (await response.json()) as any
if (body?.accessToken) {
this.accessToken = body.accessToken
@@ -418,7 +416,13 @@ describe('作业', () => {
describe('评阅与成绩', () => {
const SAMPLE = 'p4-t3-报警灯'
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释', points: 5 }] }
const scoring = {
auto: [10, 10],
manual: [
{ title: '窗体界面', points: 10 },
{ title: '注释', points: 5 },
],
}
async function submitted() {
const t = await teacher()
@@ -427,7 +431,15 @@ describe('评阅与成绩', () => {
{ username: 's2', name: '乙', password: 'p2' },
])
const files = (await loadSample(SAMPLE))!
const { body } = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '报警灯', startMode: 'ui', template: files, scoring, deadline: null })
const { body } = await t.post('/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '报警灯',
startMode: 'ui',
template: files,
scoring,
deadline: null,
})
const student = new Browser()
await student.login('s1', 'p1')
await student.post(`/api/assignments/${body.id}/submit`, { files })
@@ -452,7 +464,11 @@ describe('评阅与成绩', () => {
expect((await student.get(`/api/assignments/${assignmentId}`)).body.review).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' })
expect((await student.get('/api/assignments')).body[0].manualScore).toBe(8)
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({ username: 's1', manualScore: 8, review: { comment: '界面整齐,注释还要补' } })
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({
username: 's1',
manualScore: 8,
review: { comment: '界面整齐,注释还要补' },
})
}, 60000)
test('成绩表:总分 = 自动评分 + 老师评分;没提交的没有总分', async () => {
@@ -469,7 +485,11 @@ describe('评阅与成绩', () => {
test('老师减少评分项后,已打的分按新的评分项对齐', async () => {
const { t, assignmentId, userId } = await submitted()
await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, 4], comment: '' })
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '报警灯', deadline: null, scoring: { auto: [10, 10], manual: [{ title: '窗体界面', points: 5 }] } })
await t.patch(`/api/teacher/assignments/${assignmentId}`, {
title: '报警灯',
deadline: null,
scoring: { auto: [10, 10], manual: [{ title: '窗体界面', points: 5 }] },
})
expect((await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body.review.scores).toEqual([5])
}, 60000)
@@ -487,7 +507,12 @@ describe('语言服务', () => {
test('代码补全、边写边检查(中文提示);请求格式不对拒绝;没登录不能用', async () => {
const t = await teacher()
const files = [{ path: 'Program.cs', content: 'using System.Windows.Forms;\nstatic class P\n{\n static void Main()\n {\n var b = new Button();\n b.\n }\n}\n' }]
const files = [
{
path: 'Program.cs',
content: 'using System.Windows.Forms;\nstatic class P\n{\n static void Main()\n {\n var b = new Button();\n b.\n }\n}\n',
},
]
const completions = await t.post('/api/language', { type: 'complete', session: 'tab1', files, file: 'Program.cs', line: 7, column: 11 })
expect(completions.body.items.map((i: any) => i.label)).toEqual(expect.arrayContaining(['Text', 'Click', 'Enabled']))
@@ -500,7 +525,13 @@ describe('语言服务', () => {
test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => {
const t = await teacher()
const files = [{ path: 'Program.cs', content: 'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n' }]
const files = [
{
path: 'Program.cs',
content:
'using System.Threading;\nstatic class P\n{\n static void Main()\n {\n var t = new Thread(() => Thread.Sleep(10));\n t.Start();\n t.Suspend();\n t.Resume();\n t.Abort();\n }\n}\n',
},
]
const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab2', files })
expect(check.body.diagnostics).toEqual([])
}, 60000)
@@ -529,16 +560,40 @@ describe('数据库页面', () => {
database: '测试库',
sql: "CREATE TABLE T (ID int IDENTITY(1,1) PRIMARY KEY, name nvarchar(20)); INSERT INTO T (name) VALUES (N'张三'), (N'李四'); SELECT * FROM T WHERE name LIKE N'张%'",
})
expect(created.body).toMatchObject({ type: 'result', database: '测试库', affected: 2, results: [{ columns: [{ name: 'ID', type: 'int' }, { name: 'name', type: 'nvarchar(20)' }], rows: [[1, '张三']], total: 1 }] })
expect(created.body).toMatchObject({
type: 'result',
database: '测试库',
affected: 2,
results: [
{
columns: [
{ name: 'ID', type: 'int' },
{ name: 'name', type: 'nvarchar(20)' },
],
rows: [[1, '张三']],
total: 1,
},
],
})
const error = await t.post('/api/db/query', { database: '测试库', sql: 'SELECT * FROM 不存在的表' })
expect(error.body).toEqual({ type: 'error', number: 208, message: "对象名 '不存在的表' 无效。", line: 1 })
const explorer = (await t.get('/api/db')).body
expect(explorer.databases).toEqual([{ name: '测试库', tables: [{ name: 'T', columns: [
{ name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
] }] }])
expect(explorer.databases).toEqual([
{
name: '测试库',
tables: [
{
name: 'T',
columns: [
{ name: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
],
},
],
},
])
expect((await student.get('/api/db')).body.databases).toEqual([])
expect((await new Browser().get('/api/db')).status).toBe(401)
}, 60000)
@@ -573,7 +628,13 @@ describe('数据库页面', () => {
describe('AI 评阅', () => {
const SAMPLE = 'p4-t3-报警灯'
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释规范', points: 5 }] }
const scoring = {
auto: [10, 10],
manual: [
{ title: '窗体界面', points: 10 },
{ title: '注释规范', points: 5 },
],
}
/** 假的模型服务:记下收到的请求,返回固定的评分(第二项故意超出满分,检查服务器会限制) */
const requests: any[] = []
let fake: ReturnType<typeof Bun.serve>
@@ -584,7 +645,13 @@ describe('AI 评阅', () => {
port: 0,
async fetch(req) {
requests.push(await req.json())
const content = JSON.stringify({ scores: [{ item: '窗体界面', score: 8.6, reason: '布局整齐' }, { item: '注释规范', score: 9, reason: '注释充分' }], comment: '界面整齐,继续加油。' })
const content = JSON.stringify({
scores: [
{ item: '窗体界面', score: 8.6, reason: '布局整齐' },
{ item: '注释规范', score: 9, reason: '注释充分' },
],
comment: '界面整齐,继续加油。',
})
return Response.json({ choices: [{ message: { content } }] })
},
})
@@ -599,9 +666,15 @@ describe('AI 评阅', () => {
})
test('分数限制在 0 到满分并取整;评分项个数不对时报错', () => {
const manual = [{ title: 'A', points: 10 }, { title: 'B', points: 5 }]
const manual = [
{ title: 'A', points: 10 },
{ title: 'B', points: 5 },
]
expect(normalizeAiResult({ scores: [{ score: 8.6, reason: 'x' }, { score: -1 }], comment: ' 好 ' }, manual)).toEqual({
scores: [{ score: 9, reason: 'x' }, { score: 0, reason: '' }],
scores: [
{ score: 9, reason: 'x' },
{ score: 0, reason: '' },
],
comment: '好',
})
expect(() => normalizeAiResult({ scores: [{ score: 1 }] }, manual)).toThrow('评分项个数不对')
@@ -611,7 +684,15 @@ describe('AI 评阅', () => {
const t = await teacher()
const classId = await classWithStudents(t, [{ username: 's1', name: '甲同学', password: 'p1' }])
const files = (await loadSample(SAMPLE))!
const { body } = await t.post('/api/teacher/assignments', { classId, sampleId: SAMPLE, title: '报警灯', startMode: 'ui', template: files, scoring, deadline: null })
const { body } = await t.post('/api/teacher/assignments', {
classId,
sampleId: SAMPLE,
title: '报警灯',
startMode: 'ui',
template: files,
scoring,
deadline: null,
})
const assignmentId = body.id as number
const student = new Browser()
await student.login('s1', 'p1')
@@ -622,7 +703,14 @@ describe('AI 评阅', () => {
const userId = (await student.get('/api/me')).body.user.id
const detail = (await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body
expect(detail.aiReview).toMatchObject({ status: 'done', scores: [{ score: 9, reason: '布局整齐' }, { score: 5, reason: '注释充分' }], comment: '界面整齐,继续加油。' })
expect(detail.aiReview).toMatchObject({
status: 'done',
scores: [
{ score: 9, reason: '布局整齐' },
{ score: 5, reason: '注释充分' },
],
comment: '界面整齐,继续加油。',
})
expect(detail.screenshots).toEqual([{ form: 'Form1', png }])
expect((await t.get(`/api/teacher/assignments/${assignmentId}/submissions`)).body[0]).toMatchObject({ aiStatus: 'done', aiManual: 14, review: null })
expect((await t.get(`/api/teacher/classes/${classId}/gradebook`)).body.students[0].scores[assignmentId]).toMatchObject({ manual: null, aiManual: 14 })
+17 -14
View File
@@ -195,9 +195,7 @@ try {
await s.client.request('PUT', `/api/works/${encodeURIComponent(`assignment:${assignment.id}`)}`, { files: s.files })
})
await phase('边写边检查', students, (s) =>
s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }),
)
await phase('边写边检查', students, (s) => s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }))
// 老师说"运行看看":全班 SPREAD 内先后按 F5
await phase('运行到窗体出现', students, async (s) => {
@@ -209,17 +207,22 @@ try {
window.form = ((await shown) as Extract<ServerMessage, { type: 'formShown' }>).form
})
if (!process.env.SAMPLE) await phase('点按钮到实训台', students.filter((s) => s.window?.form), async (s) => {
const window = s.window!
window.action('com_lamp', { action: 'dropDown' })
window.action('com_lamp', { action: 'select', index: 3 })
window.action('btn_open', { action: 'click' })
await Bun.sleep(300)
// 设备消息由服务器记进实训台状态,再以 board 消息推给浏览器
const lit = window.waitFor((m) => m.type === 'board' && m.state.zigbee[1] === true, 30_000)
window.action('btn_lampOpen', { action: 'click' })
await lit
})
if (!process.env.SAMPLE)
await phase(
'点按钮到实训台',
students.filter((s) => s.window?.form),
async (s) => {
const window = s.window!
window.action('com_lamp', { action: 'dropDown' })
window.action('com_lamp', { action: 'select', index: 3 })
window.action('btn_open', { action: 'click' })
await Bun.sleep(300)
// 设备消息由服务器记进实训台状态,再以 board 消息推给浏览器
const lit = window.waitFor((m) => m.type === 'board' && m.state.zigbee[1] === true, 30_000)
window.action('btn_lampOpen', { action: 'click' })
await lit
},
)
// 程序都还开着,再自己检查一遍(评分进程和正在运行的程序抢 CPU)
await phase('自己检查', students, async (s) => {
+29 -9
View File
@@ -37,11 +37,15 @@ describe('参考答案全部通过', async () => {
const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort()
test('每个示例都有评分规则', () => expect(ids.length).toBe(23))
for (const id of ids) {
test(id, async () => {
const result = await grade(await prepare(id, id), await checksOf(id), id)
expect(result.compiled).toBe(true)
expect(result.results.filter((r) => !r.passed)).toEqual([])
}, 60000)
test(
id,
async () => {
const result = await grade(await prepare(id, id), await checksOf(id), id)
expect(result.compiled).toBe(true)
expect(result.results.filter((r) => !r.passed)).toEqual([])
},
60000,
)
}
})
@@ -66,7 +70,11 @@ describe('改坏的代码', () => {
test('判断写反:入侵时显示"正常"', async () => {
const id = 'p4-t1-红外对射'
const dir = await prepare(id, 'reversed', { file: 'Form1.cs', from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)', to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)' })
const dir = await prepare(id, 'reversed', {
file: 'Form1.cs',
from: 'if (DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)',
to: 'if (!DeviceCtrlHelper.adam4150Helper.SetData().infraredValue)',
})
const result = await grade(dir, await checksOf(id), id)
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
expect(realtime.passed).toBe(false)
@@ -94,7 +102,11 @@ describe('改坏的代码', () => {
test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {
const id = 'p6-t2-存储环境数据'
const dir = await prepare(id, 'insert-order', { file: 'Form1.cs', from: 'insert into CampusEnvironment (temperature, humidity, light, addtime) values', to: 'insert into CampusEnvironment values' })
const dir = await prepare(id, 'insert-order', {
file: 'Form1.cs',
from: 'insert into CampusEnvironment (temperature, humidity, light, addtime) values',
to: 'insert into CampusEnvironment values',
})
const result = await grade(dir, await checksOf(id), id)
const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')!
expect(save.passed).toBe(false)
@@ -157,7 +169,11 @@ describe('改坏的代码', () => {
test('管理平台:开灯时换错了背景图片', async () => {
const id = 'p8-t3-环境采集与分析'
const dir = await prepare(id, 'lamp-image', { file: 'Environment.cs', from: 'panel_bg.BackgroundImage = Properties.Resources.day_01;', to: 'panel_bg.BackgroundImage = Properties.Resources.day_02;' })
const dir = await prepare(id, 'lamp-image', {
file: 'Environment.cs',
from: 'panel_bg.BackgroundImage = Properties.Resources.day_01;',
to: 'panel_bg.BackgroundImage = Properties.Resources.day_02;',
})
const result = await grade(dir, await checksOf(id), id)
const lamp = result.results.find((r) => r.title === '路灯能手动控制')!
expect(lamp).toMatchObject({ passed: false, message: '期望 panel_bg 背景换成亮灯的场景(day_01),实际是别的图片' })
@@ -165,7 +181,11 @@ describe('改坏的代码', () => {
test('报警灯接错了 DO 口', async () => {
const id = 'p4-t3-报警灯'
const dir = await prepare(id, 'relay', { file: 'Form1.cs', from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);', to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);' })
const dir = await prepare(id, 'relay', {
file: 'Form1.cs',
from: 'DeviceCtrlHelper.adam4150Helper.OnOff(0, IsOpen);',
to: 'DeviceCtrlHelper.adam4150Helper.OnOff(1, IsOpen);',
})
const result = await grade(dir, await checksOf(id), id)
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
}, 60000)
+70 -43
View File
@@ -176,7 +176,13 @@ test('编译错误:书上常见的 True 大写给出中文提示', async () =>
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
for (const name of ['Program.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`))
const form1 = await Bun.file(`${sample}/Form1.cs`).text()
await Bun.write(`${dir}/Form1.cs`, form1.replace('private void btn_Open_Click(object sender, EventArgs e)\n {', 'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;'))
await Bun.write(
`${dir}/Form1.cs`,
form1.replace(
'private void btn_Open_Click(object sender, EventArgs e)\n {',
'private void btn_Open_Click(object sender, EventArgs e)\n {\n bool IsOpen = True;',
),
)
runner = await Runner.start(dir)
const compiled = await runner.waitFor('compiled')
@@ -193,9 +199,9 @@ test('项目2 任务2:温度读数来自实训台,滑块变化后再次获
runner.send({ type: 'action', id: port, action: 'select', index: 1 })
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
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
.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)
let temperature = textOf('txt_temperature')
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
@@ -279,19 +285,23 @@ test('项目1 任务1:多窗体——打开增加用户窗体、添加用户
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(
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
.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 })
@@ -483,8 +493,17 @@ test('旧版线程 API:Suspend / Resume / Abort、线程自己挂起、休眠
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
for (const line of [
'state0=Unstarted', 'state1=Suspended', 'frozen=True', 'abortSuspended=error', 'joined=True,Stopped',
'selfState=Suspended', 'self=resumed', 'resumeRunning=error', 'sleeperJoined=True', 'unstarted=Unstarted, AbortRequested', 'param=42',
'state0=Unstarted',
'state1=Suspended',
'frozen=True',
'abortSuspended=error',
'joined=True,Stopped',
'selfState=Suspended',
'self=resumed',
'resumeRunning=error',
'sleeperJoined=True',
'unstarted=Unstarted, AbortRequested',
'param=42',
]) {
expect(output).toContain(line)
}
@@ -677,15 +696,17 @@ test('TrackBar:拖动时先 ValueChanged 再 Scroll,超出范围的值截到
runner.send({ type: 'action', id: bar.id, action: 'scroll', value: '99' })
await runner.waitFor('console', (m) => m.text === 'scroll=32\n')
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
expect(output).toContain("error=“40”的值对于“Value”无效。")
expect(output).toContain('error=“40”的值对于“Value”无效。')
expect(output).toContain('changed=26\nscroll=26\nchanged=32\nscroll=32\n')
}, 30000)
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
process.env.SIM_TEST_SECRET = '不该被看到'
const { runner } = await runMain(
'sandbox',
`
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"));
@@ -695,19 +716,21 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别
try { File.WriteAllText("/sim/app/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)
)
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(
@@ -768,14 +791,18 @@ using System.Runtime.InteropServices;
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)
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,
)
+4 -1
View File
@@ -13,7 +13,10 @@ function tableNames(task: string): string[] {
previous = []
continue
}
const cells = line.trim().replace(/^\||\|$/g, '').split('|')
const cells = line
.trim()
.replace(/^\||\|$/g, '')
.split('|')
// 表头是分隔行(|---|)上面那一行;数据行里出现"名称"两个字不算表头
if (cells.every((c) => /^\s*:?-+:?\s*$/.test(c))) column = previous.findIndex((c) => c.includes('名称'))
else if (column >= 0) names.push(...[...(cells[column] ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1]))
+8 -2
View File
@@ -22,10 +22,16 @@
if (!app || app.hasAttribute('data-v-app')) return
app.innerHTML =
'<div style="max-width:560px;margin:80px auto;padding:0 16px;font:16px/1.8 sans-serif;color:#333">' +
'<h2 style="margin:0 0 12px">' + title + '</h2><p>' + text + '</p>' +
'<h2 style="margin:0 0 12px">' +
title +
'</h2><p>' +
text +
'</p>' +
'<p>用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到<b>极速模式</b>;或者换用新版的 <b>Chrome</b>、<b>Edge</b>。' +
'还是打不开的话,请把下面这行文字告诉老师:</p>' +
'<p style="font-size:13px;color:#888;word-break:break-all">' + navigator.userAgent.replace(/</g, '&lt;') + '</p></div>'
'<p style="font-size:13px;color:#888;word-break:break-all">' +
navigator.userAgent.replace(/</g, '&lt;') +
'</p></div>'
}
window.addEventListener('error', function (event) {
if (event && event.error instanceof SyntaxError) {
+1 -1
View File
@@ -16,6 +16,6 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
<style>
body {
margin: 0;
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
font-family: system-ui, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
}
</style>
+6 -1
View File
@@ -82,7 +82,12 @@ function problem(): string | null {
}
const sqlFor = (name: string) =>
props.table ? addColumns(props.table.name, newColumns.value) : createTable(name, rows.value.filter((r) => r.name.trim()).map((r) => ({ ...r, name: r.name.trim(), type: r.type.trim() })))
props.table
? addColumns(props.table.name, newColumns.value)
: createTable(
name,
rows.value.filter((r) => r.name.trim()).map((r) => ({ ...r, name: r.name.trim(), type: r.type.trim() })),
)
function save() {
const error = problem()
+1 -1
View File
@@ -233,7 +233,7 @@ defineExpose({ reload: load })
.grid {
border-collapse: collapse;
font-size: 12px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
background: #fff;
}
+20 -7
View File
@@ -47,7 +47,11 @@ function choose(key: string) {
const REFRESH: ExplorerAction = { kind: 'refresh' }
function rootMenu(event: MouseEvent) {
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
openMenu(event, [
['新建数据库…', { kind: 'newDatabase' }],
['-', REFRESH],
['刷新', REFRESH],
])
}
function databaseMenu(event: MouseEvent, database: string) {
@@ -61,7 +65,11 @@ function databaseMenu(event: MouseEvent, database: string) {
}
function tablesMenu(event: MouseEvent, database: string) {
openMenu(event, [['新建表…', { kind: 'newTable', database }], ['-', REFRESH], ['刷新', REFRESH]])
openMenu(event, [
['新建表…', { kind: 'newTable', database }],
['-', REFRESH],
['刷新', REFRESH],
])
}
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
@@ -88,18 +96,21 @@ function columnText(column: ExplorerTable['columns'][number]) {
<ul class="tree">
<li>
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span
>📁 数据库
</div>
<ul v-if="expanded.has('root')">
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
<li v-for="db in databases" :key="db.name">
<div class="node" @click="toggle(`db:${db.name}`)" @contextmenu="databaseMenu($event, db.name)">
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span>🛢 {{ db.name }}
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span
>🛢 {{ db.name }}
</div>
<ul v-if="expanded.has(`db:${db.name}`)">
<li>
<div class="node" @click="toggle(`tables:${db.name}`)" @contextmenu="tablesMenu($event, db.name)">
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span>📁 表
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span
>📁 表
</div>
<ul v-if="expanded.has(`tables:${db.name}`)">
<li v-if="db.tables.length === 0" class="empty">(没有表)</li>
@@ -110,12 +121,14 @@ function columnText(column: ExplorerTable['columns'][number]) {
@click="toggle(`table:${db.name}/${table.name}`)"
@contextmenu="tableMenu($event, db.name, table)"
>
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>▦ dbo.{{ table.name }}
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span
>▦ dbo.{{ table.name }}
</div>
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
<li>
<div class="node" @click="toggle(`columns:${db.name}/${table.name}`)">
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>📁 列
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span
>📁 列
</div>
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
<li v-for="column in table.columns" :key="column.name" class="node leaf">
+1 -1
View File
@@ -34,7 +34,7 @@ defineProps<{ result: ResultSet }>()
.grid {
border-collapse: collapse;
font-size: 12px;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
background: #fff;
}
+24 -8
View File
@@ -29,7 +29,7 @@ describe('编辑表数据生成的 T-SQL', () => {
})
test('有主键时按主键定位', () => {
expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe('UPDATE TOP (1) [CampusEnvironment] SET [light] = N\'600\' WHERE [ID] = 3')
expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe("UPDATE TOP (1) [CampusEnvironment] SET [light] = N'600' WHERE [ID] = 3")
expect(deleteRow(campus, [3, '500', null])).toBe('DELETE TOP (1) FROM [CampusEnvironment] WHERE [ID] = 3')
})
@@ -38,9 +38,16 @@ describe('编辑表数据生成的 T-SQL', () => {
})
test('新增行跳过自增列;什么都没填时用 DEFAULT VALUES', () => {
expect(insertRow(campus, new Map([[2, '2024-01-20 08:00:00'], [0, '9'], [1, null]]))).toBe(
"INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')",
)
expect(
insertRow(
campus,
new Map([
[2, '2024-01-20 08:00:00'],
[0, '9'],
[1, null],
]),
),
).toBe("INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')")
expect(insertRow(campus, new Map())).toBe('INSERT INTO [CampusEnvironment] DEFAULT VALUES')
})
})
@@ -53,16 +60,25 @@ describe('表设计器生成的 T-SQL', () => {
{ name: 'EnvironmentDay', type: 'date', nullable: true, identity: false, primaryKey: false },
{ name: 'EnvironmentWork', type: 'varchar(200)', nullable: true, identity: false, primaryKey: false },
]),
).toBe('CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)')
).toBe(
'CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)',
)
})
test('给已有的表加列', () => {
expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe('ALTER TABLE [T] ADD [memo] nvarchar(50) NULL')
expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe(
'ALTER TABLE [T] ADD [memo] nvarchar(50) NULL',
)
})
})
test('消息栏和 SSMS 一致', () => {
expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true }])
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual(['(3 行受影响)', '(1 行受影响)'])
expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([
{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true },
])
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual([
'(3 行受影响)',
'(1 行受影响)',
])
expect(messages({ type: 'result', database: 'db', results: [], affected: -1, ms: 1 }).map((m) => m.text)).toEqual(['命令已成功完成。'])
})
+25 -5
View File
@@ -27,8 +27,7 @@ export interface ResultSet {
}
export type QueryReply =
| { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number }
| { type: 'error'; number: number; message: string; line: number }
{ type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number } | { type: 'error'; number: number; message: string; line: number }
export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db')
export const runQuery = (database: string, sql: string) => api<QueryReply>('POST', '/api/db/query', { database, sql })
@@ -89,9 +88,30 @@ export interface DesignColumn {
/** 设计器"数据类型"下拉里的常用类型(和 SSMS 一样也可以直接输入,如 varchar(20)) */
export const COMMON_TYPES = [
'int', 'bigint', 'smallint', 'tinyint', 'bit', 'float', 'real', 'decimal(18, 0)', 'money',
'char(10)', 'varchar(50)', 'varchar(MAX)', 'nchar(10)', 'nvarchar(50)', 'nvarchar(MAX)', 'text', 'ntext',
'date', 'datetime', 'smalldatetime', 'time(7)', 'uniqueidentifier', 'varbinary(MAX)', 'image',
'int',
'bigint',
'smallint',
'tinyint',
'bit',
'float',
'real',
'decimal(18, 0)',
'money',
'char(10)',
'varchar(50)',
'varchar(MAX)',
'nchar(10)',
'nvarchar(50)',
'nvarchar(MAX)',
'text',
'ntext',
'date',
'datetime',
'smalldatetime',
'time(7)',
'uniqueidentifier',
'varbinary(MAX)',
'image',
]
export function columnDefinition(column: DesignColumn) {
+5 -2
View File
@@ -33,7 +33,10 @@ function nextName(prefix: string, taken: string[]) {
function addSeries() {
const d = draft.value
d.series.push({
name: nextName('Series', d.series.map((s) => s.name)),
name: nextName(
'Series',
d.series.map((s) => s.name),
),
chartType: 'Column',
chartArea: d.areas[0] ?? '',
legend: d.legends[0] ?? '',
@@ -93,7 +96,7 @@ function save() {
</section>
<section v-if="series" class="fields">
<label title="Name:代码里用 chart1.Series[&quot;名称&quot;] 访问这个系列">名称</label>
<label title='Name:代码里用 chart1.Series["名称"] 访问这个系列'>名称</label>
<input v-model="series.name" />
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
<select v-model="series.chartType">
+76 -19
View File
@@ -172,7 +172,12 @@ const surface = ref<HTMLElement>()
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
const ZOOM_KEY = 'iot-sim:designer-zoom'
const ZOOM_OPTIONS: [ZoomChoice, string][] = [['fit', '适应宽度'], ['1', '100%'], ['0.75', '75%'], ['0.5', '50%']]
const ZOOM_OPTIONS: [ZoomChoice, string][] = [
['fit', '适应宽度'],
['1', '100%'],
['0.75', '75%'],
['0.5', '50%'],
]
const zoomChoice = ref<ZoomChoice>(readZoom())
function readZoom(): ZoomChoice {
try {
@@ -325,9 +330,25 @@ function onKeyDown(event: KeyboardEvent) {
// ---------- 工具箱 ----------
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
const ICONS: Record<string, string> = {
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
按钮: '▭',
标签: 'A',
文本框: '▤',
下拉框: '☰',
列表框: '≣',
图片框: '🖼',
日期时间选择器: '📅',
滑动条: '⊶',
复选框: '☑',
单选按钮: '◉',
分组框: '⬚',
面板: '▢',
数据表格: '▦',
图表: '📈',
定时器: '⏱',
媒体播放器: '🔊',
'LED 显示屏': '▬',
灯泡: '💡',
报警灯: '🚨',
}
const DRAG_TYPE = 'application/x-winforms-control'
@@ -437,6 +458,11 @@ function onDoubleClick(event: MouseEvent) {
openEvent(hit, def.defaultEvent)
}
function deleteComponent(name: string) {
commit(removeControl(parsed.value.doc!, name))
selected.value = ''
}
function onComponentDoubleClick(name: string) {
const d = parsed.value.doc
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
@@ -475,7 +501,8 @@ const noop = () => {}
@dragstart="onToolDragStart($event, def)"
@dblclick="onToolDoubleClick(def)"
>
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span>{{ def.label }}
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span
>{{ def.label }}
</div>
</template>
</aside>
@@ -495,9 +522,7 @@ const noop = () => {}
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
</option>
</select>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">
查看生成的代码
</button>
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">查看生成的代码</button>
</div>
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
<div class="titlebar" @pointerdown="selected = ''">
@@ -543,7 +568,7 @@ const noop = () => {}
:class="{ active: selected === c.name }"
@click="selected = c.name"
@dblclick="onComponentDoubleClick(c.name)"
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
@keydown.delete="deleteComponent(c.name)"
>
⏱ {{ c.name }}
</button>
@@ -694,7 +719,7 @@ const noop = () => {}
height: 30px;
padding: 0 8px;
background: #fff;
font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif;
font-family: 'Microsoft YaHei', 'PingFang SC', 'Noto Sans CJK SC', sans-serif;
font-size: 12px;
user-select: none;
}
@@ -707,7 +732,7 @@ const noop = () => {}
.client {
position: relative;
overflow: hidden;
font-family: "SimSun", "Songti SC", "Noto Serif CJK SC", "Noto Serif SC", serif;
font-family: 'SimSun', 'Songti SC', 'Noto Serif CJK SC', 'Noto Serif SC', serif;
font-size: 12px;
}
@@ -734,14 +759,46 @@ const noop = () => {}
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; }
.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;
+131 -40
View File
@@ -56,7 +56,7 @@ function set(prop: PropDef, value: Value) {
/** 图片属性:项目资源里的名字,没有设置时为空 */
function imageOf(prop: PropDef) {
const text = getPropText(props.doc, props.selected, prop.name)
return text === undefined ? '' : resourceNameOf(text) ?? text
return text === undefined ? '' : (resourceNameOf(text) ?? text)
}
function setImage(prop: PropDef, choice: string, select: HTMLSelectElement) {
@@ -77,10 +77,14 @@ function reset(prop: PropDef) {
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 })
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()
@@ -151,13 +155,24 @@ function setFont(prop: PropDef, change: Partial<{ family: string; size: number;
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'))
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 !== ''))
emit(
'setItems',
props.selected,
text
.split('\n')
.map((s) => s.trimEnd())
.filter((s) => s !== ''),
)
}
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
@@ -193,35 +208,80 @@ function renameHandler(event: string, value: string) {
<div class="editor">
<code v-if="prop.type !== 'image' && prop.type !== 'chart' && 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 })" />
<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 })" />
<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)" />
<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' })">
<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) })" />
<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) })" />
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) })" />
宽
<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 })">
<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>
@@ -232,39 +292,70 @@ function renameHandler(event: string, value: string) {
<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)" />
<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)">
<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>
<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>
<span v-else-if="prop.type === 'chart'" class="chart-summary">
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`">{{ chartModel.series.length }} 个系列</span>
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`"
>{{ chartModel.series.length }} 个系列</span
>
<button @click="chartOpen = true">编辑…</button>
</span>
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
<textarea
v-else-if="prop.type === 'items'"
rows="4"
:value="itemsText"
placeholder="每行一项"
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)"
/>
<select v-else-if="prop.type === 'image'" :value="imageOf(prop)"
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
<select
v-else-if="prop.type === 'image'"
:value="imageOf(prop)"
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)"
>
<option value="">(无)</option>
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</option>
<option v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
<option value="+">添加资源…</option>
</select>
</div>
</div>
</div>
@@ -358,7 +449,7 @@ function renameHandler(event: string, value: string) {
min-width: 0;
}
.editor input:not([type="checkbox"]):not([type="color"]),
.editor input:not([type='checkbox']):not([type='color']),
.editor select,
.editor textarea {
width: 100%;
@@ -394,7 +485,7 @@ function renameHandler(event: string, value: string) {
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
}
.color input[type="color"] {
.color input[type='color'] {
flex: none;
width: 24px;
height: 22px;
+8 -3
View File
@@ -36,14 +36,19 @@ function listen(file: string) {
function save() {
// 按素材库的顺序保存,生成的代码稳定
emit('save', gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)))
emit(
'save',
gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)),
)
emit('update:show', false)
}
</script>
<template>
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
<p class="note">
勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。
</p>
<div class="items">
<!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
<div v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }" @click="toggle(item.name, !chosen.has(item.name))">
@@ -123,7 +128,7 @@ function save() {
}
.name {
font-family: Consolas, "Courier New", monospace;
font-family: Consolas, 'Courier New', monospace;
font-size: 13px;
}
+232 -60
View File
@@ -75,9 +75,15 @@ const COMMON_PROPS: PropDef[] = [
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
{
name: 'Dock', label: '停靠', description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方', type: 'enum', enumType: `${FORMS}.DockStyle`,
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'], optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
default: enumValue(`${FORMS}.DockStyle`, 'None'), category: '布局',
name: 'Dock',
label: '停靠',
description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方',
type: 'enum',
enumType: `${FORMS}.DockStyle`,
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'],
optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
default: enumValue(`${FORMS}.DockStyle`, 'None'),
category: '布局',
},
]
@@ -85,9 +91,15 @@ const COMMON_PROPS: PropDef[] = [
const BACKGROUND_PROPS: PropDef[] = [
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
{
name: 'BackgroundImageLayout', label: '背景排列', description: '背景图片怎样摆放', type: 'enum', enumType: `${FORMS}.ImageLayout`,
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'], optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'), category: '外观',
name: 'BackgroundImageLayout',
label: '背景排列',
description: '背景图片怎样摆放',
type: 'enum',
enumType: `${FORMS}.ImageLayout`,
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'],
optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'),
category: '外观',
},
]
@@ -112,7 +124,6 @@ export function textWidth(text: string) {
return width
}
export const CONTROLS: ControlDef[] = [
{
type: `${FORMS}.Button`,
@@ -122,7 +133,11 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Click',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(75, 23)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
initial: (name) => [
['Size', size(75, 23)],
['Text', str(name)],
['UseVisualStyleBackColor', bool(true)],
],
},
{
type: `${FORMS}.Label`,
@@ -130,12 +145,27 @@ export const CONTROLS: ControlDef[] = [
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: '外观',
}],
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)]],
initial: (name) => [
['AutoSize', bool(true)],
['Size', size(textWidth(name) + 5, 12)],
['Text', str(name)],
],
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
},
{
@@ -151,8 +181,15 @@ export const CONTROLS: ControlDef[] = [
{ 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: '外观',
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,
@@ -168,12 +205,22 @@ export const CONTROLS: ControlDef[] = [
...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: '外观',
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)]],
initial: () => [
['FormattingEnabled', bool(true)],
['Size', size(121, 20)],
],
},
{
type: `${FORMS}.ListBox`,
@@ -185,15 +232,23 @@ export const CONTROLS: ControlDef[] = [
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观',
name: 'BorderStyle',
label: '边框',
description: '边框',
type: 'enum',
enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'],
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'),
category: '外观',
},
],
events: [
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
{ name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT },
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }],
initial: () => [
['FormattingEnabled', bool(true)],
['ItemHeight', int(12)],
['Size', size(120, 88)],
],
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
},
{
type: `${FORMS}.PictureBox`,
@@ -206,18 +261,33 @@ export const CONTROLS: ControlDef[] = [
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
...BACKGROUND_PROPS,
{
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
name: 'SizeMode',
label: '显示方式',
description: '图片在图片框里怎样显示',
type: 'enum',
enumType: `${FORMS}.PictureBoxSizeMode`,
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
optionLabels: { Normal: '原样放在左上角', StretchImage: '拉伸填满', AutoSize: '图片框随图片大小', CenterImage: '居中', Zoom: '按比例缩放' },
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'), category: '外观',
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'),
category: '外观',
},
{
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
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.filter((e) => e.name !== 'TextChanged'),
initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]],
initial: () => [
['Size', size(100, 50)],
['TabStop', bool(false)],
],
},
{
type: `${FORMS}.DataGridView`,
@@ -228,19 +298,43 @@ export const CONTROLS: ControlDef[] = [
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'),
{ name: 'ReadOnly', label: '只读', description: '只读:不能在表格里修改数据', type: 'bool', default: bool(false), category: '行为' },
{ name: 'AllowUserToAddRows', label: '允许添加行', description: '最下面显示一行空行,可以在里面输入新数据', type: 'bool', default: bool(true), category: '行为' },
{
name: 'AllowUserToAddRows',
label: '允许添加行',
description: '最下面显示一行空行,可以在里面输入新数据',
type: 'bool',
default: bool(true),
category: '行为',
},
{ name: 'AllowUserToDeleteRows', label: '允许删除行', description: '允许删除行', type: 'bool', default: bool(true), category: '行为' },
{ name: 'RowHeadersVisible', label: '显示行标题', description: '显示最左边的行标题列', type: 'bool', default: bool(true), category: '外观' },
{
name: 'SelectionMode', label: '选择方式', description: '点单元格时选中什么', type: 'enum', enumType: `${FORMS}.DataGridViewSelectionMode`,
name: 'SelectionMode',
label: '选择方式',
description: '点单元格时选中什么',
type: 'enum',
enumType: `${FORMS}.DataGridViewSelectionMode`,
options: ['CellSelect', 'FullRowSelect', 'FullColumnSelect', 'RowHeaderSelect', 'ColumnHeaderSelect'],
optionLabels: { CellSelect: '单元格', FullRowSelect: '整行', FullColumnSelect: '整列', RowHeaderSelect: '单元格(点行标题选整行)', ColumnHeaderSelect: '单元格(点列标题选整列)' },
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'), category: '行为',
optionLabels: {
CellSelect: '单元格',
FullRowSelect: '整行',
FullColumnSelect: '整列',
RowHeaderSelect: '单元格(点行标题选整行)',
ColumnHeaderSelect: '单元格(点列标题选整列)',
},
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'),
category: '行为',
},
{
name: 'AutoSizeColumnsMode', label: '列宽', description: '列宽怎样自动调整', type: 'enum', enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
options: ['None', 'Fill'], optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'), category: '布局',
name: 'AutoSizeColumnsMode',
label: '列宽',
description: '列宽怎样自动调整',
type: 'enum',
enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
options: ['None', 'Fill'],
optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'),
category: '布局',
},
],
events: [
@@ -251,7 +345,10 @@ export const CONTROLS: ControlDef[] = [
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
{ name: 'SelectionChanged', label: '选中改变', ...EVENT },
],
initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]],
initial: () => [
['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')],
['Size', size(240, 150)],
],
},
{
type: 'System.Windows.Forms.DataVisualization.Charting.Chart',
@@ -265,7 +362,10 @@ export const CONTROLS: ControlDef[] = [
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
],
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
initial: (name) => [['Size', size(300, 300)], ['Text', str(name)]],
initial: (name) => [
['Size', size(300, 300)],
['Text', str(name)],
],
},
{
type: `${FORMS}.DateTimePicker`,
@@ -276,9 +376,15 @@ export const CONTROLS: ControlDef[] = [
props: [
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
{
name: 'Format', label: '格式', description: '日期和时间的显示格式', type: 'enum', enumType: `${FORMS}.DateTimePickerFormat`,
options: ['Long', 'Short', 'Time', 'Custom'], optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'), category: '外观',
name: 'Format',
label: '格式',
description: '日期和时间的显示格式',
type: 'enum',
enumType: `${FORMS}.DateTimePickerFormat`,
options: ['Long', 'Short', 'Time', 'Custom'],
optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'),
category: '外观',
},
{ name: 'CustomFormat', label: '自定义格式', description: '格式为"自定义"时使用,如 yyyy-MM-dd HH:mm', type: 'string', category: '外观' },
{ name: 'ShowUpDown', label: '上下箭头', description: '用上下箭头调整,代替下拉日历', type: 'bool', default: bool(false), category: '外观' },
@@ -301,14 +407,26 @@ export const CONTROLS: ControlDef[] = [
{ name: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
{
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
name: 'TickStyle',
label: '刻度位置',
description: '刻度画在哪一边',
type: 'enum',
enumType: `${FORMS}.TickStyle`,
options: ['None', 'TopLeft', 'BottomRight', 'Both'],
optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'),
category: '外观',
},
{
name: 'Orientation', label: '方向', description: '横向或竖向', type: 'enum', enumType: `${FORMS}.Orientation`,
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
name: 'Orientation',
label: '方向',
description: '横向或竖向',
type: 'enum',
enumType: `${FORMS}.Orientation`,
options: ['Horizontal', 'Vertical'],
optionLabels: { Horizontal: '横向', Vertical: '竖向' },
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'),
category: '外观',
},
],
events: [
@@ -326,7 +444,12 @@ export const CONTROLS: ControlDef[] = [
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)]],
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 }),
},
{
@@ -337,7 +460,12 @@ export const CONTROLS: ControlDef[] = [
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)]],
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 }),
},
{
@@ -349,7 +477,11 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Enter',
props: [...COMMON_PROPS],
events: COMMON_EVENTS,
initial: (name) => [['Size', size(200, 100)], ['TabStop', bool(false)], ['Text', str(name)]],
initial: (name) => [
['Size', size(200, 100)],
['TabStop', bool(false)],
['Text', str(name)],
],
},
{
type: `${FORMS}.Panel`,
@@ -371,7 +503,14 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Tick',
props: [
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
{ name: 'Enabled', label: '启用', description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()', type: 'bool', default: bool(false), category: '行为' },
{
name: 'Enabled',
label: '启用',
description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()',
type: 'bool',
default: bool(false),
category: '行为',
},
],
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
initial: () => [],
@@ -384,7 +523,10 @@ export const CONTROLS: ControlDef[] = [
defaultEvent: 'Enter',
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
initial: () => [
['Enabled', bool(true)],
['Size', size(245, 240)],
],
},
{
type: 'WinFormsDeviceControlLibrary.LED',
@@ -404,7 +546,10 @@ export const CONTROLS: ControlDef[] = [
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)]],
initial: () => [
['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }],
['Size', size(105, 185)],
],
},
{
type: 'WinFormsDeviceControlLibrary.Alarm',
@@ -433,16 +578,40 @@ export const FORM_DEF: ControlDef = {
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', category: '布局' },
{
name: 'FormBorderStyle', label: '边框样式', description: '边框样式', type: 'enum', enumType: `${FORMS}.FormBorderStyle`,
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: '外观',
optionLabels: {
None: '无边框',
FixedSingle: '固定单线',
Fixed3D: '固定立体',
FixedDialog: '对话框',
Sizable: '可调整大小',
FixedToolWindow: '固定工具窗口',
SizableToolWindow: '可调整工具窗口',
},
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'),
category: '外观',
},
{
name: 'StartPosition', label: '启动位置', description: '启动位置', type: 'enum', enumType: `${FORMS}.FormStartPosition`,
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: '布局',
optionLabels: {
Manual: '手动指定',
CenterScreen: '屏幕中央',
WindowsDefaultLocation: '系统默认位置',
WindowsDefaultBounds: '系统默认位置和大小',
CenterParent: '父窗体中央',
},
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'),
category: '布局',
},
...BACKGROUND_PROPS,
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
@@ -468,7 +637,10 @@ export function controlDef(type: string): ControlDef {
type,
label: type.split('.').at(-1)!,
group: '常用控件',
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
namePrefix: type
.split('.')
.at(-1)!
.replace(/^./, (c) => c.toLowerCase()),
defaultEvent: 'Click',
props: COMMON_PROPS,
events: COMMON_EVENTS,
+22 -3
View File
@@ -42,8 +42,26 @@ describe('图表的系列和图例', () => {
areas: ['ChartArea1'],
legends: ['MaxLegend', 'AVGLegend'],
series: [
{ name: 'MaxSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'MaxLegend', legendText: '最大值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'MaxTemperature' },
{ name: 'AVGSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'AVGLegend', legendText: '平均值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'AVGTemperature' },
{
name: 'MaxSeries',
chartType: 'Line',
chartArea: 'ChartArea1',
legend: 'MaxLegend',
legendText: '最大值',
isVisibleInLegend: true,
xValueMember: '_Time',
yValueMembers: 'MaxTemperature',
},
{
name: 'AVGSeries',
chartType: 'Line',
chartArea: 'ChartArea1',
legend: 'AVGLegend',
legendText: '平均值',
isVisibleInLegend: true,
xValueMember: '_Time',
yValueMembers: 'AVGTemperature',
},
],
})
})
@@ -70,7 +88,8 @@ describe('图表的系列和图例', () => {
const { doc, name } = addControl(parseDesigner(EMPTY_FORM), def, '', { x: 10, y: 20 })
expect(name).toBe('chart1')
const out = generateDesigner(doc)
expect(out).toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
expect(out)
.toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
System.Windows.Forms.DataVisualization.Charting.Legend legend1 = new System.Windows.Forms.DataVisualization.Charting.Legend();
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
this.chart1 = new System.Windows.Forms.DataVisualization.Charting.Chart();`)
+12 -1
View File
@@ -51,7 +51,18 @@ export const CHART_TYPES: Record<string, string> = {
export const DEFAULT_CHART: ChartModel = {
areas: ['ChartArea1'],
legends: ['Legend1'],
series: [{ name: 'Series1', chartType: 'Column', chartArea: 'ChartArea1', legend: 'Legend1', legendText: '', isVisibleInLegend: true, xValueMember: '', yValueMembers: '' }],
series: [
{
name: 'Series1',
chartType: 'Column',
chartArea: 'ChartArea1',
legend: 'Legend1',
legendText: '',
isVisibleInLegend: true,
xValueMember: '',
yValueMembers: '',
},
],
}
interface ChartObject {
+4 -8
View File
@@ -9,13 +9,7 @@ test('插入事件处理方法:放在类的最后,与上一个方法空一
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.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', '', ' }'])
// 方法的 "}" 之后就是类的 "}"
expect(lines[line + 1]).toBe(' }')
})
@@ -30,7 +24,9 @@ test('方法已经存在:不重复插入,只返回它的位置', async () =>
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(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)
})
+1 -7
View File
@@ -20,13 +20,7 @@ export function ensureHandler(code: string, className: string, handler: string,
// 类里已有成员时,与上一个成员之间空一行
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 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) }
}
+1 -4
View File
@@ -18,10 +18,7 @@ async function designerFiles(dir: string) {
}
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
const files = [
...(await designerFiles(resolve(ROOT, 'samples'))),
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
]
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()
+18 -15
View File
@@ -19,9 +19,7 @@ export function listControls(doc: DesignerDocument): ControlInfo[] {
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 }] : [],
)
return doc.prologue.flatMap((stmt) => (stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : []))
}
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
@@ -133,7 +131,9 @@ export function setEvent(doc: DesignerDocument, target: string, event: string, d
}
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(' '),
'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,否则返回原因 */
@@ -206,11 +206,7 @@ export function removeControl(doc: DesignerDocument, name: string): DesignerDocu
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
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))
)
!((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))
@@ -229,7 +225,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
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 }
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
@@ -240,7 +238,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
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
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)
@@ -274,7 +274,12 @@ export function moveControl(doc: DesignerDocument, name: string, parent: string,
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
*/
export function syncLayout(doc: DesignerDocument) {
const containers = ['', ...listControls(doc).filter((c) => isContainerType(c.type)).map((c) => c.name)]
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 === ''
@@ -331,9 +336,7 @@ function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout'
} 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 === ''))
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)
}
}
+3 -4
View File
@@ -34,9 +34,7 @@ function dockOf(doc: DesignerDocument, name: string) {
function paddingOf(doc: DesignerDocument, name: string) {
const m = /Padding\((\d+)(?:,\s*(\d+),\s*(\d+),\s*(\d+))?\)/.exec(getPropText(doc, name, 'Padding') ?? '')
if (!m) return { left: 0, top: 0, right: 0, bottom: 0 }
return m[2] === undefined
? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] }
: { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
return m[2] === undefined ? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] } : { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
}
/** 容器里用来停靠的区域:客户区去掉内边距;带边框的面板去掉边框,分组框去掉边框和标题 */
@@ -63,7 +61,8 @@ export function dockLayout(doc: DesignerDocument): DesignerDocument {
const point = pointOf(next, name)
if (point.x !== bounds.x || point.y !== bounds.y) next = setProp(next, name, 'Location', { kind: 'point', x: bounds.x, y: bounds.y })
const size = sizeOf(next, name)
if (size?.width !== bounds.width || size?.height !== bounds.height) next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
if (size?.width !== bounds.width || size?.height !== bounds.height)
next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
}
const layout = (container: string) => {
+22 -7
View File
@@ -61,7 +61,11 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
/** 教材控件在 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.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' },
}
@@ -73,17 +77,26 @@ export function previewTree(doc: DesignerDocument): ControlNode {
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
props.Visible = true
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox') props.Items = getItems(doc, name).map((item) => {
const v = parseValue(item)
return v.kind === 'string' ? v.value : item
})
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox')
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 = ''
// 图表和 VS 一样用示例数据画出各个系列
if (type === CHART_TYPE) props.Chart = previewChart(readChart(doc, name))
// 和 VS 一样显示今天的日期
if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long'))
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
return {
id: name,
type: clientType(type),
name,
props,
children: childrenOf(doc, name)
.filter((c) => types.has(c))
.map(build),
}
}
const formProps = propsOf(doc, '')
return {
@@ -91,7 +104,9 @@ export function previewTree(doc: DesignerDocument): ControlNode {
type: 'Form',
name: doc.formName,
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
children: childrenOf(doc, '').filter((c) => types.has(c)).map(build),
children: childrenOf(doc, '')
.filter((c) => types.has(c))
.map(build),
}
}
+26 -5
View File
@@ -3,10 +3,24 @@ import { expect, test } from 'bun:test'
import { generateDesigner, parseDesigner } from './document'
import { addControl } from './edit'
import { controlDef } from './catalog'
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from './templates'
import {
assignmentTemplate,
classPathError,
formNameTaken,
newClassFile,
newFormFiles,
nextFormName,
projectNamespace,
renameForm,
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: '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' },
]
@@ -68,10 +82,14 @@ test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
})
test('新建类:可以带文件夹,文件夹也是命名空间的一段;名字不能重复', () => {
const files = [{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' }, { path: 'AppSet.cs', content: 'class AppSet { }' }]
const files = [
{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' },
{ path: 'AppSet.cs', content: 'class AppSet { }' },
]
expect(newClassFile('智慧校园', 'SQLHelpers/SQLHelper')).toEqual({
path: 'SQLHelpers/SQLHelper.cs',
content: 'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
content:
'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
})
expect(newClassFile('智慧校园', 'RecordClass.cs').path).toBe('RecordClass.cs')
expect(classPathError(files, 'SQLHelpers/SQLBLL')).toBeNull()
@@ -84,7 +102,10 @@ test('新建类:可以带文件夹,文件夹也是命名空间的一段;
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
const code = blank.find((f) => f.path === 'Form1.cs')!
code.content = code.content.replace('InitializeComponent();', 'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();')
code.content = code.content.replace(
'InitializeComponent();',
'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();',
)
const files = renameForm(blank, 'Form1.Designer.cs', 'Main')
expect(files.map((f) => f.path).sort()).toEqual(['Main.Designer.cs', 'Main.cs', 'Program.cs'])
expect(files.find((f) => f.path === 'Program.cs')!.content).toContain('Application.Run(new Main())')
+4 -1
View File
@@ -21,7 +21,10 @@ export function nextFormName(files: SourceFile[]): string {
/** 窗体名是否已被使用(同名文件或同名类) */
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))
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),
)
}
/**
+3 -6
View File
@@ -88,12 +88,9 @@ export function renderValue(value: Value): string {
? `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 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})))`
+10 -9
View File
@@ -73,7 +73,7 @@ function applyMarkers() {
startColumn: d.column,
endLineNumber: d.line,
// 编译器只给出起点;标到该行的下一个词结尾,至少一个字符
endColumn: (model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1),
endColumn: model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1,
}))
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
}
@@ -134,11 +134,14 @@ onMounted(() => {
applyMarkers()
})
watch(() => props.files, () => {
syncModels()
showActive()
applyMarkers()
})
watch(
() => props.files,
() => {
syncModels()
showActive()
applyMarkers()
},
)
watch(() => props.activeFile, showActive)
watch(
() => props.diagnostics,
@@ -158,9 +161,7 @@ onBeforeUnmount(() => {
<template>
<div class="code-editor">
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
</div>
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。</div>
<div ref="container" class="monaco-host" />
</div>
</template>
+10 -12
View File
@@ -84,18 +84,16 @@ export function registerLanguageFeatures() {
if (!result) return { suggestions: [] }
const range = toRange(result.range)
return {
suggestions: result.items.map(
(item, index): Item => ({
index,
label: item.label,
insertText: item.insert,
filterText: item.filter,
sortText: item.sort,
kind: KINDS[item.kind] ?? Kind.Text,
detail: item.detail || undefined,
range,
}),
),
suggestions: result.items.map((item, index): Item => ({
index,
label: item.label,
insertText: item.insert,
filterText: item.filter,
sortText: item.sort,
kind: KINDS[item.kind] ?? Kind.Text,
detail: item.detail || undefined,
range,
})),
}
},
// 选中某一项时再取它的签名和说明
+7 -3
View File
@@ -20,7 +20,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
<div class="grade">
<div v-if="submission.status === 'pending'" class="pending">
<NSpin size="small" />
<span>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span>
<span
>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span
>
</div>
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
<template v-else-if="submission.result">
@@ -47,7 +49,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
</div>
<ul class="items">
<li v-for="(m, i) in manual" :key="i">
<span class="title">{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span>
<span class="title"
>{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span
>
</li>
</ul>
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
@@ -138,6 +142,6 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
}
.manual span + span::before {
content: "、";
content: '、';
}
</style>
+2 -7
View File
@@ -85,8 +85,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
<template v-if="titles.length">
<h4 class="section">评价项</h4>
<p class="note">
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果, 告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
</p>
<div class="actions">
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
@@ -101,11 +100,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
<ul class="items">
<li
v-for="(title, i) in titles"
:key="i"
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
>
<li v-for="(title, i) in titles" :key="i" :class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''">
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
<div>
<div class="title">
+5 -3
View File
@@ -18,13 +18,15 @@ onMounted(async () => {
loaded.value = true
})
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
function status(row: Row) {
const s = row.latest
if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交')
if (!s)
return row.deadline && Date.now() > row.deadline
? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止')
: h(NTag, { size: 'small' }, () => '未提交')
if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
if (row.manualScore !== null) {
+6 -11
View File
@@ -24,9 +24,7 @@ import { currentUser } from '../auth'
<h2>学生</h2>
<h3>1. 登录</h3>
<p>
用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。
</p>
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
<h3>2. 自由练习和做作业</h3>
<ul>
@@ -66,10 +64,11 @@ import { currentUser } from '../auth'
<h3>6. 自己检查、提交</h3>
<ul>
<li><b>任务</b>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
<li>
做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
<b>任务</b
>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
</li>
<li>做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。</li>
<li>点工具栏上的分数,看每一项的得分;老师评阅后,这里能看到老师的打分和评语。</li>
</ul>
@@ -111,9 +110,7 @@ import { currentUser } from '../auth'
<h3>4. 上课前后</h3>
<ul>
<li>
第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。
</li>
<li>第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。</li>
<li>上课时间不要更新平台(推送代码):更新会重启平台,正在运行的学生程序会结束(页面会提示并自动重连)。</li>
<li>全班同时"自己检查"或提交时要排队,项目 8 这种大任务最后一个人大约等 1.5 分钟。</li>
<li>重要节点前后备份数据:在服务器上执行 <code>deploy/backup.sh</code>(说明见 deploy/README.md)。</li>
@@ -126,9 +123,7 @@ import { currentUser } from '../auth'
<dt>窗体上的按钮点了没反应</dt>
<dd>看运行区有没有显示"与服务器的连接断开了"。断开后程序已经结束,等它自动连上,再按 F5 重新运行。</dd>
<dt>代码改乱了,想重来</dt>
<dd>
工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。
</dd>
<dd>工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。</dd>
<dt>登录时提示密码输错太多次</dt>
<dd>同一个账号连续输错 10 次要等 10 分钟,或者请老师在名单里查看、重置密码。</dd>
<dt>网页打不开、一片空白</dt>
+1 -7
View File
@@ -34,13 +34,7 @@ async function submit() {
<h1>物联网编程仿真平台</h1>
<p>用老师发给你的账号和密码登录</p>
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
<NInput
v-model:value="password"
type="password"
show-password-on="click"
placeholder="密码"
:input-props="{ autocomplete: 'current-password' }"
/>
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
<div v-if="error" class="error">{{ error }}</div>
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
<RouterLink to="/help" target="_blank" class="help">第一次使用?看使用说明</RouterLink>
+13 -7
View File
@@ -237,8 +237,7 @@ watch(selectedId, (_, old) => {
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
})
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success' | 'info'; text: string } {
if (!row.status) return { type: 'default', text: '未提交' }
@@ -262,7 +261,15 @@ function exportCsv() {
const a = assignment.value
if (!a) return
downloadCsv(`${a.title}-成绩.csv`, [
['学号', '姓名', `自动评分(${autoTotal.value})`, ...a.scoring.manual.map((m) => `${m.title}(${m.points})`), `总分(${autoTotal.value + manualTotal.value})`, '提交次数', '评语'],
[
'学号',
'姓名',
`自动评分(${autoTotal.value})`,
...a.scoring.manual.map((m) => `${m.title}(${m.points})`),
`总分(${autoTotal.value + manualTotal.value})`,
'提交次数',
'评语',
],
...rows.value.map((r) => [
`\t${r.username}`,
r.name,
@@ -375,7 +382,8 @@ async function signOut() {
<template v-else-if="detail.aiReview.status === 'done'">
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
<span class="score-title">{{ m.title }}</span>
<b>{{ detail.aiReview.scores[i]?.score }}</b><span class="muted"> / {{ m.points }}</span>
<b>{{ detail.aiReview.scores[i]?.score }}</b
><span class="muted"> / {{ m.points }}</span>
<div class="ai-reason">{{ detail.aiReview.scores[i]?.reason }}</div>
</div>
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
@@ -401,9 +409,7 @@ async function signOut() {
</div>
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
<div class="save-row">
<span class="muted">
总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }}
</span>
<span class="muted"> 总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }} </span>
<span class="spacer" />
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
+14 -20
View File
@@ -53,9 +53,7 @@ async function loadClasses() {
}
async function loadStudents() {
students.value = selectedId.value
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
: []
students.value = selectedId.value ? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? []) : []
}
async function selectClass(id: number) {
@@ -173,18 +171,14 @@ async function confirmImport() {
else
dialog.warning({
title: '部分学生没有导入',
content: () =>
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
content: () => h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
positiveText: '知道了',
})
}
function exportRoster() {
if (!selected.value) return
downloadCsv(`${selected.value.name}-名单.csv`, [
['学号', '姓名', '密码'],
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
])
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])])
}
function downloadTemplate() {
@@ -226,13 +220,7 @@ async function signOut() {
</form>
</NPopover>
</div>
<button
v-for="c in classes"
:key="c.id"
class="class-item"
:class="{ active: c.id === selectedId }"
@click="selectClass(c.id)"
>
<button v-for="c in classes" :key="c.id" class="class-item" :class="{ active: c.id === selectedId }" @click="selectClass(c.id)">
<span>{{ c.name }}</span>
<span class="count">{{ c.studentCount }} 人</span>
</button>
@@ -267,7 +255,8 @@ async function signOut() {
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
</div>
<p class="tip">
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
</p>
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
</NTabPane>
@@ -292,14 +281,19 @@ async function signOut() {
@update:show="(v: boolean) => !v && (preview = null)"
>
<p>
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length"
>, <span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
>。
</p>
<div class="preview">
<table>
<thead>
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
<tr>
<th>学号</th>
<th>姓名</th>
<th>密码</th>
<th />
</tr>
</thead>
<tbody>
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
+31 -25
View File
@@ -10,7 +10,17 @@ import FormDesigner from '../designer/FormDesigner.vue'
import { createDesignerHistory } from '../designer/history'
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
import ResourcesDialog from '../designer/ResourcesDialog.vue'
import { classPathError, formNameTaken, isFormDesigner, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from '../designer/templates'
import {
classPathError,
formNameTaken,
isFormDesigner,
newClassFile,
newFormFiles,
nextFormName,
projectNamespace,
renameForm,
startupForm,
} from '../designer/templates'
import CodeEditor from '../editor/CodeEditor.vue'
import { api } from '../api'
import { currentUser, logout } from '../auth'
@@ -36,11 +46,13 @@ const DESIGN = '设计:'
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
const forms = computed(() => {
const startup = startupForm(files.value)
return files.value
.filter((f) => isFormDesigner(f.path))
.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`))
return (
files.value
.filter((f) => isFormDesigner(f.path))
.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`))
)
})
/**
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
@@ -79,7 +91,9 @@ const message = useMessage()
const workKey = ref<string>()
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
const 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')
/**
@@ -165,7 +179,6 @@ const taskPanel = computed(() => {
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
})
// ---------- 作业 ----------
const assignment = ref<Assignment | null>(null)
const submission = ref<Submission | null>(null)
@@ -265,8 +278,7 @@ function pollSubmission() {
}
onBeforeUnmount(() => clearTimeout(pollTimer))
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
// ---------- 自动保存到服务器 ----------
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
@@ -510,13 +522,7 @@ function run() {
</template>
<template v-else>
<strong>物联网编程仿真平台</strong>
<NSelect
class="sample-select"
:value="sampleId"
:options="samples"
placeholder="选择示例工程"
@update:value="loadSample"
/>
<NSelect class="sample-select" :value="sampleId" :options="samples" placeholder="选择示例工程" @update:value="loadSample" />
</template>
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
@@ -546,8 +552,7 @@ function run() {
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
<span v-if="!assignmentId && !viewing && currentUser?.role === 'teacher'" class="metrics">
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
{{ state.metrics.lastRoundTripMs }} ms
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返 {{ state.metrics.lastRoundTripMs }} ms
</span>
<span class="spacer" />
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
@@ -616,7 +621,12 @@ function run() {
</template>
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
</NPopconfirm>
<NButton size="small" quaternary title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字" @click="resourcesOpen = true">
<NButton
size="small"
quaternary
title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字"
@click="resourcesOpen = true"
>
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
</NButton>
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
@@ -655,11 +665,7 @@ function run() {
/>
</section>
<button
class="run-toggle"
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
@click="toggleRunArea()"
>
<button class="run-toggle" :title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'" @click="toggleRunArea()">
{{ runCollapsed ? '◂' : '▸' }}
</button>
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
+3 -13
View File
@@ -59,10 +59,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<template>
<section class="run-area" :class="{ maximized }">
<div ref="desktop" class="desktop">
<div
class="desktop-canvas"
:style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined"
>
<div class="desktop-canvas" :style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined">
<FormWindow
v-for="(form, index) in state.forms"
:key="form.root.id"
@@ -73,21 +70,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
:act="session.act"
/>
</div>
<MessageBoxDialog
v-for="box in state.messageBoxes"
:key="box.requestId"
:box="box"
@reply="session.replyMessageBox(box.requestId, $event)"
/>
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
</div>
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
<div class="desktop-tools">
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点&quot;放大&quot;可以看得更清楚">
缩放 {{ Math.round(scale * 100) }}%
</span>
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'> 缩放 {{ Math.round(scale * 100) }}% </span>
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
{{ maximized ? '还原' : '放大' }}
</button>
+1 -9
View File
@@ -1,14 +1,6 @@
import { reactive, shallowReactive } from 'vue'
import { validAccessToken } from '../tokens'
import type {
BoardState,
ClientMessage,
ControlNode,
Diagnostic,
ServerMessage,
SourceFile,
UserAction,
} from '@iot/shared'
import type { BoardState, ClientMessage, ControlNode, Diagnostic, ServerMessage, SourceFile, UserAction } from '@iot/shared'
export interface OpenForm {
root: ControlNode
+17 -18
View File
@@ -151,20 +151,18 @@ const cameraTitle = computed(() =>
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
<div class="ceiling">
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
红外对射
</button>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
</div>
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" /></svg>
<svg viewBox="0 0 24 24" width="22" height="22">
<path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" />
</svg>
</div>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
红外对射
</button>
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
</div>
<!-- LED 显示屏 -->
@@ -227,7 +225,14 @@ const cameraTitle = computed(() =>
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
<path v-if="playing" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
<path
v-if="playing"
d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
<div class="card-title">广播</div>
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
@@ -278,22 +283,16 @@ const cameraTitle = computed(() =>
<div class="module">
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
<div class="io-row">
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
<i />DI{{ i }} {{ input.label }}
</span>
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }"> <i />DI{{ i }} {{ input.label }} </span>
</div>
<div class="io-row">
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
<i />DO{{ i }} {{ label }}
</span>
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }"> <i />DO{{ i }} {{ label }} </span>
</div>
</div>
<div class="module ports">
<div class="module-title">串口服务器</div>
<div class="port-list">
<span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span>
</div>
<div class="port-list"><span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span></div>
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
</div>
</div>
@@ -498,7 +497,7 @@ button {
white-space: pre;
color: #ff2a2a;
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
font-family: 'SimHei', 'Noto Sans CJK SC', sans-serif;
font-size: 30px;
line-height: 36px;
animation: led-scroll 12s linear infinite;
+1 -2
View File
@@ -53,8 +53,7 @@ function review(row: Row) {
void router.push(`/teacher/assignments/${row.id}`)
}
const formatTime = (ms: number) =>
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
const columns: DataTableColumns<Row> = [
{ title: '作业', key: 'title' },
+1 -2
View File
@@ -46,8 +46,7 @@ function cell(row: Row, assignmentId: number) {
const c = row.scores[assignmentId]
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
const text =
c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
const text = c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
}
+60 -11
View File
@@ -2,14 +2,46 @@
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
import {
ArcElement, BarController, BarElement, CategoryScale, Chart, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip, type ChartConfiguration,
ArcElement,
BarController,
BarElement,
CategoryScale,
Chart,
DoughnutController,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PieController,
PointElement,
RadarController,
RadialLinearScale,
Title,
Tooltip,
type ChartConfiguration,
} from 'chart.js'
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
import type { ControlNode } from '@iot/shared'
Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, Filler, Legend, LinearScale, LineController,
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip)
Chart.register(
ArcElement,
BarController,
BarElement,
CategoryScale,
DoughnutController,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PieController,
PointElement,
RadarController,
RadialLinearScale,
Title,
Tooltip,
)
interface ChartSeries {
name: string
@@ -33,7 +65,23 @@ interface ChartData {
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
/** WinForms 图表默认的 BrightPastel 配色 */
const PALETTE = ['#418CF0', '#FCB441', '#E0400A', '#056492', '#BFBFBF', '#1A3B69', '#FFE382', '#129CDD', '#CA6B4B', '#005CDB', '#F3D288', '#506381', '#F1B9A8', '#E0830A', '#7893BE']
const PALETTE = [
'#418CF0',
'#FCB441',
'#E0400A',
'#056492',
'#BFBFBF',
'#1A3B69',
'#FFE382',
'#129CDD',
'#CA6B4B',
'#005CDB',
'#F3D288',
'#506381',
'#F1B9A8',
'#E0830A',
'#7893BE',
]
const data = computed(() => props.node.props.Chart as ChartData | undefined)
const canvas = ref<HTMLCanvasElement>()
@@ -84,12 +132,13 @@ function config(d: ChartData): ChartConfiguration {
legend: { display: d.series.some((s) => s.inLegend), position, labels: { filter: (item) => d.series[item.datasetIndex ?? 0]?.inLegend ?? true } },
title: { display: d.titles.length > 0, text: d.titles },
},
scales: pie || chartKind === 'radar'
? {}
: {
x: { stacked, grid: { color: '#d9d9d9' } },
y: { stacked, min: d.yMin ?? undefined, max: d.yMax ?? undefined, beginAtZero: true, grid: { color: '#d9d9d9' } },
},
scales:
pie || chartKind === 'radar'
? {}
: {
x: { stacked, grid: { color: '#d9d9d9' } },
y: { stacked, min: d.yMin ?? undefined, max: d.yMax ?? undefined, beginAtZero: true, grid: { color: '#d9d9d9' } },
},
},
} as ChartConfiguration
}
+2 -12
View File
@@ -95,13 +95,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
<template>
<!-- Button -->
<button
v-if="node.type === 'Button'"
class="wf-control wf-button"
:style="style"
:disabled="!enabled"
@click="act({ action: 'click' })"
>
<button v-if="node.type === 'Button'" class="wf-control wf-button" :style="style" :disabled="!enabled" @click="act({ action: 'click' })">
{{ displayText(p.Text) }}
</button>
@@ -230,11 +224,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
</div>
<!-- 教材控件库:LED 显示屏 -->
<LedControl
v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'"
:node="node"
:style="style"
/>
<LedControl v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'" :node="node" :style="style" />
<!-- 其他容器(Panel、UserControl 等):按普通容器渲染 -->
<div v-else class="wf-control wf-panel" :style="style" :title="node.type">
+7 -7
View File
@@ -53,6 +53,11 @@ function click(row: number, column: number) {
if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' })
}
function doubleClick(row: number, column: number) {
props.act({ action: 'cellDoubleClick', row, column })
startEdit(row, column)
}
function startEdit(row: number, column: number, initial?: string) {
const col = columns.value.find((c) => c.index === column)
if (!props.enabled || !col || col.readOnly || col.check || p.value.ReadOnly === true) return
@@ -137,12 +142,7 @@ function columnStyle(column: Column): CSSProperties {
</tr>
</thead>
<tbody>
<tr
v-for="(row, r) in rows.items"
:key="r"
:class="{ 'wf-dgv-selected': selectedRows.has(r) }"
:style="{ height: `${rows.height}px` }"
>
<tr v-for="(row, r) in rows.items" :key="r" :class="{ 'wf-dgv-selected': selectedRows.has(r) }" :style="{ height: `${rows.height}px` }">
<th v-if="rowHeaders" class="wf-dgv-rowheader" @click="click(r, -1)">
{{ current?.[0] === r ? '▶' : row.isNew ? '*' : '' }}
</th>
@@ -152,7 +152,7 @@ function columnStyle(column: Column): CSSProperties {
:class="{ 'wf-dgv-current': isCurrent(r, column.index) }"
:style="columnStyle(column)"
@click="click(r, column.index)"
@dblclick="act({ action: 'cellDoubleClick', row: r, column: column.index }); startEdit(r, column.index)"
@dblclick="doubleClick(r, column.index)"
>
<input
v-if="editing && editing.row === r && editing.column === column.index"
+1 -2
View File
@@ -33,8 +33,7 @@ function pick(event: Event) {
if (!picked) return
const [date, time] = value.value.split('T')
const withSeconds = (t: string) => (t.length === 5 ? `${t}:00` : t)
const next =
inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
const next = inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
props.act({ action: 'pick', value: next })
}
+4 -1
View File
@@ -26,7 +26,10 @@ const props = defineProps<{
const p = computed(() => props.form.props)
const runImages = inject(RUN_IMAGES, new Map<string, string>())
provide(AMBIENT_BACK_COLOR, computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR))
provide(
AMBIENT_BACK_COLOR,
computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR),
)
const borderless = computed(() => p.value.FormBorderStyle === 'None')
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
const childrenTopLast = computed(() => [...props.form.children].reverse())
+32 -6
View File
@@ -8,13 +8,39 @@ const emit = defineEmits<{ reply: [result: string] }>()
const BUTTONS: Record<string, [string, string][]> = {
OK: [['OK', '确定']],
OKCancel: [['OK', '确定'], ['Cancel', '取消']],
YesNo: [['Yes', '是(Y)'], ['No', '否(N)']],
YesNoCancel: [['Yes', '是(Y)'], ['No', '否(N)'], ['Cancel', '取消']],
RetryCancel: [['Retry', '重试(R)'], ['Cancel', '取消']],
AbortRetryIgnore: [['Abort', '中止(A)'], ['Retry', '重试(R)'], ['Ignore', '忽略(I)']],
OKCancel: [
['OK', '确定'],
['Cancel', '取消'],
],
YesNo: [
['Yes', '是(Y)'],
['No', '否(N)'],
],
YesNoCancel: [
['Yes', '是(Y)'],
['No', '否(N)'],
['Cancel', '取消'],
],
RetryCancel: [
['Retry', '重试(R)'],
['Cancel', '取消'],
],
AbortRetryIgnore: [
['Abort', '中止(A)'],
['Retry', '重试(R)'],
['Ignore', '忽略(I)'],
],
}
const ICONS: Record<string, string> = {
Error: '⛔',
Hand: '⛔',
Stop: '⛔',
Warning: '⚠️',
Exclamation: '⚠️',
Information: 'ℹ️',
Asterisk: 'ℹ️',
Question: '❓',
}
const ICONS: Record<string, string> = { Error: '⛔', Hand: '⛔', Stop: '⛔', Warning: '⚠️', Exclamation: '⚠️', Information: 'ℹ️', Asterisk: 'ℹ️', Question: '❓' }
const buttons = computed(() => BUTTONS[props.box.buttons] ?? BUTTONS.OK)
const icon = computed(() => ICONS[props.box.icon])
+1 -1
View File
@@ -22,7 +22,7 @@ const vertical = computed(() => p.value.Orientation === 'Vertical')
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
/** 轨道方向上的长度 */
const length = computed(() => (vertical.value ? size.value?.height ?? 104 : size.value?.width ?? 104))
const length = computed(() => (vertical.value ? (size.value?.height ?? 104) : (size.value?.width ?? 104)))
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
function position(v: number) {
+1 -2
View File
@@ -28,8 +28,7 @@ export function imageSources(value: unknown, images: ReadonlyMap<string, string>
}
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
export const srcset = (sources: { src: string; hidpi?: string } | undefined) =>
sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined
export const srcset = (sources: { src: string; hidpi?: string } | undefined) => (sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined)
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more