diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..290e2c8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +# bun fmt 不格式化的文件 +# 书上的摘录,保持原样 +docs/教材摘要/ diff --git a/README.md b/README.md index a3d21d8..5effa39 100644 --- a/README.md +++ b/README.md @@ -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 阶段进行中 diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..abe41fe --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,5 @@ +# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160) +[fmt] +printWidth = 160 +semi = false +singleQuote = true diff --git a/compose.yaml b/compose.yaml index b12b6e7..b47703d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/deploy/README.md b/deploy/README.md index c1a6b9d..64316cf 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 不够,定时器来不及触发,正确的程序会被误判 | diff --git a/docs/设计方案.md b/docs/设计方案.md index 8cddafb..ba98a58 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -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 备案 | 办理需要数周 | 已备案 | **待确认** diff --git a/package.json b/package.json index f4bb3f3..31d0b35 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/runtime/Sim.Devices/Camera/build-frames.ts b/runtime/Sim.Devices/Camera/build-frames.ts index 7038c32..504cc84 100644 --- a/runtime/Sim.Devices/Camera/build-frames.ts +++ b/runtime/Sim.Devices/Camera/build-frames.ts @@ -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) diff --git a/runtime/Sim.WinForms/Resources/gallery/index.json b/runtime/Sim.WinForms/Resources/gallery/index.json index e4b331e..6b16f7d 100644 --- a/runtime/Sim.WinForms/Resources/gallery/index.json +++ b/runtime/Sim.WinForms/Resources/gallery/index.json @@ -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" + } ] diff --git a/samples/p1-t1-登录/checks.yaml b/samples/p1-t1-登录/checks.yaml index 1742ea5..ecbe166 100644 --- a/samples/p1-t1-登录/checks.yaml +++ b/samples/p1-t1-登录/checks.yaml @@ -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: 用户名或密码错误 } diff --git a/samples/p1-t1-登录/task.md b/samples/p1-t1-登录/task.md index 8732e08..8b835be 100644 --- a/samples/p1-t1-登录/task.md +++ b/samples/p1-t1-登录/task.md @@ -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`,代码里用的是上面这些名字。请按上面的表起名,自动评分按这些名字操作。 diff --git a/samples/p1-t2-面向对象特性/checks.yaml b/samples/p1-t2-面向对象特性/checks.yaml index 1d58226..6bd8a6e 100644 --- a/samples/p1-t2-面向对象特性/checks.yaml +++ b/samples/p1-t2-面向对象特性/checks.yaml @@ -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: diff --git a/samples/p1-t2-面向对象特性/task.md b/samples/p1-t2-面向对象特性/task.md index 8470227..f2506ff 100644 --- a/samples/p1-t2-面向对象特性/task.md +++ b/samples/p1-t2-面向对象特性/task.md @@ -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,要自己在属性里设置。 diff --git a/samples/p2-t1-LED显示屏/task.md b/samples/p2-t1-LED显示屏/task.md index ac45f78..1a1799a 100644 --- a/samples/p2-t1-LED显示屏/task.md +++ b/samples/p2-t1-LED显示屏/task.md @@ -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`,请按代码起名。 diff --git a/samples/p2-t2-模拟量采集/checks.yaml b/samples/p2-t2-模拟量采集/checks.yaml index be3588f..865c95b 100644 --- a/samples/p2-t2-模拟量采集/checks.yaml +++ b/samples/p2-t2-模拟量采集/checks.yaml @@ -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' } diff --git a/samples/p2-t2-模拟量采集/task.md b/samples/p2-t2-模拟量采集/task.md index 90ac6c3..b3c8523 100644 --- a/samples/p2-t2-模拟量采集/task.md +++ b/samples/p2-t2-模拟量采集/task.md @@ -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`。 diff --git a/samples/p2-t3-LED推送模拟量/checks.yaml b/samples/p2-t3-LED推送模拟量/checks.yaml index 694a5a5..4ae5360 100644 --- a/samples/p2-t3-LED推送模拟量/checks.yaml +++ b/samples/p2-t3-LED推送模拟量/checks.yaml @@ -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 } diff --git a/samples/p2-t3-LED推送模拟量/task.md b/samples/p2-t3-LED推送模拟量/task.md index 7d08540..ca37161 100644 --- a/samples/p2-t3-LED推送模拟量/task.md +++ b/samples/p2-t3-LED推送模拟量/task.md @@ -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`。请按代码起名,自动评分按这些名字找控件。 diff --git a/samples/p3-t1-ZigBee路灯/task.md b/samples/p3-t1-ZigBee路灯/task.md index bfa3bbd..4079282 100644 --- a/samples/p3-t1-ZigBee路灯/task.md +++ b/samples/p3-t1-ZigBee路灯/task.md @@ -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`,否则自动评分找不到这个按钮。 diff --git a/samples/p3-t2-人体红外ZigBee联动/task.md b/samples/p3-t2-人体红外ZigBee联动/task.md index fda92cc..6d1f066 100644 --- a/samples/p3-t2-人体红外ZigBee联动/task.md +++ b/samples/p3-t2-人体红外ZigBee联动/task.md @@ -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`,否则自动评分找不到这个按钮。 diff --git a/samples/p4-t1-红外对射/task.md b/samples/p4-t1-红外对射/task.md index 10fbd3d..330b35f 100644 --- a/samples/p4-t1-红外对射/task.md +++ b/samples/p4-t1-红外对射/task.md @@ -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 秒) | > 书上的表格里没有分组框,看书上的界面图,"时间""入侵"两行放在"数据显示"分组框里。 diff --git a/samples/p4-t2-网络摄像机/task.md b/samples/p4-t2-网络摄像机/task.md index cc7e7ea..7f3ab48 100644 --- a/samples/p4-t2-网络摄像机/task.md +++ b/samples/p4-t2-网络摄像机/task.md @@ -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` | 拍照 | ## 实现步骤 diff --git a/samples/p4-t3-报警灯/task.md b/samples/p4-t3-报警灯/task.md index 8ccffde..101f25f 100644 --- a/samples/p4-t3-报警灯/task.md +++ b/samples/p4-t3-报警灯/task.md @@ -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`,请按代码写,否则自动评分找不到它。 diff --git a/samples/p5-t1-火焰/task.md b/samples/p5-t1-火焰/task.md index dd5b671..73a1f1a 100644 --- a/samples/p5-t1-火焰/task.md +++ b/samples/p5-t1-火焰/task.md @@ -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`(数据显示),把时间、事件两行放进去。 diff --git a/samples/p5-t2-烟雾/task.md b/samples/p5-t2-烟雾/task.md index 5e98b87..8f28ae8 100644 --- a/samples/p5-t2-烟雾/task.md +++ b/samples/p5-t2-烟雾/task.md @@ -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`(数据显示)。 diff --git a/samples/p5-t3-推送灾情/task.md b/samples/p5-t3-推送灾情/task.md index 7660ceb..589b2c6 100644 --- a/samples/p5-t3-推送灾情/task.md +++ b/samples/p5-t3-推送灾情/task.md @@ -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),照着写。 diff --git a/samples/p6-t2-存储环境数据/task.md b/samples/p6-t2-存储环境数据/task.md index e61793c..60e691c 100644 --- a/samples/p6-t2-存储环境数据/task.md +++ b/samples/p6-t2-存储环境数据/task.md @@ -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` 不带,照书上的写,自动评分按这些名字找按钮。 diff --git a/samples/p6-t3-按时间查询/task.md b/samples/p6-t3-按时间查询/task.md index 55cecea..7406f58 100644 --- a/samples/p6-t3-按时间查询/task.md +++ b/samples/p6-t3-按时间查询/task.md @@ -12,16 +12,16 @@ ## 界面和控件名 -| 控件 | 名称(Name) | 文字(Text)和用途 | -|---|---|---| -| 窗体 | `Form1` | 查询校园灾情数据 | -| 标签 | `LblStartTime` | 开始日期 | -| 日期时间控件 | `StartTime` | 选开始日期 | -| 标签 | `LblEndTime` | 结束日期 | -| 日期时间控件 | `EndTime` | 选结束日期 | -| 按钮 | `btnCx` | 查询 | -| 分组框 | `groupBox4` | 历史记录 | -| 数据表格 | `dataGridView1` | 显示查到的记录 | +| 控件 | 名称(Name) | 文字(Text)和用途 | +| ------------ | --------------- | ------------------ | +| 窗体 | `Form1` | 查询校园灾情数据 | +| 标签 | `LblStartTime` | 开始日期 | +| 日期时间控件 | `StartTime` | 选开始日期 | +| 标签 | `LblEndTime` | 结束日期 | +| 日期时间控件 | `EndTime` | 选结束日期 | +| 按钮 | `btnCx` | 查询 | +| 分组框 | `groupBox4` | 历史记录 | +| 数据表格 | `dataGridView1` | 显示查到的记录 | ## 实现步骤 diff --git a/samples/p7-t1-智慧教室/task.md b/samples/p7-t1-智慧教室/task.md index a291e98..cdf9adf 100644 --- a/samples/p7-t1-智慧教室/task.md +++ b/samples/p7-t1-智慧教室/task.md @@ -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℃)照书上的图摆好,名字不影响评分。 diff --git a/samples/p7-t2-智慧寝室/task.md b/samples/p7-t2-智慧寝室/task.md index 59e0463..5c7d2b4 100644 --- a/samples/p7-t2-智慧寝室/task.md +++ b/samples/p7-t2-智慧寝室/task.md @@ -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 | 其余标签(当前时间、串口、光照度、温度、湿度、窗帘、全开、全关、广播、灯光)照书上的图摆好,名字不影响评分。 diff --git a/samples/p7-t3-智慧会议室/task.md b/samples/p7-t3-智慧会议室/task.md index cecefd4..6623ed7 100644 --- a/samples/p7-t3-智慧会议室/task.md +++ b/samples/p7-t3-智慧会议室/task.md @@ -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₂ 浓度一行,但代码里没有用到,可以不做。 diff --git a/samples/p8-t1-主界面/task.md b/samples/p8-t1-主界面/task.md index 553322f..3522c2e 100644 --- a/samples/p8-t1-主界面/task.md +++ b/samples/p8-t1-主界面/task.md @@ -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**,否则图标四周会露出一块灰色。 diff --git a/samples/p8-t2-校园环境界面/task.md b/samples/p8-t2-校园环境界面/task.md index 1face93..11b8960 100644 --- a/samples/p8-t2-校园环境界面/task.md +++ b/samples/p8-t2-校园环境界面/task.md @@ -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 关闭,主界面还在。 diff --git a/samples/p8-t3-环境采集与分析/task.md b/samples/p8-t3-环境采集与分析/task.md index c833e32..3eac501 100644 --- a/samples/p8-t3-环境采集与分析/task.md +++ b/samples/p8-t3-环境采集与分析/task.md @@ -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` | 自动推送火情 | ## 实现步骤 diff --git a/samples/p8-t4-校园安防/task.md b/samples/p8-t4-校园安防/task.md index 7591b1f..55011b3 100644 --- a/samples/p8-t4-校园安防/task.md +++ b/samples/p8-t4-校园安防/task.md @@ -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 关闭,摄像机也关闭。 diff --git a/samples/p8-t5-历史图片/task.md b/samples/p8-t5-历史图片/task.md index 9c36e88..732101e 100644 --- a/samples/p8-t5-历史图片/task.md +++ b/samples/p8-t5-历史图片/task.md @@ -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` 是**右**箭头(下一张),名字照书上写。 diff --git a/server/src/ai-review.ts b/server/src/ai-review.ts index 9fefbb3..b068f9c 100644 --- a/server/src/ai-review.ts +++ b/server/src/ai-review.ts @@ -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}` } }))], }, ], }), diff --git a/server/src/app.ts b/server/src/app.ts index f6d8628..0975931 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 }) }) diff --git a/server/src/assignments.ts b/server/src/assignments.ts index ac24f07..7620baa 100644 --- a/server/src/assignments.ts +++ b/server/src/assignments.ts @@ -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, diff --git a/server/src/board.ts b/server/src/board.ts index b697d67..c3de8b5 100644 --- a/server/src/board.ts +++ b/server/src/board.ts @@ -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') } diff --git a/server/src/classes.ts b/server/src/classes.ts index 3822647..17ea55b 100644 --- a/server/src/classes.ts +++ b/server/src/classes.ts @@ -68,9 +68,7 @@ export function deleteClass(db: Database, id: number): { error?: string } { } export function listStudents(db: Database, classId: number): Student[] { - return db - .query(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`) - .all(classId) + return db.query(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId) } /** diff --git a/server/src/grading/checks.ts b/server/src/grading/checks.ts index 3650c8c..6c7e68b 100644 --- a/server/src/grading/checks.ts +++ b/server/src/grading/checks.ts @@ -61,7 +61,15 @@ export type Expectation = */ const DEFAULT_WITHIN = 5000 -const PROP_KEYS: Record = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' } +const PROP_KEYS: Record = { + 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, 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, 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 } diff --git a/server/src/grading/grader.ts b/server/src/grading/grader.ts index 29418d6..19ce5f0 100644 --- a/server/src/grading/grader.ts +++ b/server/src/grading/grader.ts @@ -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 `"${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 `${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 ? ' ……' : ''}` : '没有数据'}` } } diff --git a/server/src/grading/queue.ts b/server/src/grading/queue.ts index 8fda30b..b0f0d91 100644 --- a/server/src/grading/queue.ts +++ b/server/src/grading/queue.ts @@ -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 }) diff --git a/server/src/index.ts b/server/src/index.ts index 71091c8..110f578 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 diff --git a/server/src/reviews.ts b/server/src/reviews.ts index 64e2ee2..59b0dcb 100644 --- a/server/src/reviews.ts +++ b/server/src/reviews.ts @@ -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 })), diff --git a/server/src/sandbox.ts b/server/src/sandbox.ts index 66e5947..c301209 100644 --- a/server/src/sandbox.ts +++ b/server/src/sandbox.ts @@ -75,13 +75,20 @@ export function runCommand(projectDir: string, extraEnv: Record, 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, ] } diff --git a/server/src/sql-service.ts b/server/src/sql-service.ts index ad8625f..9479d74 100644 --- a/server/src/sql-service.ts +++ b/server/src/sql-service.ts @@ -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((resolveReady) => { diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 442dc3c..a4e528c 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -33,9 +33,7 @@ class Browser { const headers: Record = { '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 @@ -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 }) diff --git a/server/test/classroom.ts b/server/test/classroom.ts index c8ff24b..548121f 100644 --- a/server/test/classroom.ts +++ b/server/test/classroom.ts @@ -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).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) => { diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index 034bda4..7853f0b 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -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) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts index b2e66b2..3df6df1 100644 --- a/server/test/runner.test.ts +++ b/server/test/runner.test.ts @@ -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, +) diff --git a/server/test/samples.test.ts b/server/test/samples.test.ts index 3f6652c..08a29c3 100644 --- a/server/test/samples.test.ts +++ b/server/test/samples.test.ts @@ -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])) diff --git a/web/index.html b/web/index.html index 7691891..643acfa 100644 --- a/web/index.html +++ b/web/index.html @@ -22,10 +22,16 @@ if (!app || app.hasAttribute('data-v-app')) return app.innerHTML = '
' + - '

' + title + '

' + text + '

' + + '

' + + title + + '

' + + text + + '

' + '

用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到极速模式;或者换用新版的 Chrome、Edge。' + '还是打不开的话,请把下面这行文字告诉老师:

' + - '

' + navigator.userAgent.replace(/

' + '

' + + navigator.userAgent.replace(/' } window.addEventListener('error', function (event) { if (event && event.error instanceof SyntaxError) { diff --git a/web/src/Root.vue b/web/src/Root.vue index 1a1c189..5d83286 100644 --- a/web/src/Root.vue +++ b/web/src/Root.vue @@ -16,6 +16,6 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive- diff --git a/web/src/database/DesignTab.vue b/web/src/database/DesignTab.vue index b11960c..01806c2 100644 --- a/web/src/database/DesignTab.vue +++ b/web/src/database/DesignTab.vue @@ -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() diff --git a/web/src/database/EditTab.vue b/web/src/database/EditTab.vue index 3f9b72c..af5ec46 100644 --- a/web/src/database/EditTab.vue +++ b/web/src/database/EditTab.vue @@ -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; } diff --git a/web/src/database/ObjectExplorer.vue b/web/src/database/ObjectExplorer.vue index 950bdcc..557c288 100644 --- a/web/src/database/ObjectExplorer.vue +++ b/web/src/database/ObjectExplorer.vue @@ -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]) {

  • - {{ expanded.has('root') ? '▾' : '▸' }}📁 数据库 + {{ expanded.has('root') ? '▾' : '▸' }}📁 数据库
    • (还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)
    • - {{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}🛢 {{ db.name }} + {{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}🛢 {{ db.name }}
      • - {{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}📁 表 + {{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}📁 表
        • (没有表)
        • @@ -110,12 +121,14 @@ function columnText(column: ExplorerTable['columns'][number]) { @click="toggle(`table:${db.name}/${table.name}`)" @contextmenu="tableMenu($event, db.name, table)" > - {{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}▦ dbo.{{ table.name }} + {{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}▦ dbo.{{ table.name }}
          • - {{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}📁 列 + {{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}📁 列
            • diff --git a/web/src/database/ResultGrid.vue b/web/src/database/ResultGrid.vue index bdff4fb..5231702 100644 --- a/web/src/database/ResultGrid.vue +++ b/web/src/database/ResultGrid.vue @@ -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; } diff --git a/web/src/database/sql.test.ts b/web/src/database/sql.test.ts index 7d6d4f7..b60671c 100644 --- a/web/src/database/sql.test.ts +++ b/web/src/database/sql.test.ts @@ -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(['命令已成功完成。']) }) diff --git a/web/src/database/sql.ts b/web/src/database/sql.ts index e13ac73..66e43cf 100644 --- a/web/src/database/sql.ts +++ b/web/src/database/sql.ts @@ -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('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) { diff --git a/web/src/designer/ChartDialog.vue b/web/src/designer/ChartDialog.vue index 6297156..5fc122a 100644 --- a/web/src/designer/ChartDialog.vue +++ b/web/src/designer/ChartDialog.vue @@ -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() {
              - + - +
              @@ -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 }} @@ -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; diff --git a/web/src/designer/PropertyGrid.vue b/web/src/designer/PropertyGrid.vue index ec1aa0c..209c998 100644 --- a/web/src/designer/PropertyGrid.vue +++ b/web/src/designer/PropertyGrid.vue @@ -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(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) {
              {{ raw(prop) }} - + -