Compare commits
3
Commits
3b9f635c35
...
de277d3e9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de277d3e9d | ||
|
|
d4b8bf9241 | ||
|
|
90b739a2c7 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# bun fmt 不格式化的文件
|
||||
# 书上的摘录,保持原样
|
||||
docs/教材摘要/
|
||||
@@ -6,7 +6,7 @@
|
||||
## 目录
|
||||
|
||||
| 目录 | 内容 |
|
||||
|---|---|
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
||||
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||
@@ -56,7 +56,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
||||
本机实测(16 核、28 GB):
|
||||
|
||||
| 指标 | 结果 |
|
||||
|---|---|
|
||||
| ------------------------ | ----------------------------------------------------------- |
|
||||
| 编译(编译服务预热后) | 约 10–300 ms |
|
||||
| 启动到窗体显示 | 约 100 ms |
|
||||
| 单次操作往返 | 约 15 ms |
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# bun fmt 的格式:和项目原来的写法一致(不写分号、单引号、一行最长 160)
|
||||
[fmt]
|
||||
printWidth = 160
|
||||
semi = false
|
||||
singleQuote = true
|
||||
+3
-3
@@ -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:
|
||||
|
||||
+2
-2
@@ -97,7 +97,7 @@
|
||||
在服务器的 `~/IOT_SIM` 目录下:
|
||||
|
||||
| 做什么 | 命令 |
|
||||
|---|---|
|
||||
| ------------ | --------------------------------------------------------------------------------------- |
|
||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
||||
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||
@@ -125,7 +125,7 @@ deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部
|
||||
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
||||
|
||||
+10
-6
@@ -15,7 +15,7 @@
|
||||
## 已确定的关键决策
|
||||
|
||||
| 事项 | 决定 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| 学生编程语言 | C# | 课程要求 |
|
||||
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||
@@ -64,7 +64,7 @@
|
||||
平台提供与教材同名、同签名的仿真类库(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) |
|
||||
@@ -151,7 +151,7 @@ IOT/
|
||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||
|
||||
| 层 | 用途 | 选择 |
|
||||
| --- | --- | --- |
|
||||
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||
@@ -207,7 +207,7 @@ 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 | 已完成 |
|
||||
@@ -250,7 +250,7 @@ flowchart TB
|
||||
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||
|
||||
| 指标 | 结果 |
|
||||
| --- | --- |
|
||||
| ---------------------- | ---------------------------------------------------- |
|
||||
| 编译(编译服务预热后) | 10–300 ms |
|
||||
| 点"运行"到窗体出现 | 约 100 ms |
|
||||
| 单次操作往返 | 约 15 ms |
|
||||
@@ -328,12 +328,16 @@ flowchart TB
|
||||
- **使用说明**(`/help`,没登录也能看):学生部分讲登录、自由练习和作业、设计器、运行、实训台、自己检查和提交、数据库;老师登录后多出建班导入名单、布置作业、评阅和成绩、上课前后的注意事项;最后是常见问题。登录页和各页顶栏都有入口,在新标签页打开,不打断正在做的事;可以直接打印。
|
||||
- **首次打开的下载量**:实验页第一次打开约 1.5 MB(压缩后,代码编辑器 Monaco 占 1 MB),之后浏览器长期缓存。全班 45 人同时第一次打开约 70 MB,服务器公网带宽 5 Mbps 时要近 2 分钟。
|
||||
|
||||
- 2026-10-26:**任务说明**。每个实验一份 `task.md`(任务目标、界面和控件名表格、实现步骤、和书上不一样的地方、在平台上怎么试),用自己的话精简写,详细过程标出书页,不照搬书上原文。工具栏"评价项"改成"**任务**":右侧面板上面是任务说明,下面是评价项和"自己检查";面板不遮挡页面、可以拖宽,开着也能搭界面、写代码;第一次打开一个实验(或作业)时自动打开(记在浏览器里)。渲染用自己写的 Markdown 子集(标题、段落、两级列表、表格、提示框、加粗、代码),先转义全部 HTML。布置作业时任务说明随作业保存,老师可以改写;以前布置的作业用实验现在的说明。测试检查任务说明表格里的控件名都在示例的 Designer.cs 里。先写了项目 3 任务 2、项目 6 任务 2 两份样板,随后补齐其余 21 份(23 个实验全部有说明)。
|
||||
- 2026-10-26:**窗体改名**。23 个示例里 11 个的主窗体不叫 Form1(Login、frm_analog、frm_LED、frm_control、Main),学生的空白工程却只有 Form1,以前属性窗口改不了窗体名(项目 8 任务 2 的评分还要找 Main 窗体)。现在选中窗体可以改"名称":和 VS 一样,类名、构造函数、各文件里的 `new X()` 和 X 类型的变量都改(跳过字符串和注释),Designer.cs 里窗体的块头注释和 `this.Name` 也改,两个文件跟着改名;事件处理方法名(如 Form1_Load)和窗体标题不变。改名不进设计器的撤销历史。任务说明里提示先改名再双击窗体生成 Load 事件。
|
||||
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
|
||||
|
||||
## 技术风险与待确认事项
|
||||
|
||||
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||
|
||||
| 风险 | 担心什么 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||
|
||||
+5
-1
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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: 用户名或密码错误 }
|
||||
@@ -0,0 +1,64 @@
|
||||
## 任务目标
|
||||
|
||||
做一个登录程序:登录窗体里输入用户名和密码,点"登录"检查账号是否存在;点"增加用户"打开第二个窗体,输入用户名和两遍密码后保存。用户数据放在一个公共的静态类里,两个窗体都能用。
|
||||
|
||||
练习重点:多个窗体(新建、Show、Close)、静态类和属性、数组和 for 循环、MessageBox。详细步骤见书 p2–7。
|
||||
|
||||
> 书上先用 Python 写了一遍登录程序,平台只支持 C#,这一部分看书了解即可。知识提炼里的控制台示例可以把代码放进按钮的 Click 里运行,`Console.WriteLine` 的输出显示在运行区下面;`Console.ReadLine` 读不到输入。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
登录窗体:
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | -------------------------------------------------------------------- |
|
||||
| 窗体 | `Login` | 登录 |
|
||||
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
|
||||
| 文本框 | `txtPassword` | 输入密码,PasswordChar 设为星号 *(前面放标签 `LblPwd`,文字"密码") |
|
||||
| 按钮 | `btnOk` | 登录 |
|
||||
| 按钮 | `btnCancel` | 取消 |
|
||||
| 按钮 | `btnAdd` | 增加用户 |
|
||||
|
||||
用户名前面的标签 `LblUsername` 文字是"用户名称"。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `Login`,类名、文件名和 Program.cs 会跟着改。
|
||||
|
||||
增加用户窗体(点"+ 新建窗体",名字填 AddUser):
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | --------------------------------- |
|
||||
| 窗体 | `AddUser` | 增加用户 |
|
||||
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
|
||||
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
|
||||
| 文本框 | `txtPass2` | 确认密码,PasswordChar 设为星号 * |
|
||||
| 按钮 | `btnOk` | 确定 |
|
||||
| 按钮 | `btnCancel` | 取消 |
|
||||
|
||||
> 书上的控件表和代码对不上:表里写 `txtPwd`、`BtnOk`,增加用户窗体写 `txtUserName`、`txtPwd`、`txtCheckPwd`,代码里用的是上面这些名字。请按上面的表起名,自动评分按这些名字操作。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点"+ 新建类",新建 `PublicData` 类,写 4 个静态成员:用户名数组 `SysUserName`、密码数组 `SysPwd`(都是 `new string[4]`,最多存 4 个用户),已存的用户数 `intNum`、当前登录用户的序号 `intUser`(这两个写成 `{ get; set; }` 属性)。
|
||||
2. Login 窗体里声明 `public bool IsLogin = false;` 记录是否登录成功。
|
||||
3. **登录窗体 btnOk 的 Click**:用 for 循环逐个比较 `PublicData.SysUserName[i]` 和 `SysPwd[i]`,用户名和密码都相同(且用户名不为空)时把 IsLogin 设为 true,提示"登录成功,欢迎进入!",记下 `intUser = i`,break 跳出循环;循环结束后 IsLogin 还是 false,就提示"无效的密码或用户名,请重试",并让密码框获得焦点(`Focus()`)。
|
||||
4. **登录窗体 btnCancel 的 Click**:IsLogin 设为 false,`this.Close()` 关闭窗体。
|
||||
5. **btnAdd 的 Click**:`new AddUser()` 创建增加用户窗体,再调用 `Show()` 显示出来。
|
||||
6. **增加用户窗体 btnOk 的 Click**:
|
||||
- `intNum` 小于 4 时,如果两次密码相同(`txtPass1.Text.Equals(txtPass2.Text)`)且不为空,就把用户名和密码存进两个数组的第 `intNum` 个位置,`intNum` 加 1,关闭窗体;否则提示"用户名或密码错误,请重新输入"。
|
||||
- 已经存满 4 个,提示"用户名已满不能增加"。
|
||||
7. **增加用户窗体 btnCancel 的 Click**:`this.Close()`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **增加用户窗体每次点击时新建**:书上在 Login 里写了全局变量 `AddUser addfrm = new AddUser();`,点按钮时只 `Show()`。窗体关闭后就被释放了,第二次点"增加用户"会出错,所以改成在 btnAdd 的 Click 里 `new AddUser()` 再 `Show()`。
|
||||
|
||||
> 书上登录成功后用到的变量 `change` 没有声明过,照抄会编译不过,这几行删掉即可。
|
||||
|
||||
> 程序刚启动时一个用户都没有,要先点"增加用户"加一个账号才能登录。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点"增加用户",用户名填 zhangsan,两次密码都填 123,点"确定",窗体关闭。
|
||||
2. 在登录窗体输入 zhangsan 和 123,点"登录",应提示"登录成功,欢迎进入!"。
|
||||
3. 输入一个不存在的用户名或错误的密码,应提示"无效的密码或用户名,请重试"。
|
||||
4. 再点"增加用户",两次密码填得不一样,点"确定",应提示"用户名或密码错误,请重新输入"。
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
## 任务目标
|
||||
|
||||
一个窗体里做两件事:上半部分是简易计算器,输入两个数、选运算符,点"="算出结果;下半部分点"显示",让两个中国人、两个美国人依次"说话",在文本框里输出各自说的语言。
|
||||
|
||||
练习重点:装箱与拆箱(object 和值类型互相转换)、类的继承、虚方法(virtual)和重写(override)、List 和 Dictionary。详细步骤见书 p13–16。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | -------------- | ---------------------------------------- |
|
||||
| 窗体 | `Form1` | C#入门基础 |
|
||||
| 分组框 | `groupBox1` | 简易计算器 |
|
||||
| 文本框 | `txtNum1` | 第一个数 |
|
||||
| 下拉框 | `cmboxType` | 运算符,Items 填 `+`、`-`、`*`、`/` 四项 |
|
||||
| 文本框 | `txtNum2` | 第二个数 |
|
||||
| 按钮 | `btnCalculate` | = |
|
||||
| 文本框 | `txtResult` | 计算结果,ReadOnly 设为 True |
|
||||
| 分组框 | `groupBox2` | 类的多态性 |
|
||||
| 文本框 | `txtDispaly` | 输出说话内容,Multiline 设为 True |
|
||||
| 按钮 | `btnDisplay` | 显示 |
|
||||
|
||||
> `txtDispaly` 是书上的拼法(不是 Display),代码和自动评分都用这个名字,照着写。书上的表格没写下拉框的 Items 和文本框的 Multiline,要自己在属性里设置。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 写一个方法 `public Object Result(Object a, Object b, String type)`:先把 a、b 拆箱成 float,用 switch 按 `+ - * /` 计算(其他情况结果为 0),最后把结果装箱成 Object 返回。
|
||||
2. **btnCalculate 的 Click**:两个文本框都不为空时,用 `float.Parse` 把文字转成数,调用 `Result(a, b, cmboxType.Text)`,返回值拆箱成 float,显示到 `txtResult`。
|
||||
3. 点"+ 新建类",新建 `People` 类,写一个虚方法 `public virtual void Speak(TextBox Txtbox, string name)`,把 name 追加到文本框的 Text 后面。
|
||||
4. 新建 `Chinese` 类继承 People,重写 Speak:调用 `base.Speak`,传入 `name + "说的是汉语!\r\n"`。
|
||||
5. 新建 `American` 类继承 People,同样重写,内容改成"说的是英语!"。
|
||||
6. **btnDisplay 的 Click**:
|
||||
- 建一个 `Dictionary<int, string>`,放入 1 张三、2 李四、3 Mary、4 Jimmy;
|
||||
- 建一个 `List<People>`,先加 2 个 `new Chinese()`,再加 2 个 `new American()`;
|
||||
- 遍历 List,对第 i 个对象调用 `Speak(this.txtDispaly, dicName[i + 1])`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 8 步写的是"双击'='按钮",其实应该双击"**显示**"按钮,生成 `btnDisplay_Click`。
|
||||
|
||||
> 书上排版把减号印成了长一点的"–",抄 `case "-"` 和下拉框的 Items 时要用键盘上的减号,否则选"-"时算不出来。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后输入 7 和 2,选"+",点"=",结果应为 9;改选"/"再点"=",结果应为 3.5。
|
||||
2. 点"显示",文本框里应依次出现"张三说的是汉语!""李四说的是汉语!""Mary说的是英语!""Jimmy说的是英语!"四行。
|
||||
@@ -0,0 +1,37 @@
|
||||
## 任务目标
|
||||
|
||||
模拟校园信息发布:选好 LED 显示屏的串口并打开,在文本框里输入一段文字,点"推送"后实训台的 LED 屏和窗体上的 LED 控件都显示这段文字;推送失败时弹出出错信息。
|
||||
|
||||
练习重点:分组框(GroupBox)、下拉框的 Click 事件、用 `SerialPort.GetPortNames()` 列出串口、调用设备类库、自定义控件 LED。详细步骤见书 p23–27。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ---------- | -------------- | ----------------------------------------------- |
|
||||
| 窗体 | `Form1` | LED显示屏 |
|
||||
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
|
||||
| 分组框 | `groupBox1` | LED控制 |
|
||||
| 下拉框 | `com_Port` | 串口(前面放标签"串口") |
|
||||
| 按钮 | `btn_Open` | 打开 |
|
||||
| 文本框 | `txt_SendText` | 要推送的文字(前面放标签"推送LED信息") |
|
||||
| 按钮 | `btn_Send` | 推送 |
|
||||
|
||||
> 书上的表格把 LED 控件写成 `LED`,代码里用的是小写的 `led`,请按代码起名。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 平台已经引用好了 `DeviceCtrlLibrary.dll` 和 `WinFormsDeviceControlLibrary.dll`,不用像书上那样复制 dll、添加引用;LED 显示屏、灯泡、报警灯这些控件直接在工具箱的"物联网设备"里。
|
||||
2. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using WinFormsDeviceControlLibrary;`。
|
||||
3. **com_Port 的 Click**(在 ⚡ 事件里双击 Click):先清空下拉框,再用 foreach 把 `SerialPort.GetPortNames()` 的每个串口号加进去。这样每次点开下拉框都会刷新串口列表。
|
||||
4. **btn_Open 的 Click**:调用 `DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text)` 打开选中的串口。
|
||||
5. **btn_Send 的 Click**:调用 `DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text)`,把返回的字符串存到变量 res;如果 res 里含有 "Error"(`res.IndexOf("Error") >= 0`)就用 MessageBox 显示出来,否则调用 `led.setLedText(res)` 让窗体上的 LED 控件显示。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 知识提炼的 try-catch 示例里,书上把 `catch (Exception ex)` 印成了 `Catch(EventArgs ex)`,照抄会编译不过:catch 要小写,括号里是异常类型 Exception。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点开"串口"下拉框,选 **COM5**(LED 屏),点"打开"。
|
||||
2. 输入"欢迎光临",点"推送",实训台的 **LED 显示屏**和窗体上的 LED 控件都应显示"欢迎光临"。
|
||||
3. 重新运行,不点"打开"直接推送,应弹出"Error:发送失败"。
|
||||
@@ -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' }
|
||||
@@ -0,0 +1,40 @@
|
||||
## 任务目标
|
||||
|
||||
从 ADAM-4017+ 模拟量采集器读出光照度、温度、湿度:选好串口点"开启",再点"获取",三个文本框显示传感器的当前值;"开启"按钮打开后变成"关闭",再点一次关闭串口。
|
||||
|
||||
练习重点:标签(Label)和按钮(Button)、窗体的 Load 事件、用 bool 变量记住串口是否打开、读取设备类库返回的数据。详细步骤见书 p36–39。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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` | 开启(打开后变成"关闭") |
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_analog`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_analog_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`(平台已经引用好了设备类库,不用复制 dll)。
|
||||
2. 声明两个全局变量:`bool IsOpen = false;` 记录串口是否打开,`ADAM4017ResModel ResModel;` 存放读到的数据。
|
||||
3. **frm_analog_Load**(窗体的 Load 事件,双击窗体空白处生成):清空 `com_port`,用 foreach 把 `SerialPort.GetPortNames()` 的串口号加进去。
|
||||
4. **btn_Open 的 Click**:
|
||||
- IsOpen 为 false 时:`DeviceCtrlHelper.adam4017Helper.Open(com_port.Text)` 打开串口,按钮文字改成"关闭",IsOpen 设为 true;
|
||||
- 否则:`adam4017Helper.Close()` 关闭串口,按钮文字改回"开启",IsOpen 设为 false。
|
||||
5. **btn_get 的 Click**:`ResModel = DeviceCtrlHelper.adam4017Helper.SetData();`,把 `ResModel.lightValue`、`temperatureValue`、`humidityValue` 分别填进三个文本框。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上把 `IsOpen = true`、`IsOpen = false` 印成了 `True`、`False`(首字母大写),C# 的布尔值只能小写,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**(ADAM-4017+),点"开启",按钮应变成"关闭"。
|
||||
2. 点"获取",三个文本框应显示实训台上的默认值:温度 25、湿度 60、光照度 500。
|
||||
3. 在实训台上把温度拖到 31、光照拖到 300,再点"获取",文本框应变成新的数值。
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,48 @@
|
||||
## 任务目标
|
||||
|
||||
把任务 1 和任务 2 合起来:同时打开 ADAM-4017+ 和 LED 屏的串口,点"获取"读出光照度、温度、湿度,显示在文本框里,再拼成一句话推送到 LED 屏,例如"当前环境信息 光照度:500lx温度:25℃湿度:60%"。
|
||||
|
||||
练习重点:下拉框(ComboBox)和文本框(TextBox)、字符串拼接、一个按钮同时控制两个设备。详细步骤见书 p42–45。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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`。请按代码起名,自动评分按这些名字找控件。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_LED`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_LED_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||
2. 声明全局变量:`bool IsOpen = false;`、`ADAM4017ResModel ResModel;`、`String LED_MSG = "";`(要推送的文字)。
|
||||
3. **frm_LED_Load**:清空两个下拉框,用一个 foreach 把串口号同时加进 `com_adamPort` 和 `com_ledPort`。
|
||||
4. **btn_open 的 Click**:
|
||||
- 没打开时:`adam4017Helper.Open(com_adamPort.Text)` 和 `ledHelper.OpenLedCom(com_ledPort.Text)` 打开两个串口,按钮改成"关闭",IsOpen 设为 true;
|
||||
- 已打开时:`adam4017Helper.Close()` 和 `ledHelper.Close()` 关闭,按钮改回"开启",IsOpen 设为 false。
|
||||
5. **btn_get 的 Click**:
|
||||
- 先把 LED_MSG 清空,用 `adam4017Helper.SetData()` 读数据,填进三个文本框(和任务 2 一样);
|
||||
- 把"当前环境信息"和三个值拼进 LED_MSG(光照度后面加 lx,温度加 ℃,湿度加 %);
|
||||
- 调用 `ledHelper.SendLedString(LED_MSG)`,返回值里没有 "Error" 时,调用 `led.setLedText` 让窗体上的 LED 控件也显示。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台保留了它(什么也不做),书上的代码可以原样编译。
|
||||
|
||||
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后"模拟量串口"选 **COM2**(ADAM-4017+),"LED串口"选 **COM5**(LED 屏),点"开启"。
|
||||
2. 点"获取",文本框显示温度 25、湿度 60、光照度 500,实训台的 **LED 显示屏**上应出现这三个数。
|
||||
3. 在实训台上把温度拖到 31,再点"获取",LED 屏上的温度应变成 31。
|
||||
@@ -0,0 +1,38 @@
|
||||
## 任务目标
|
||||
|
||||
做一个路灯遥控程序:选好 ZigBee 协调器的串口点"打开",然后选"开"单选按钮路灯亮,选"关"路灯灭,窗体上的灯泡跟着实训台上的灯一起变。
|
||||
|
||||
练习重点:if 语句、按钮文字切换、单选按钮(RadioButton)的 CheckedChanged 事件、自己定义一个方法。详细步骤见书 p53–56。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| -------- | ------------ | ------------------------------------------------------ |
|
||||
| 窗体 | `Form1` | ZigBee路灯 |
|
||||
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
|
||||
| 单选按钮 | `rb_Open` | 开(开灯) |
|
||||
| 单选按钮 | `rb_Close` | 关(关灯) |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `lbl_Lamp` | 路灯 |
|
||||
| 下拉框 | `com_port` | ZigBee 协调器的串口 |
|
||||
| 按钮 | `btn_open` | 打开(打开后变成"关闭") |
|
||||
|
||||
> 书上的表格写的是 `btn_Open`,代码里是 `btn_open`。名字区分大小写,请按代码写成 `btn_open`,否则自动评分找不到这个按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 写一个方法 `LampOnOff(int RelayNum, bool IsOpen)`:调用 `DeviceCtrlHelper.zigBeeHelper.OnOff` 控制实训台的路灯,再调用 `lamp1.OnOff` 让窗体上的灯泡跟着变。
|
||||
3. **Form1_Load**:清空 `com_port`,用 `SerialPort.GetPortNames()` 把串口号加进去。
|
||||
4. **btn_open 的 Click**:用 if 判断按钮文字——是"打开"就用 `zigBeeHelper.Open(com_port.Text)` 打开串口,文字改成"关闭";是"关闭"就 `zigBeeHelper.Close()`,文字改回"打开"。
|
||||
5. **rb_Open 的 CheckedChanged**:调用 `LampOnOff(1, true)`。
|
||||
6. **rb_Close 的 CheckedChanged**:调用 `LampOnOff(1, false)`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 12 步"关"按钮的事件方法名印成了 `rb_Open_CheckedChanged`,应该是 `rb_Close_CheckedChanged`。在设计器里双击"关"单选按钮,生成的名字就是对的。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM4**(ZigBee 协调器),点"打开",按钮变成"关闭"。
|
||||
2. 选"开",窗体上的灯泡和实训台的**照明灯 1** 应一起亮;选"关",一起灭。
|
||||
@@ -0,0 +1,42 @@
|
||||
## 任务目标
|
||||
|
||||
做一个"楼梯灯":勾选**人体开关**后,程序每 5 秒检查一次人体红外传感器,有人就开路灯,没人就关灯;不勾选时用"开""关"两个按钮手动控制。
|
||||
|
||||
练习重点:复选框的 Click 事件、定时器(Timer)、控件的 Enabled 属性。详细步骤见书 p58–61。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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`,否则自动评分找不到这个按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. **Form1_Load**:清空两个下拉框,用 `SerialPort.GetPortNames()` 把串口号加进去。
|
||||
3. 写一个方法 `LampOnOff(int RelayNum, bool IsOpen)`:调用 `DeviceCtrlHelper.zigBeeHelper.OnOff` 控制实训台的路灯,再调用 `lamp1.OnOff` 让窗体上的灯泡跟着变。
|
||||
4. **btn_open 的 Click**:和任务 1 一样,按钮文字是"打开"时打开 ZigBee 串口(`com_lamp.Text`)并改成"关闭",反之关闭串口。
|
||||
5. **btn_lampOpen、btn_lampClose 的 Click**:分别调用 `LampOnOff(1, true)`、`LampOnOff(1, false)`。
|
||||
6. **ch_bodyInfrared 的 Click**:
|
||||
- 勾上时:打开 ADAM-4150 串口(`adam4150Helper.Open(com_environment.Text)`),启动 `timer1`,两个手动按钮设为不可用;
|
||||
- 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。
|
||||
7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。
|
||||
|
||||
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**:在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后,"路灯"选 **COM4**(ZigBee 协调器),"环境采集"选 **COM3**(ADAM-4150),点"打开"。
|
||||
2. 点"开""关",看窗体上的灯泡和实训台的**照明灯 1** 是否一起亮灭。
|
||||
3. 勾选"人体开关","开""关"按钮应变成灰色;在实训台上把**人体红外**点成"有人",5 秒内路灯应亮起,点回"无人"后 5 秒内熄灭。
|
||||
@@ -0,0 +1,42 @@
|
||||
## 任务目标
|
||||
|
||||
做一个围墙入侵监测程序:点"开启"后,每 2 秒读一次红外对射传感器,在窗体上显示当前时间和"发现入侵"或"正常";再点一次"关闭",停止检测。
|
||||
|
||||
练习重点:定时器(Timer)的 Tick 事件、用一个 bool 变量记住开关状态、foreach 语句。详细步骤见书 p73–76。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------ | -------------------------- |
|
||||
| 窗体 | `Form1` | 红外对射 |
|
||||
| 标签 | `lbl_port` | 环境串口 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 标签 | `lbl_time` | 时间 |
|
||||
| 文本框 | `txt_time` | 显示当前时间 |
|
||||
| 标签 | `lbl_even` | 入侵 |
|
||||
| 文本框 | `txt_even` | 显示"发现入侵"或"正常" |
|
||||
| 定时器 | `timer1` | Interval 设为 2000(2 秒) |
|
||||
|
||||
> 书上的表格里没有分组框,看书上的界面图,"时间""入侵"两行放在"数据显示"分组框里。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明全局变量 `bool IsOpen = false;`,记住串口有没有打开。
|
||||
3. **Form1_Load**:清空 `com_port`,用 foreach 把 `SerialPort.GetPortNames()` 里的串口号逐个加进去。
|
||||
4. **btn_open 的 Click**:先把 `IsOpen` 取反;
|
||||
- 变成 true 时:打开 ADAM-4150 串口(`adam4150Helper.Open(com_port.Text)`),启动 `timer1`,按钮文字改成"关闭";
|
||||
- 变成 false 时:关闭串口,停止 `timer1`,按钮文字改回"开启"。
|
||||
5. **timer1 的 Tick**:把 `DateTime.Now` 显示到 `txt_time`;读 `adam4150Helper.SetData().infraredValue`,是 `true` 就在 `txt_even` 显示"发现入侵",否则显示"正常"。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上的全局变量印成了 `boolIsOpen = false;`,`bool` 和 `IsOpen` 之间要有空格,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭",约 2 秒后时间框开始显示时间。
|
||||
2. 在实训台顶棚点**红外对射**(光束断开,显示"有人闯入"),几秒内入侵框应显示"发现入侵";再点一次恢复光束,应变回"正常"。
|
||||
3. 点"关闭",按钮变回"开启",时间框不再更新。
|
||||
@@ -0,0 +1,43 @@
|
||||
## 任务目标
|
||||
|
||||
做一个校园监控程序:输入摄像机 IP 点"开",图片框里实时显示摄像机拍到的画面;用四个方向键转动摄像机;点"拍照"把当前画面保存成图片。
|
||||
|
||||
练习重点:图片框(PictureBox)、给设备的事件绑定处理方法(`+=`)、static 变量、文件夹和图片的保存。详细步骤见书 p78–81。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | -------------------------------------------------------------- |
|
||||
| 窗体 | `Form1` | 网络摄像机 |
|
||||
| 图片框 | `pictureBox1` | 显示画面;背景色设为黑色,显示方式选"拉伸填满"(StretchImage) |
|
||||
| 标签 | `label1` | 摄像机IP |
|
||||
| 文本框 | `txt_ip` | 输入摄像机的 IP |
|
||||
| 按钮 | `btn_open` | 开(打开后变成"关") |
|
||||
| 按钮 | `btn_up` | ↑(向上转) |
|
||||
| 按钮 | `btn_down` | ↓(向下转) |
|
||||
| 按钮 | `btn_left` | ←(向左转) |
|
||||
| 按钮 | `btn_right` | →(向右转) |
|
||||
| 按钮 | `btn_camera` | 拍照 |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明 4 个全局变量:拍照保存路径 `CameraPath`(static,程序所在目录下的 `Camera/` 文件夹)、摄像机 IP `IpCameraIPAddress`(static)、`bool IsOpen`、存当前画面的 `Image ReadImage`。
|
||||
3. 写一个方法 `ipCameraHelper_ImageRead(ImageReadEvenArgs e)`:把 `e.img` 存进 `ReadImage`,再显示到 `pictureBox1`。
|
||||
4. **Form1_Load**:`Camera` 文件夹不存在就用 `Directory.CreateDirectory` 建一个;用 `+=` 把第 3 步的方法绑定到 `DeviceCtrlHelper.ipCameraHelper.ImageRead` 事件上。
|
||||
5. **btn_open 的 Click**:先把 `IsOpen` 取反;
|
||||
- 打开时:取 `txt_ip` 里的 IP,调用 `ipCameraHelper.Open(IP, "admin", "")` 连接,再 `Start()` 开始传画面,按钮文字改成"关";
|
||||
- 关闭时:`Stop()` 停止传画面,按钮文字改回"开"。
|
||||
6. **四个方向键的 Click**:分别调用 `ipCameraHelper.PanUp()`、`PanDown()`、`PanLeft()`、`PanRight()`。
|
||||
7. **btn_camera 的 Click**:`ReadImage` 不是 null 时,用当前时间做文件名,`ReadImage.Save(...)` 存成 .jpg 到 `CameraPath`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上第 6 步只写了 `using System.IO.Ports;`,可是 `Directory` 要用 `using System.IO;`,不加会编译不过。这个任务不用串口,`System.IO.Ports` 可以不写。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后在 IP 框里填 **192.168.2.50**(填别的 IP 也能连上),点"开",按钮变成"关",图片框里出现东围墙的画面,实训台顶棚的**网络摄像头**亮起。
|
||||
2. 点四个方向键,画面应跟着转动:每点一次转一格,左右各 3 格、上下各 1 格,转到头就不再动。
|
||||
3. 点"拍照",运行区下方出现"程序保存的图片",点开能看到刚拍的照片(只在这次运行中保留)。
|
||||
4. 再点"关",按钮变回"开",画面停止更新。
|
||||
@@ -0,0 +1,42 @@
|
||||
## 任务目标
|
||||
|
||||
做一个报警灯控制程序:选好 ADAM-4150 的串口点"开启",点"开"报警灯亮,点"关"报警灯灭,窗体上的报警灯跟着实训台上的灯一起变。
|
||||
|
||||
练习重点:switch 语句、带参数的方法。详细步骤见书 p84–87。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ---------------- | ---------------------------------------------------------- |
|
||||
| 窗体 | `Form1` | 报警灯 |
|
||||
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
|
||||
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
|
||||
| 按钮 | `btn_alarmClose` | 关(关闭报警灯) |
|
||||
| 分组框 | `groupBox1` | 配置 |
|
||||
| 标签 | `label1` | 报警灯 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
|
||||
> 书上的表格把报警灯控件的名称写成 `Alarm`,那是控件的类型名;代码里用的名字是 `alarm1`,请按代码写,否则自动评分找不到它。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`。
|
||||
2. 声明全局变量 `bool IsOpen = false;`。
|
||||
3. **Form1_Load**:和任务 1 一样,把串口号加进 `com_port`。
|
||||
4. 写一个方法 `AlarmOnOff(string Device, bool IsOpen)`,用 switch 按设备名分支:
|
||||
- `"Alarm"`:调用 `adam4150Helper.OnOff(0, IsOpen)` 控制接在 DO0 的报警灯,再调用 `alarm1.OnOff(IsOpen)` 改窗体上的报警灯;
|
||||
- `"LED1"`、`"LED2"`:分别控制继电器 1、2;
|
||||
- 其他(default):弹出"没有合适的接口程序!"。
|
||||
- 每个分支最后都要有 `break;`。
|
||||
5. **btn_open 的 Click**:把 `IsOpen` 取反,打开时 `adam4150Helper.Open(com_port.Text)`、文字改成"关闭",关闭时 `adam4150Helper.Close()`、文字改回"开启"。这个任务不用定时器。
|
||||
6. **btn_alarmOpen、btn_alarmClose 的 Click**:分别调用 `AlarmOnOff("Alarm", true)`、`AlarmOnOff("Alarm", false)`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> 书上的全局变量又印成了 `boolIsOpen = false;`,`bool` 和 `IsOpen` 之间要有空格,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭"。
|
||||
2. 点"开",窗体上的报警灯和实训台顶棚的**警示灯**应一起亮;点"关",一起灭。
|
||||
@@ -0,0 +1,38 @@
|
||||
## 任务目标
|
||||
|
||||
做一个火情监测窗体:选好串口点"开启"后,程序每 2 秒读一次火焰传感器,在"时间"框里显示当前时间,在"事件"框里显示"发现火情!"或"正常!";再点一次按钮(这时文字是"关闭")就停止采集。
|
||||
|
||||
练习重点:for 循环、带返回值的方法和 return 语句、DateTime.Now、定时器(Timer)。详细步骤见书 p91–94。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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`(数据显示),把时间、事件两行放进去。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;` 和 `using DeviceCtrlLibrary;`,声明全局变量 `bool IsOpen = false;`。
|
||||
2. **Form1_Load**:这次用 **for 循环**,从 0 数到 `SerialPort.GetPortNames().Length - 1`,把每个串口号加进 `com_port`。
|
||||
3. **btn_open 的 Click**:先把 `IsOpen` 取反;打开时调用 `adam4150Helper.Open(com_port.Text)`、启动 `timer1`、按钮改成"关闭";关闭时调用 `Close()`、停止 `timer1`、按钮改回"开启"。
|
||||
4. 写一个方法 `GetFireInfo()`,返回 `String`:`adam4150Helper.SetData().fireValue` 是 `true` 就 `return "发现火情!";`,否则 `return "正常!";`。
|
||||
5. **timer1 的 Tick**:`txt_time` 显示 `DateTime.Now.ToString()`,`txt_even` 显示 `GetFireInfo()` 的返回值。
|
||||
|
||||
> 在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭",2 秒左右"时间"框开始走。
|
||||
2. 在实训台上把**火焰传感器**点成"有火焰",几秒内"事件"框显示"发现火情!";点回"正常",显示"正常!"。
|
||||
3. 点"关闭",按钮变回"开启","时间"框不再变化。
|
||||
@@ -0,0 +1,43 @@
|
||||
## 任务目标
|
||||
|
||||
和任务 1 一样监测灾情,这次读的是烟雾传感器,而且不用定时器,改用**线程**:线程里用 while 循环每 2 秒读一次烟雾传感器,显示时间和"发现烟情"或"正常"。点"关闭"时把线程挂起,再点"开启"让线程继续,关闭窗体时终止线程。
|
||||
|
||||
练习重点:while 循环、线程 Thread(Start、Suspend、Resume、Abort、Thread.Sleep)、窗体的 FormClosing 事件。详细步骤见书 p98–101。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------ | ------------------------ |
|
||||
| 窗体 | `Form1` | 烟雾 |
|
||||
| 标签 | `lbl_port` | 串口 |
|
||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||
| 按钮 | `btn_open` | 开启(打开后变成"关闭") |
|
||||
| 分组框 | `groupBox1` | 数据显示 |
|
||||
| 标签 | `lbl_time` | 时间 |
|
||||
| 文本框 | `txt_time` | 显示当前时间 |
|
||||
| 标签 | `lbl_even` | 事件 |
|
||||
| 文本框 | `txt_even` | 显示有没有烟情 |
|
||||
|
||||
> 界面和任务 1 一样,但**没有定时器**。书上的表格里没有分组框,照图加一个 `groupBox1`(数据显示)。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using System.Threading;`。
|
||||
2. 声明全局变量:`bool IsOpen = false;`、`bool IsFirst = true;`(线程是不是第一次启动)、`Thread thd;`。
|
||||
3. 写线程要运行的方法 `GetData()`:`while (true)` 循环里,`adam4150Helper.SetData().smokeValue` 是 `true` 就显示"发现烟情",否则显示"正常",两种情况都把当前时间写进 `txt_time`;每次循环最后 `Thread.Sleep(2000)`。
|
||||
4. **Form1_Load**:这次用 **foreach** 把串口号加进 `com_port`;然后 `thd = new Thread(GetData);`,再写 `Control.CheckForIllegalCrossThreadCalls = false;`,允许线程直接改窗体上的控件。
|
||||
5. **btn_open 的 Click**:先把 `IsOpen` 取反。
|
||||
- 打开时:打开 ADAM-4150 串口,按钮改成"关闭";`IsFirst` 是 `true` 就把它改成 `false` 并 `thd.Start()`,否则 `thd.Resume()`;
|
||||
- 关闭时:关闭串口,按钮改回"开启",`thd.Suspend()`。
|
||||
6. **Form1 的 FormClosing**:终止线程(见下面"和书上不一样的地方")。在设计器里选中窗体,到 **⚡ 事件** 里双击 FormClosing 生成方法。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **关闭窗体时先让线程继续**:书上 FormClosing 里直接 `thd.Abort();`。如果点过"关闭",线程正处于挂起状态,直接终止会出错(在真的 Visual Studio 里也一样)。要先判断 `thd.ThreadState == ThreadState.Suspended`,是的话先 `thd.Resume();`,再 `thd.Abort();`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM3**(ADAM-4150),点"开启",按钮变成"关闭","时间"框开始走。
|
||||
2. 在实训台上把**烟雾传感器**点成"有烟雾",几秒内"事件"框显示"发现烟情";点回"正常",显示"正常"。
|
||||
3. 点"关闭","时间"框应停住不动;再点"开启",时间接着走,烟雾状态也继续更新。
|
||||
4. 点"关闭"之后直接关掉窗体,不应弹出错误。
|
||||
@@ -0,0 +1,64 @@
|
||||
## 任务目标
|
||||
|
||||
把任务 1、任务 2 合在一起:点"获取"时同时读火焰和烟雾传感器,显示在窗体上,把灾情推送到 LED 屏(实训台的屏和窗体上的 LED 控件都显示);每次发现灾情都记进一个二维数组,点"历史灾情信息"打开第二个窗体,在列表框里列出全部记录。
|
||||
|
||||
练习重点:二维数组、静态类成员、continue 语句、两个窗体、列表框(ListBox)。详细步骤见书 p104–107。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
主窗体 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` | 历史灾情信息 |
|
||||
|
||||
历史记录窗体 Form2:
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------ | ------------- | ------------------ |
|
||||
| 窗体 | `Form2` | 历史灾情记录 |
|
||||
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
|
||||
|
||||
> 书上表 5-6 没列 `btn_get`、`OpenRecord`、`groupBox2`,照图加上。`lstbRecored` 的拼写就是这样(不是 Record),照着写。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点 **+ 新建类**,建 `RecordClass`,写两个静态成员:`string[,] record = new string[50, 2]`(每条记录存时间和内容)、`int recoudeindex = 0`(下一条记录存在第几行)。
|
||||
2. Form1 的代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`;全局变量:`ADAM4150ResModel ResModel;`、`bool IsOpen = false;`、`string LED_MSG = "";`。
|
||||
3. **Form1_Load**:清空两个下拉框,用 for 循环加串口号;遇到 `COM1`(电脑自带的串口)就 `continue` 跳过。
|
||||
4. **btn_Open 的 Click**:打开时调用 `adam4150Helper.Open(com_environmentPort.Text)` 和 `ledHelper.OpenLedCom(com_ledPort.Text)`,按钮改成"关闭";关闭时关闭 ADAM-4150 串口,按钮改回"开启"。
|
||||
5. **btn_get 的 Click**:
|
||||
- 用 `adam4150Helper.SetData()` 读出数据存进 `ResModel`,清空 `LED_MSG`,显示当前时间,火焰、烟雾两个框先写"正常";
|
||||
- `fireValue` 为 `true`:火焰框写"发现火情",`LED_MSG` 加上"发现火情",把时间和内容记进数组当前行,`recoudeindex` 加 1;
|
||||
- `smokeValue` 为 `true`:同样处理,写的是"发现烟情";
|
||||
- 用 `ledHelper.SendLedString(LED_MSG)` 推送到 LED 屏,返回的字符串里没有 "Error" 时,再用 `led1.setLedText(...)` 显示到窗体上。
|
||||
6. 点 **+ 新建窗体**,建 Form2,放列表框 `lstbRecored`。
|
||||
7. **OpenRecord 的 Click**:`new` 一个 Form2 并 `Show()`。
|
||||
8. **Form2_Load**:for 循环从 0 到 `recoudeindex - 1`,把每条记录的"时间 + 内容"加进 `lstbRecored`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **烟雾记录写成了火情**:书上烟雾分支往数组里记的内容是" 发现火情",要改成" 发现烟情",否则历史记录里看不到烟情。
|
||||
|
||||
> **列表框的名字**:书上 Form2_Load 里写的是 `listBox1`,表 5-7 和界面里叫 `lstbRecored`,代码要写 `lstbRecored`。
|
||||
|
||||
> 书上烟雾分支有一行注释只写了一个 `/`,照抄会编译不过,要写成 `//`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后"LED"选 **COM5**(LED 屏),"环境采集"选 **COM3**(ADAM-4150),点"开启"。
|
||||
2. 在实训台上把**火焰传感器**点成"有火焰",点"获取":火焰框显示"发现火情"、烟雾框显示"正常",实训台的 LED 屏和窗体上的 LED 控件都显示"发现火情"。
|
||||
3. 把火焰点回"正常"、**烟雾传感器**点成"有烟雾",再点"获取",火焰框显示"正常"、烟雾框显示"发现烟情"。
|
||||
4. 点"历史灾情信息",列表里应有"发现火情"和"发现烟情"两条,每条前面是时间。
|
||||
@@ -0,0 +1,57 @@
|
||||
## 任务目标
|
||||
|
||||
从 ADAM-4017+ 读出光照度、温度、湿度,点"保存"存进 SQL Server 数据库的 CampusEnvironment 表,在表格里显示全部记录;还能在表格里改数据后点"修改"保存,选中一行点"删除"删掉。
|
||||
|
||||
练习重点:ADO.NET 的 SqlConnection、SqlCommand、SqlDataAdapter,insert / update / delete 语句,DataGridView 绑定数据。详细步骤见书 p119–124。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
这个任务用任务 1 建的**环境数据表**库,并在库里新建 **CampusEnvironment** 表:ID(int,标识列,主键)、light、humidity、temperature(都是 varchar(50))、addtime(datetime)。
|
||||
|
||||
在右上角的**数据库**页面里建表;还没做任务 1,或者数据弄乱了,点**恢复实验初始数据**,选这个实验,库和表会自动建好。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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` 不带,照书上的写,自动评分按这些名字找按钮。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 引用 `System.Data.SqlClient`、`System.IO.Ports`、`DeviceCtrlLibrary`、`DeviceCtrlLibrary.Model`。
|
||||
2. 声明全局变量:连接字符串、SqlConnection、SqlCommand、SqlDataAdapter、DataSet、串口是否打开(bool)、ADAM4017ResModel。
|
||||
3. **Form1_Load**:打开数据库连接,把串口号加进 `com_port`。
|
||||
4. **btn_Open 的 Click**:串口没打开就打开(`adam4017Helper.Open`)并把按钮改成"关闭",否则关闭并改回"开启"。
|
||||
5. **btn_get 的 Click**:`adam4017Helper.SetData()` 读出数据,把 lightValue、temperatureValue、humidityValue 填进三个文本框。
|
||||
6. 写一个 **Databind()** 方法:用 select 语句(列名起中文别名:编号、温度、湿度、光照度、时间)查出全部记录,用 SqlDataAdapter 填进 DataSet,绑定到 `dataGridView1`。
|
||||
7. **btn_save 的 Click**:用 insert 语句把三个值和当前时间存进表,提示"保存成功!",再调用 Databind() 刷新表格。
|
||||
8. **btnUpdate 的 Click**:用 SqlCommandBuilder 生成更新语句,`sqldata.Update(...)` 把表格里改过的数据写回数据库。
|
||||
9. **btnDelete 的 Click**:按表格当前行第一列的编号,用 delete 语句删掉这一行,再刷新表格。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **连接字符串**:平台只看 `Initial Catalog=环境数据表`,服务器名、用户名和密码照书上写或写成 `Data Source=.` 都可以。
|
||||
|
||||
> **insert 要写列名**:书上写的是 `insert into CampusEnvironment values (温度, 湿度, 光照度, 时间)`,没写列名时值按建表的列顺序(light、humidity、temperature)存进去,温度和光照度就存反了。要写成 `insert into CampusEnvironment (temperature, humidity, light, addtime) values (...)`。
|
||||
|
||||
> 书上的 `True` / `False` 大写、中文引号 `”关闭”`、`privatevoid` 都是印刷错误,照抄会编译不过。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**(ADAM-4017+),点"开启"。
|
||||
2. 在实训台上拖动光照、温度、湿度的滑块,点"获取",三个文本框应显示对应的值。
|
||||
3. 点"保存",弹出"保存成功!",表格最后一行是刚存的数据——检查温度、湿度、光照度有没有存进对应的列。
|
||||
4. 在表格里改一个温度,点"修改";点中某一行,点"删除"。到**数据库**页面查询 CampusEnvironment 表,看数据是不是真的变了。
|
||||
@@ -0,0 +1,46 @@
|
||||
## 任务目标
|
||||
|
||||
选一个开始日期和结束日期,点"查询",从任务 2 存的 CampusEnvironment 表里查出这段时间内的记录,显示在表格里。
|
||||
|
||||
练习重点:select 语句和 where … between … and …、日期时间控件(DateTimePicker)、SqlDataAdapter 和 DataSet、DataGridView 绑定数据。详细步骤见书 p127–128。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
这个任务用任务 2 的**环境数据表**库和里面的 **CampusEnvironment** 表(ID、light、humidity、temperature、addtime)。表里要先有几条不同日期的记录,查询才看得出效果。
|
||||
|
||||
还没做任务 2,或者数据弄乱了,到右上角的**数据库**页面点**恢复实验初始数据**,选这个实验:库和表会自动建好,CampusEnvironment 里有 3 条记录,时间是 2024 年 1 月 16 日、18 日、23 日。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||
| ------------ | --------------- | ------------------ |
|
||||
| 窗体 | `Form1` | 查询校园灾情数据 |
|
||||
| 标签 | `LblStartTime` | 开始日期 |
|
||||
| 日期时间控件 | `StartTime` | 选开始日期 |
|
||||
| 标签 | `LblEndTime` | 结束日期 |
|
||||
| 日期时间控件 | `EndTime` | 选结束日期 |
|
||||
| 按钮 | `btnCx` | 查询 |
|
||||
| 分组框 | `groupBox4` | 历史记录 |
|
||||
| 数据表格 | `dataGridView1` | 显示查到的记录 |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 引用 `System.Data.SqlClient` 和 `System.IO.Ports`。
|
||||
2. 声明全局变量:连接字符串 `sqlConnectString`、`SqlConnection mySqlConnection`、`SqlCommand mySqlCmd`、`SqlDataAdapter sqldata`、`DataSet ds`。
|
||||
3. **btnCx 的 Click**:
|
||||
- 创建连接对象并打开;
|
||||
- 写 select 语句:查出 ID、temperature、humidity、light、addtime,列名起中文别名(编号、温度、湿度、光照度、时间),条件是 `addtime BETWEEN '开始' AND '结束'`,开始、结束分别拼进 `StartTime.Value.ToString()` 和 `EndTime.Value.ToString()`;
|
||||
- 用这条命令创建 SqlDataAdapter,`ds = new DataSet();`,`Fill(ds)`;
|
||||
- `dataGridView1.DataSource = ds.Tables[0].DefaultView;`,最后关闭连接。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **要先 new 一个 DataSet**:书上直接 `sqldata.Fill(ds)`,`ds` 还没有创建,运行时会报空引用错误。在 Fill 前面加一行 `ds = new DataSet();`。
|
||||
|
||||
> **连接字符串**:平台只看 `Initial Catalog=环境数据表`,服务器名、用户名和密码照书上写或写成 `Data Source=.` 都可以。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 先按上面准备好数据库(用初始数据最好对照)。
|
||||
2. 运行后开始日期选 **2024 年 1 月 15 日**,结束日期选 **2024 年 1 月 20 日**,点"查询":表格里应有编号 1、2 两条(16 日、18 日),没有 23 日的那条。
|
||||
3. 改成 1 月 21 日到 1 月 31 日再点"查询":表格里只剩编号 3(23 日)。
|
||||
@@ -0,0 +1,63 @@
|
||||
## 任务目标
|
||||
|
||||
做一个"智慧教室"控制窗体:点"启用"后,程序每 2 秒读一次光照度、温度、湿度并显示出来,再按读数自动控制设备——光线暗就开灯、太亮就关灯;太热或太冷就开空调并调好温度,否则关空调。灯也可以点开关图片手动开关。
|
||||
|
||||
练习重点:滑动条(TrackBar)的 Minimum / Maximum / Value,图片框(PictureBox)显示项目资源里的图片,定时器 Tick 里的 if / else if 判断。详细步骤见书 p133–137。
|
||||
|
||||
## 先添加项目资源
|
||||
|
||||
开关图片用 `switch_on`(开)和 `switch_off`(关)。书上是"项目属性 → 资源 → 添加资源",平台上点代码上方的**项目资源**,在素材库里勾选这两张图(名字和书上一样,不能自己上传图片)。
|
||||
|
||||
加好后,图片框的**图片**属性可以直接选 `Resources.switch_off`;代码里写 `Properties.Resources.switch_on`。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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℃)照书上的图摆好,名字不影响评分。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||
2. 声明全局变量:串口是否打开、灯是否打开、空调是否打开(三个 bool,都是 false),以及 `ADAM4017ResModel ResModel`。
|
||||
3. **Load**:用 `SerialPort.GetPortNames()` 把串口号加进 `com_import`。
|
||||
4. **btn_apply 的 Click**:
|
||||
- 没打开时:用 `com_import.Text` 打开 `adam4017Helper` 和 `adam4150Helper`,按钮改成"关闭",`lbl_mode` 改成"智慧教室已开启",启动 `timer`;
|
||||
- 已打开时:关闭两个采集器,停止 `timer`,文字改回"启用""智慧教室将开启"。
|
||||
5. **timer 的 Tick**:
|
||||
- `adam4017Helper.SetData()` 读出数据,把 lightValue、temperatureValue、humidityValue 填进三个文本框;
|
||||
- 光照度(用 `Convert.ToInt16` 转成数字)**小于 400** 开灯:图片换成 switch_on,`lamp.OnOff(true)`,`adam4150Helper.OnOff(1, true)`,记下灯已打开;**大于 800** 关灯(反过来做一遍);
|
||||
- 温度**高于 30℃**:空调图片换成 switch_on,`trb_airconditione.Value = 26`;**低于 5℃**:图片换成 switch_on,`Value = 25`;其他情况:图片换成 switch_off,`Value = 18`。
|
||||
6. **pic_lightsStatus 的 Click**:灯开着就关(图片、灯泡、实训台照明灯一起关),关着就开。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **手动开关灯的判断写反了**:书上 pic_lightsStatus_Click 写的是 `if (!IsLampOpen) { 关灯 }`,这样灯关着时点了还是关。应该是 `if (IsLampOpen) { 关灯 } else { 开灯 }`。
|
||||
|
||||
> **滑动条要设最小值 18、最大值 32**:书上的表格没写。滑动条默认最大值是 10,代码里把 Value 设成 26 时程序会出错。
|
||||
|
||||
> 书上表格里温度文本框写的是 `txt_temperation`,代码里用的是 `txt_temperature`,请按代码写,自动评分按 `txt_temperature` 找。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的)。
|
||||
2. 在实训台上拖动光照、温度、湿度的滑块,2 秒内三个文本框应跟着变。
|
||||
3. 把光照拖到 400 以下,窗体上的灯泡和实训台 ADAM-4150 的 **DO1 照明灯**应一起亮;拖到 800 以上应熄灭。
|
||||
4. 把温度拖到 30℃ 以上,空调滑块应停在 26;拖到 5℃ 以下停在 25;其他温度回到 18。
|
||||
5. 点灯光的开关图片,灯应该一下开、一下关。
|
||||
@@ -0,0 +1,71 @@
|
||||
## 任务目标
|
||||
|
||||
做一个"智慧寝室"控制窗体:点"开启情景模式"后,程序每秒显示当前时间和光照度、温度、湿度,并按时间段自动切换四种情景模式,控制灯光、窗帘和广播:
|
||||
|
||||
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
|
||||
| ----------- | -------- | ---- | -------------- | ------ |
|
||||
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
|
||||
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
|
||||
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
|
||||
| 其他时间 | 熄灯模式 | 关 | 10(全关) | 关 |
|
||||
|
||||
练习重点:DateTime 的比较,自己写方法(CheckMode、PlayMp3),用线程(Thread)播放声音,媒体播放器控件。详细步骤见书 p141–145。
|
||||
|
||||
## 先添加项目资源
|
||||
|
||||
点代码上方的**项目资源**,在素材库里勾选开关图片 `switch_on`、`switch_off` 和铃声 `ring`(就是书上的 ring.mp3,可以先试听)。名字和书上一样,不能自己上传。
|
||||
|
||||
广播图片框的**图片**属性选 `Resources.switch_off`。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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 |
|
||||
|
||||
其余标签(当前时间、串口、光照度、温度、湿度、窗帘、全开、全关、广播、灯光)照书上的图摆好,名字不影响评分。
|
||||
|
||||
媒体播放器在工具箱的"物联网设备"里,直接拖到窗体上就行,不用像书上小贴士那样去"选择工具箱项"添加 COM 组件。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`、`using System.Threading;`。
|
||||
2. 声明全局变量:串口、灯、广播是否打开(三个 bool),`ADAM4017ResModel ResModel`,`DateTime now`,以及四个时间点:起床 `DateTime.Today.AddHours(6.75)`、上课 `AddHours(7.5)`、回寝 `AddHours(21.0)`、熄灯 `AddHours(22.0)`。
|
||||
3. **Load**:清空 `com_import`,把串口号加进去。
|
||||
4. **btn_apply 的 Click**:没打开时打开 `adam4017Helper`、`adam4150Helper`,按钮改成"停止情景模式",启动 `timer`;打开着就反过来。
|
||||
5. 写 **PlayMp3()** 方法:广播图片换成 switch_on,给媒体播放器的 `URL` 设铃声文件的路径(`AppDomain.CurrentDomain.BaseDirectory + "//Resources//ring.mp3"`),调用 `Ctlcontrols.play()`,`Thread.Sleep(4000)` 等 4 秒,图片换回 switch_off。
|
||||
6. 写 **CheckMode()** 方法:用 `now` 和四个时间点比较,判断现在是哪种模式,`lbl_mode` 显示"智慧寝室正处于××模式",再按上面的表格控制:
|
||||
- 开灯 / 关灯:`adam4150Helper.OnOff(1, ...)` 和 `lamp.OnOff(...)`,只在状态要变时才做;
|
||||
- 放铃声:广播还没开时 `new Thread(PlayMp3).Start()`,让铃声在另一个线程里放,不卡住界面;关广播:图片换成 switch_off;
|
||||
- 窗帘:设 `trb_curtain.Value`。
|
||||
7. **timer 的 Tick**:`now = DateTime.Now`,`txt_time` 显示 `now.ToString("HH:mm:ss")`,读出数据填进三个文本框,最后调用 `CheckMode()`。
|
||||
|
||||
## 在平台上怎么用时钟和广播
|
||||
|
||||
- **时钟**在实训台上:填一个时刻点"拨到",程序里的 `DateTime.Now` 就从这个时刻往后走;还可以选 10×、60× 倍速,点"恢复真实时间"回到现在。不用等到早上 6:45 才能测试起床模式。
|
||||
- 拨时间时**日期别动**:四个时间点是程序启动时按"今天"算好的,改了日期就对不上了。
|
||||
- 程序放铃声时,实训台上的**广播**喇叭亮起,浏览器里能听到声音(电脑要开音量)。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**,点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
|
||||
2. 在实训台的时钟上拨到 **06:50:00**:应显示起床模式,ADAM-4150 的 **DO1 照明灯**亮,窗帘滑块到 5,广播响一声铃。
|
||||
3. 拨到 **08:00:00**:上课模式,灯灭,窗帘到 0。
|
||||
4. 拨到 **21:30:00**:回寝模式,灯亮,窗帘到 10,广播响铃。
|
||||
5. 拨到 **23:00:00**:熄灯模式,灯灭,窗帘到 10。
|
||||
6. 也可以拨到 6:44:50,看程序到 6:45 时自己切进起床模式。
|
||||
@@ -0,0 +1,84 @@
|
||||
## 任务目标
|
||||
|
||||
做一个"智慧会议室"预约窗体:程序一启动就每秒显示当前时间和光照度、温度、湿度、气压、空气质量。填好时、分、秒,选一种模式(会议、讨论、展示),点"预约";到了预约时间,程序自动开启这种模式,控制灯光、窗帘和主、客话筒:
|
||||
|
||||
| 模式 | 灯光 | 窗帘(滑动条) | 主话筒 | 客话筒 |
|
||||
| ---- | ---- | -------------- | ------ | ------ |
|
||||
| 会议 | 开 | 0(全开) | 开 | 关 |
|
||||
| 讨论 | 开 | 0(全开) | 开 | 开 |
|
||||
| 展示 | 开 | 10(全关) | 开 | 关 |
|
||||
|
||||
勾选"拍照记录"时,预约时间到了还要打开网络摄像头,每 5 分钟存一张照片。点"退出"关掉所有设备。
|
||||
|
||||
练习重点:DateTime 的计算和比较(AddHours、AddMinutes、AddSeconds),switch 语句,用 `+=` 订阅事件。详细步骤见书 p148–153。
|
||||
|
||||
## 先添加项目资源
|
||||
|
||||
点代码上方的**项目资源**,在素材库里勾选 `switch_on`、`switch_off` 两张开关图片(名字和书上一样,不能自己上传)。三个开关图片框的**图片**属性都选 `Resources.switch_off`。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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₂ 浓度一行,但代码里没有用到,可以不做。
|
||||
|
||||
> 窗体名:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `frm_control`,类名、文件名和 Program.cs 会跟着改。先改名再双击窗体,生成的 Load 事件才叫 `frm_control_Load`。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 代码开头加上 `using System.IO;`、`using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using DeviceCtrlLibrary.Model;`。
|
||||
2. 声明全局变量:灯、主话筒、客话筒是否打开,模式是否已经开启(`IsInitialized`),`ADAM4017ResModel ResModel`,`DateTime now`,预约时间 `reserve`(先设成 `DateTime.Now.AddDays(1)`,也就是明天,免得一启动就"到时间"),摄像头读到的图片 `Image ReadImage = null`。
|
||||
3. **Load**:
|
||||
- 清空两个下拉框,把串口号加进 `com_import`,`com_mode` 加入"会议""讨论""展示";
|
||||
- 启动 `timer`;
|
||||
- 程序目录下没有 `Camera/` 文件夹就建一个(`Directory.Exists`、`Directory.CreateDirectory`);
|
||||
- 用 `+=` 给 `ipCameraHelper.ImageRead` 绑定一个方法,在方法里把 `e.img` 存进 `ReadImage`。
|
||||
4. **btn_reserve 的 Click**:`reserve = DateTime.Today`,再依次加上三个文本框里的时、分、秒(`Convert.ToInt16` 转成数字)。
|
||||
5. 写 **ChooseMode()** 方法:`lbl_mode` 显示"智慧会议室已开启××模式",用 `switch (com_mode.Text)` 按上面的表格设置灯(图片、`lamp`、`adam4150Helper.OnOff(1, ...)`)、`trb_curtain.Value` 和两个话筒的图片。
|
||||
6. **timer 的 Tick**:
|
||||
- 用 `com_import.Text` 打开 `adam4017Helper`、`adam4150Helper`;
|
||||
- `now = DateTime.Now`,`lbl_time` 显示 `HH:mm:ss`,读出数据填进五个文本框;
|
||||
- `now >= reserve` 时:模式还没开启就调用 `ChooseMode()`,勾了"拍照记录"就打开并启动摄像头(`ipCameraHelper.Open(txt_camera.Text, "admin", "")`、`Start()`),然后记下已开启;分钟能被 5 整除且秒是 0 时,把 `ReadImage` 存到 `Camera/` 文件夹,文件名用当前时间。
|
||||
7. **三个开关图片框的 Click**:灯、主话筒、客话筒各自开着就关、关着就开(灯还要控制 `lamp` 和实训台的照明灯)。
|
||||
8. **btn_exit 的 Click**:停止 `timer`,`reserve` 改回明天,"拍照记录"设为不可用,关灯、关两个话筒,`trb_curtain.Value = 0`,`IsInitialized` 改回 false,关闭两个采集器和摄像头。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **书上 Load 里没有把串口号加进 `com_import`**(注释写了,代码漏了),下拉框是空的,选不了串口。要补上 `foreach (string item in SerialPort.GetPortNames())` 那几行。
|
||||
|
||||
> 书上表格里温度文本框写的是 `txt_temperation`,代码里用的是 `txt_temperature`,请按代码写,自动评分按 `txt_temperature` 找。
|
||||
|
||||
## 在平台上怎么用时钟
|
||||
|
||||
- 实训台上的**时钟**可以拨到任意时刻:填好时刻点"拨到",程序里的 `DateTime.Now` 就从这个时刻往后走,点"恢复真实时间"回到现在。不用真的等到下午 1 点 20 分。
|
||||
- 拨时间时**日期别动**:预约时间是按"今天"算的。
|
||||
- 摄像头拍的照片会出现在运行区下方的"程序保存的图片"里,只在这次运行中保留。"每 5 分钟拍照"这一项自动评分不检查,由老师看代码评阅。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。
|
||||
2. 把时钟拨到 **13:19:54**;模式选"讨论",时分秒填 13、20、00,点"预约"。
|
||||
3. 预约时间没到时,ADAM-4150 的 **DO1 照明灯**不亮;到 13:20:00 后,应显示"智慧会议室已开启讨论模式",照明灯亮,窗帘滑块到 0,主、客话筒都是开。
|
||||
4. 点"退出",灯、话筒都关掉。想再试一次,重新运行程序。
|
||||
@@ -0,0 +1,38 @@
|
||||
## 任务目标
|
||||
|
||||
开始做项目 8 的"智慧校园管理平台":先做主界面。主界面没有标题栏,上面一条蓝色标题栏放"智慧校园"标题图,下面是校园背景图,背景上有"校园环境""校园安防""退出"三个图标;点"退出"关闭程序。后面四个任务都在这个程序上接着做。
|
||||
|
||||
练习重点:无边框窗体(FormBorderStyle)、面板(Panel)的停靠(Dock)、背景图片、图片框(PictureBox)和项目资源里的图片。详细步骤见书 p157–159。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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**,否则图标四周会露出一块灰色。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点代码上方的**项目资源**,勾选 `ico_title`、`bg_home`、`icon_02`、`icon_03`、`icon_04` 五张图片。
|
||||
2. 设置窗体:无边框、屏幕居中。
|
||||
3. 拖两个面板:panel1 停靠 Top 当标题栏,里面放标题图;panel2 停靠 Fill,设背景图。
|
||||
4. 在 panel2 上放三个图片框,按上表设名字、图片和透明背景。
|
||||
5. 双击 `pic_exit`,在它的 Click 里写 `this.Close();`。窗体没有标题栏和关闭按钮,只能靠这个图标退出程序。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **图片从项目资源里选**:书上第 2 步把配套资料里的 Resources 文件夹复制到工程里。平台上点**项目资源**,从素材库里勾选同名图片(平台自己画的,名字和尺寸与书上一致),设计器里"图片""背景图片"属性就能选到它们。
|
||||
|
||||
> **窗体名 Main**:在设计器里点窗体空白处,把属性窗口的**名称**从 `Form1` 改成 `Main`,类名、文件名和 Program.cs 会跟着改。任务 2 的自动评分要找到 Main 窗体。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后,运行区显示没有标题栏的主界面:蓝色标题栏上是"智慧校园",下面是校园背景和三个图标(窗体比运行区大时会按比例缩小,点**放大**看得更清楚)。
|
||||
2. 点"退出"图标,程序应结束。
|
||||
@@ -0,0 +1,56 @@
|
||||
## 任务目标
|
||||
|
||||
在任务 1 的程序里新建"校园环境"窗体 `Environment`,把界面搭好:顶部标题栏和"<返回主窗体",中间是路灯场景的背景图,底部一栏显示火焰、烟雾、光照度、温度、湿度,右边是"路灯控制"和"LED推送"两组控件,左下方是显示一周温度的折线图。这个任务**只做界面**,采集数据、控制路灯、推送 LED 在任务 3 做。
|
||||
|
||||
练习重点:多窗体、面板的多层停靠(Top / Fill / Bottom / Right)、透明背景、复选框、图表(Chart)的系列和图例。详细步骤见书 p159–164。
|
||||
|
||||
> 接着任务 1 的程序做。为了能看到效果,这次先让主界面的"校园环境"能打开窗体、"<返回主窗体"能关闭窗体(书上放在后面的任务里)。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
| 控件 | 名称(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 的代码和自动评分都用这些名字。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. **项目资源**里再勾选 `day_02`、`bg_from2`、`switch_off`(任务 3 还要用 `day_01`、`switch_on`,可以一起勾上)。
|
||||
2. 点 **+ 新建窗体**,建 `Environment`,设成无边框、屏幕居中,调到合适的大小。
|
||||
3. 按"标题栏 → panel2 → panel3 → panel_bg"的顺序拖面板,设好停靠。先放的面板先占地方,放错了可以在属性里改停靠。
|
||||
4. 在 panel_bg 上依次放底部的 panel5(环境数据栏)和右边的 panel_form,再把两组控件放进 panel_form 里的两个分组框。书上要求 panel_form 里的其他控件背景色也设成 Transparent,让背景图透出来。
|
||||
5. 拖一个**图表**到 panel_bg 上,在属性窗口点**系列和图例**:
|
||||
- 图例两个:`MaxLegend`、`AVGLegend`;
|
||||
- 系列两个:`MaxSeries`(图表类型 Line,图例 MaxLegend,图例文字"最大值",X 值字段 `_Time`,Y 值字段 `MaxTemperature`)和 `AVGSeries`(Line,AVGLegend,"平均值",`_Time`,`AVGTemperature`)。
|
||||
6. 主窗体 `pic_environment` 的 Click:`new Environment().Show();`。
|
||||
7. `lb_return` 的 Click:`this.Close();`(只关掉这个窗体,主窗体还在)。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **背景图片叫 `bg_from2`**:书上写的是 `bg_form2.png`,配套资料里的文件名是 `bg_from2`,素材库也用这个名字。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后在主界面点"校园环境",打开 Environment 窗体:背景是路灯场景,底部有五个文本框和"数据分析""推送天气信息",右边有"路灯控制""LED推送"两组,左下方有图表(现在还没有数据)。
|
||||
2. 点"< 返回主窗体",Environment 关闭,主界面还在。
|
||||
@@ -0,0 +1,76 @@
|
||||
## 任务目标
|
||||
|
||||
让任务 2 做好的"校园环境"界面动起来:打开窗体后,后台线程每隔 5 秒多采集一次环境数据,显示在底部的文本框里并存进数据库;路灯可以点开关手动控制,也可以按光照度自动开关,开灯时背景换成亮灯的场景;能把天气信息、自己输入的文字推送到 LED 屏,勾选"自动推送火情"后有火情自动推送;点"数据分析"用折线图显示最近 7 天每天的最高温度和平均温度。
|
||||
|
||||
练习重点:自己写类(AppSet、SQLHelper、SQLBLL)、后台线程(Thread)和 SynchronizationContext 回到界面线程更新控件、参数化的 SQL 语句、图表绑定数据(DataSource + DataBind)、复选框的 CheckedChanged 事件。详细步骤见书 p164–174。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
这个任务用 **WisdomCampus** 库(书上配套资料里附带的库):EnvironmentData 表存环境数据,SecurityData 表存报警图片(任务 4、5 用),Users 表存用户。平台已经按书上的表结构建好,并预置了 7 天的环境数据,"数据分析"一打开就有图。
|
||||
|
||||
第一次运行这个实验时,平台会自动建好这个库;数据弄乱了,到右上角的**数据库**页面点**恢复实验初始数据**,选这个实验。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
界面在任务 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` | 自动推送火情 |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. 点 **+ 新建类**,建 `AppSet`:四个串口(ADAM-4017+ 是 COM2、ADAM-4150 是 COM3、ZigBee 是 COM4、LED 屏是 COM5)、摄像机的 IP / 用户名 / 密码(任务 4 用)、数据库连接字符串,都写成 `public static string`。
|
||||
2. 再建 `SQLHelpers/SQLHelper` 和 `SQLHelpers/SQLBLL` 两个类(带文件夹,命名空间是 `智慧校园.SQLHelpers`):
|
||||
- SQLHelper:`getData` 执行查询返回 DataSet,`Exec` 执行增删改返回影响的行数(出错返回 −1),`SaveData` 用 SqlCommandBuilder 保存 DataTable;
|
||||
- SQLBLL:`EnvironmentUpData` 把一次采集的数据 insert 进 EnvironmentData,`GetEnvironmentGraphData` 按天分组查出最近 7 天的最高温度和平均温度,`SecurityUpData`、`GetSecurityData` 存取报警图片(任务 4、5 用);
|
||||
- 在 SQLBLL 文件里再写一个 `SQLRes` 类(Msg、Result 两个属性)当返回结果。
|
||||
3. **Main_Load**:在 try 里依次打开 ADAM-4017+、ADAM-4150、ZigBee、LED 屏的串口(用 AppSet 里的串口号)。
|
||||
4. **Environment_Load**:隐藏 chart1,创建线程运行 `Tick` 方法,记下 `WindowsFormsSynchronizationContext.Current`,启动线程。
|
||||
5. **Tick()**:`while (true)` 循环,每次:
|
||||
- 读 ADAM-4017+ 和 ADAM-4150 的数据;
|
||||
- 用 `_main.Send(...)` 回到界面线程,把火焰、烟雾、光照、温度、湿度显示到文本框;
|
||||
- 勾了自动控制时,光照低于设定值就 `LampOnOff(1, true)`,否则 `LampOnOff(1, false)`;
|
||||
- 勾了自动推送火情时,火焰和烟雾都有推送"发生严重火灾",只有火焰推送"发现火情",只有烟雾推送"发现烟情";
|
||||
- 调用 `SQLBLL.EnvironmentUpData(...)` 存数据,`Thread.Sleep(5000)`。
|
||||
6. **LampOnOff(int RelayNum, bool IsOpen)**:调用 `zigBeeHelper.OnOff` 开关路灯,再用 `_main.Send` 换图片:开灯时开关换成 `switch_on`、背景换成 `day_01`,关灯时换回 `switch_off`、`day_02`。
|
||||
7. **Environment_FormClosed**:停止采集线程。
|
||||
8. **btn_dataAnalyse 的 Click**:文字是"数据分析"时,把 `GetEnvironmentGraphData()` 的结果给 `chart1.DataSource`,`DataBind()`,显示图表,文字改成"关闭图表";反过来隐藏图表、改回"数据分析"。
|
||||
9. **两个"推送天气信息"按钮**:推送"当前温度:…℃ 湿度:…%"。
|
||||
10. **pic_lampOpen 的 Click**:按 `IsLampOpen` 开灯或关灯,然后把 `IsLampOpen` 取反。
|
||||
11. **btn_ledSendMsg 的 Click**:推送 `txt_ledMsg` 里的文字。
|
||||
12. **txt_autoLight 的 TextChanged**:用 `double.TryParse` 把输入的数存进 `setLight`。
|
||||
13. **ch_autoLamp 的 CheckedChanged**:`IsAuto_Lamp = ch_autoLamp.Checked;`
|
||||
14. **ch_autoLedFire 的 CheckedChanged**:`IsAuto_FireMsg = ch_autoLedFire.Checked;`
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **第 23 步的控件名写错了**:书上说在 `ch_autoLamp` 的 CheckedChanged 里写 `IsAuto_FireMsg = ch_autoLedFire.Checked;`,应该是 **`ch_autoLedFire`** 的 CheckedChanged(双击"自动推送火情"复选框生成)。写到 ch_autoLamp 里,"自动推送火情"就不起作用。
|
||||
|
||||
> **连接字符串**:平台只看 `Initial Catalog=WisdomCampus`,服务器名、用户名和密码照书上写就行。
|
||||
|
||||
> 书上 `GetSecurityData` 最后一行印成了 `eturn ImgDictionary;`,要写成 `return`。
|
||||
|
||||
> 书上关窗体时用的 `thread.Suspend()` 在新版 .NET 里已经不能用,平台做了兼容,照书上写就行。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点"校园环境"。在实训台上拖动温度、湿度、光照滑块,8 秒内文本框里的数应跟着变;点火焰、烟雾,"火焰""烟雾"应显示"发生火情""发生烟情"。
|
||||
2. 点灯光控制的开关:实训台的**照明灯 1** 亮起,开关和背景换成开灯的图片;再点一次全部换回来。
|
||||
3. 在"设定开灯光照值"里输入 400,勾选"自动控制"。把光照拖到 200,几秒内路灯自动亮;拖到 800,路灯自动熄灭。
|
||||
4. 点"推送天气信息",实训台的 LED 屏显示"当前温度:26℃ 湿度:50%"这样的文字;在"LED信息"里输入文字,点"推送LED信息",LED 屏显示这段文字。
|
||||
5. 勾选"自动推送火情",点实训台的火焰,LED 屏显示"发现火情";再点烟雾,显示"发生严重火灾"。
|
||||
6. 点"数据分析",出现两条折线(最大值、平均值),按钮变成"关闭图表";再点一次图表隐藏。到**数据库**页面查询 EnvironmentData 表,能看到程序刚存进去的数据。
|
||||
@@ -0,0 +1,58 @@
|
||||
## 任务目标
|
||||
|
||||
在主界面上接通"校园安防":新建 `Security` 窗体,打开时连上网络摄像机显示实时画面,后台线程每秒检查一次红外对射,有人闯入时显示"发现入侵"并把当前画面存进数据库;四个方向键控制摄像机转动;"拍照"把画面存成图片文件、同时存进数据库;返回主窗体时关掉摄像机。"查看历史图像"按钮先放好,任务 5 再做。
|
||||
|
||||
练习重点:摄像机的 ImageRead 事件、后台线程、图片框的鼠标按下 / 松开事件(MouseDown / MouseUp)、换背景图片、保存图片文件。详细步骤见书 p174–180。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
用任务 3 的 **WisdomCampus** 库,入侵和拍照的图片存进 **SecurityData** 表(报警时间 EvenTime、图片 EvenImage),存的方法是任务 3 写好的 `SQLBLL.SecurityUpData`。还没有这个库或数据弄乱了,到**数据库**页面点**恢复实验初始数据**,选这个实验。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
接着任务 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 |
|
||||
| 校园安防 | `pic_up`、`pic_down`、`pic_left`、`pic_right` | 放在方向盘上的四个方向键,背景图片 `btn_arrow_up_normal` 等,背景排列 Stretch |
|
||||
| 校园安防 | `btn_camera` | 拍照 |
|
||||
| 校园安防 | `btn_history` | 查看历史图像(任务 5 再写代码) |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. **项目资源**里再勾选 `icon_monitor`、`panel_video_ctrl` 和四个方向键的 `btn_arrow_*_normal`、`btn_arrow_*_press`(共 8 张)。
|
||||
2. 点 **+ 新建窗体** 建 `Security`,按上表搭界面。
|
||||
3. 主窗体 `pic_security` 的 Click:`new Security().Show();`。
|
||||
4. 在 Security 里引用 `System.IO`、`System.Threading`、`DeviceCtrlLibrary`、`智慧校园.SQLHelpers`,声明字段:入侵标志 `IsInfrared`、图片文件夹 `ImgDir`(程序目录下的 Camera 文件夹)、线程、SynchronizationContext、当前画面 `img`。
|
||||
5. 写 **ipCameraHelper_ImageRead(ImageReadEvenArgs e)**:把 `e.img` 记到 `img`,并显示到 `pic_video`。
|
||||
6. 写 **tick()**:`while (true)` 循环,读 ADAM-4150 的 `infraredValue`,用 `_main.Send` 把"发现入侵"或"正常"显示到 `lb_infrared`;有入侵且有画面时调用 `SQLBLL.SecurityUpData(img)`;`Thread.Sleep(1000)`。
|
||||
7. **Security_Load**:打开摄像机(AppSet 里的 IP、用户名、密码),挂上 ImageRead 事件,`Start()`;启动 tick 线程;Camera 文件夹不存在就建一个。
|
||||
8. **Security_FormClosed**:摄像机 `Stop()`、`Close()`,停止线程。
|
||||
9. **btn_camera 的 Click**:把 `pic_video.Image` 存成 `ImgDir + 年月日_时分秒 + ".jpg"`,再调用 `SQLBLL.SecurityUpData` 存进数据库。
|
||||
10. **四个方向键**:在 ⚡ 事件里双击"鼠标按下"和"鼠标松开"生成方法。按下时换成 `_press` 图片并调用 `PanUp()`(或 PanDown / PanLeft / PanRight),松开时换回 `_normal` 图片并再调用一次同一个方法。
|
||||
11. **lb_return 的 Click**:`this.Close();`。
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **摄像机每次调用转一格**:平台的摄像机左右 7 格、上下 3 格,开始在正中,`PanUp()` 等方法每调用一次转一格。书上按下、松开各调用一次,所以点一下方向键会转两格。
|
||||
|
||||
> **面板的名字**:示例(取自配套工程)里白色的大面板叫 `panel_bg_video`、灰色视频区叫 `panel2`,书上叫 `panel_bg`、`panel_bg_video`。面板的名字不评分,照书上起名也可以。
|
||||
|
||||
> 书上的路径 `"Camera\\"` 照写就行,平台会换成服务器上能用的路径。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 运行后点"校园安防",打开 Security 窗体,几秒内 `pic_video` 里出现摄像机画面(校园东围墙)。
|
||||
2. 点实训台的**红外对射**遮挡光束,"入侵:"后面显示"发现入侵",画面里出现翻墙的人;再点一次恢复,显示"正常"。
|
||||
3. 点四个方向键,画面跟着转;松开后方向键换回原来的图片。把鼠标放在实训台的**网络摄像头**上能看到云台的位置。
|
||||
4. 点"拍照",运行区下面的"程序保存的图片"里多出一张 Camera 文件夹下的照片(文件名是拍照的日期和时间);到**数据库**页面查询 SecurityData 表,能看到拍照和入侵时存的记录。
|
||||
5. 点"< 返回主窗体",Security 关闭,摄像机也关闭。
|
||||
@@ -0,0 +1,54 @@
|
||||
## 任务目标
|
||||
|
||||
做"历史报警记录"窗体 `SecurityCameraData`:在校园安防界面点"查看历史图像"打开它,按开始时间、结束时间从数据库里查出这段时间拍下的照片(拍照和入侵时存的),一次显示一张和它的拍摄时间,用左右箭头前后翻;查不到就显示"NO DATA"。
|
||||
|
||||
练习重点:ShowDialog 打开对话框式的窗体、日期时间选择器(DateTimePicker)、Dictionary 和 List 集合、运行时改控件的父容器(Parent)和位置(Location)。详细步骤见书 p181–185。
|
||||
|
||||
## 先准备数据库
|
||||
|
||||
照片在 **WisdomCampus** 库的 **SecurityData** 表里,任务 4 的拍照和入侵检测会往里存;初始数据里这张表是空的,先在校园安防界面拍几张照片再查。查询用任务 3 写好的 `SQLBLL.GetSecurityData(开始日期, 结束日期)`,它返回"拍摄时间 → 照片"的 Dictionary。
|
||||
|
||||
## 界面和控件名
|
||||
|
||||
接着任务 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 |
|
||||
|
||||
> 注意 `pic_up` 是**左**箭头(上一张)、`pic_down` 是**右**箭头(下一张),名字照书上写。
|
||||
|
||||
## 实现步骤
|
||||
|
||||
1. **项目资源**里再勾选 `windowstate_closed_hover`、`arrow_left_normal`、`arrow_right_normal`。
|
||||
2. 点 **+ 新建窗体** 建 `SecurityCameraData`,按上表搭界面。
|
||||
3. 校园安防 `btn_history` 的 Click:`new SecurityCameraData().ShowDialog();`。
|
||||
4. 引用 `DeviceCtrlLibrary`、`智慧校园.SQLHelpers`,声明字段:照片集合 `ImgDictionary`(`Dictionary<string, Image>`)、当前下标 `index = -1`、当前照片 `img`、时间列表 `Keys`(`List<string>`)。
|
||||
5. 写 **ImageChanged(char UpOrDown = '+')**:`'+'` 时下标加 1、`'-'` 时减 1,超过最后一张回到第一张、小于 0 回到最后一张;按 `Keys[index]` 从集合里取出照片显示到 `pic_camera`,时间显示到 `lb_time`。
|
||||
6. 写 **GetData()**:用两个日期时间选择器的 Value 调用 `SQLBLL.GetSecurityData`,下标设回 −1,清空 `Keys`;没有照片就显示 `lb_noData` 并返回,有照片就隐藏它,把所有时间加进 `Keys`,调用 `ImageChanged()` 显示第一张。
|
||||
7. **SecurityCameraData_Load**:把 `pic_up`、`pic_down` 的 Parent 设成 `pic_camera`,位置分别设为 (20, 0) 和 (560, 0),然后调用 `GetData()`。
|
||||
8. **btn_get 的 Click**:`GetData();`
|
||||
9. **pic_up 的 Click**:`ImageChanged('-');` **pic_down 的 Click**:`ImageChanged();`
|
||||
10. **pic_close 的 Click**:`this.Close();`
|
||||
|
||||
## 和书上不一样的地方
|
||||
|
||||
> **每次查询前清空 Keys**:书上的 GetData 没有 `Keys.Clear();`,查第二次时 Keys 里还留着上次的时间,翻页时照片和时间就对不上了。在下标设回 −1 的地方加上 `Keys.Clear();`。
|
||||
|
||||
## 在平台上怎么试
|
||||
|
||||
1. 在实训台的**时钟**里拨到 9:00:00,运行后打开校园安防,等画面出来点"拍照";再拨到 10:30:00,再拍一张。
|
||||
2. 点"查看历史图像",打开历史报警记录窗体:两个日期默认都是今天,显示第一张照片,右下角时间是 9:00 左右,"NO DATA"不显示。
|
||||
3. 点右箭头显示 10:30 那张,再点回到第一张;点左箭头往回翻。
|
||||
4. 开始时间选 2024-01-01,点"查询",还能查到今天的照片;结束时间再改成 2024-01-31,点"查询",显示"NO DATA"。
|
||||
5. 点右上角的关闭按钮,回到校园安防界面。
|
||||
+11
-7
@@ -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}` } }))],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
+51
-9
@@ -25,7 +25,7 @@ import { getReview, gradebook, manualScore, saveReview, studentDetail, submissio
|
||||
import type { GradingQueue } from './grading/queue'
|
||||
import { config } from './config'
|
||||
import { deleteUserDatabases, restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||
import { listSamples, loadChecks, loadSample } from './samples'
|
||||
import { listSamples, loadChecks, loadSample, loadTask } from './samples'
|
||||
import { SqlError, sqlService } from './sql-service'
|
||||
import { serveStatic } from './static'
|
||||
import { deleteWork, getWork, saveWork, validFiles, validScreenshots } from './works'
|
||||
@@ -112,6 +112,9 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
return c.json(parseChecks(checks).map((check) => check.title))
|
||||
})
|
||||
|
||||
/** 实验的任务说明(Markdown);没有时 task 为 null */
|
||||
app.get('/api/samples/:id/task', async (c) => c.json({ task: await loadTask(c.req.param('id')) }))
|
||||
|
||||
app.post('/api/samples/:id/selfcheck', async (c) => {
|
||||
const sampleId = c.req.param('id')
|
||||
const yaml = (await listSamples()).some((s) => s.id === sampleId) ? await loadChecks(sampleId) : null
|
||||
@@ -202,11 +205,18 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
)
|
||||
})
|
||||
|
||||
app.get('/api/assignments/:id', (c) => {
|
||||
app.get('/api/assignments/:id', async (c) => {
|
||||
const assignment = visibleAssignment(c)
|
||||
if (!assignment) return c.json({ message: '作业不存在' }, 404)
|
||||
const userId = c.get('user').id
|
||||
return c.json({ assignment, template: assignmentTemplate(db, assignment.id), latest: latestSubmission(db, assignment.id, userId), review: getReview(db, assignment.id, userId) })
|
||||
// 以前布置的作业没有保存任务说明,用实验现在的说明
|
||||
const task = assignment.task ?? (await loadTask(assignment.sampleId))
|
||||
return c.json({
|
||||
assignment: { ...assignment, task },
|
||||
template: assignmentTemplate(db, assignment.id),
|
||||
latest: latestSubmission(db, assignment.id, userId),
|
||||
review: getReview(db, assignment.id, userId),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/assignments/:id/submit', async (c) => {
|
||||
@@ -249,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) => {
|
||||
@@ -298,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 }>(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)
|
||||
@@ -313,13 +331,22 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
||||
checks,
|
||||
scoring: a.scoring as Scoring,
|
||||
deadline: typeof a.deadline === 'number' ? a.deadline : null,
|
||||
task: typeof a.task === 'string' ? a.task : null,
|
||||
})
|
||||
return 'error' in result ? c.json({ message: result.error }, 400) : c.json(result)
|
||||
})
|
||||
|
||||
app.patch('/api/teacher/assignments/:id', async (c) => {
|
||||
const a = await body<{ title: string; deadline: number | null; scoring: Scoring }>(c)
|
||||
return reply(c, updateAssignment(db, id(c), { title: String(a.title ?? ''), deadline: typeof a.deadline === 'number' ? a.deadline : null, scoring: a.scoring as Scoring }))
|
||||
const a = await body<{ title: string; deadline: number | null; scoring: Scoring; task: string | null }>(c)
|
||||
return reply(
|
||||
c,
|
||||
updateAssignment(db, id(c), {
|
||||
title: String(a.title ?? ''),
|
||||
deadline: typeof a.deadline === 'number' ? a.deadline : null,
|
||||
scoring: a.scoring as Scoring,
|
||||
task: typeof a.task === 'string' ? a.task : null,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
app.delete('/api/teacher/assignments/:id', (c) => {
|
||||
@@ -333,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,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -359,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 })
|
||||
})
|
||||
|
||||
+42
-12
@@ -15,10 +15,11 @@ interface AssignmentRow {
|
||||
scoring: string
|
||||
deadline: number | null
|
||||
created_at: number
|
||||
task: string | null
|
||||
}
|
||||
|
||||
const SELECT_ASSIGNMENT = `
|
||||
SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at
|
||||
SELECT a.id, a.class_id, c.name AS class_name, a.sample_id, a.title, a.start_mode, a.checks, a.scoring, a.deadline, a.created_at, a.task
|
||||
FROM assignments a JOIN classes c ON c.id = a.class_id`
|
||||
|
||||
function toAssignment(row: AssignmentRow): Assignment {
|
||||
@@ -33,6 +34,7 @@ function toAssignment(row: AssignmentRow): Assignment {
|
||||
createdAt: row.created_at,
|
||||
checkTitles: parseChecks(row.checks).map((c) => c.title),
|
||||
scoring: JSON.parse(row.scoring) as Scoring,
|
||||
task: row.task,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,36 +78,52 @@ export interface NewAssignment {
|
||||
checks: string
|
||||
scoring: Scoring
|
||||
deadline: number | null
|
||||
/** 任务说明(Markdown),null 表示用实验的说明 */
|
||||
task: string | null
|
||||
}
|
||||
|
||||
/** 任务说明最长多少字 */
|
||||
export const MAX_TASK_LENGTH = 20_000
|
||||
|
||||
export function createAssignment(db: Database, a: NewAssignment): { id: number } | { error: string } {
|
||||
if (!a.title.trim()) return { error: '作业名称不能为空' }
|
||||
const error = scoringError(a.scoring, parseChecks(a.checks).length)
|
||||
const error = scoringError(a.scoring, parseChecks(a.checks).length) ?? taskError(a.task)
|
||||
if (error) return { error }
|
||||
const result = db
|
||||
.query(
|
||||
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO assignments (class_id, sample_id, title, start_mode, template, checks, scoring, deadline, created_at, task)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now())
|
||||
.run(a.classId, a.sampleId, a.title.trim(), a.startMode, JSON.stringify(a.template), a.checks, JSON.stringify(a.scoring), a.deadline, Date.now(), a.task)
|
||||
return { id: Number(result.lastInsertRowid) }
|
||||
}
|
||||
|
||||
/** 修改名称、截止时间、分值(起点工程和评分规则布置后不能改) */
|
||||
export function updateAssignment(db: Database, id: number, changes: { title: string; deadline: number | null; scoring: Scoring }): { error?: string } {
|
||||
function taskError(task: string | null) {
|
||||
return task !== null && task.length > MAX_TASK_LENGTH ? `任务说明不能超过 ${MAX_TASK_LENGTH} 字` : null
|
||||
}
|
||||
|
||||
/** 修改名称、截止时间、分值、任务说明(起点工程和评分规则布置后不能改) */
|
||||
export function updateAssignment(
|
||||
db: Database,
|
||||
id: number,
|
||||
changes: { title: string; deadline: number | null; scoring: Scoring; task: string | null },
|
||||
): { error?: string } {
|
||||
const checks = assignmentChecks(db, id)
|
||||
if (!checks) return { error: '作业不存在' }
|
||||
if (!changes.title.trim()) return { error: '作业名称不能为空' }
|
||||
const error = scoringError(changes.scoring, checks.length)
|
||||
const error = scoringError(changes.scoring, checks.length) ?? taskError(changes.task)
|
||||
if (error) return { error }
|
||||
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ? WHERE id = ?').run(
|
||||
db.query('UPDATE assignments SET title = ?, deadline = ?, scoring = ?, task = ? WHERE id = ?').run(
|
||||
changes.title.trim(),
|
||||
changes.deadline,
|
||||
JSON.stringify(changes.scoring),
|
||||
changes.task,
|
||||
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 {}
|
||||
@@ -164,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) {
|
||||
@@ -195,7 +216,16 @@ export function saveGrade(db: Database, id: number, result: GradeResult | null)
|
||||
export function classSubmissions(db: Database, assignmentId: number) {
|
||||
return db
|
||||
.query<
|
||||
{ user_id: number; username: string; name: string; submission_id: number | null; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number },
|
||||
{
|
||||
user_id: number
|
||||
username: string
|
||||
name: string
|
||||
submission_id: number | null
|
||||
status: Submission['status'] | null
|
||||
auto_score: number | null
|
||||
created_at: number | null
|
||||
count: number
|
||||
},
|
||||
[number]
|
||||
>(
|
||||
`SELECT u.id AS user_id, u.username, u.name, s.id AS submission_id, s.status, s.auto_score, s.created_at,
|
||||
|
||||
+12
-3
@@ -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')
|
||||
}
|
||||
@@ -68,9 +68,7 @@ export function deleteClass(db: Database, id: number): { error?: string } {
|
||||
}
|
||||
|
||||
export function listStudents(db: Database, classId: number): Student[] {
|
||||
return db
|
||||
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
|
||||
.all(classId)
|
||||
return db.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,6 +101,10 @@ const MIGRATIONS = [
|
||||
ALTER TABLE submissions ADD COLUMN screenshots TEXT;
|
||||
ALTER TABLE submissions ADD COLUMN ai_review TEXT;
|
||||
`,
|
||||
// 作业的任务说明(Markdown);以前布置的作业为 NULL,用实验现在的说明
|
||||
`
|
||||
ALTER TABLE assignments ADD COLUMN task TEXT;
|
||||
`,
|
||||
]
|
||||
|
||||
/** 打开数据库并执行未执行过的迁移。path 为 ":memory:" 时用内存数据库(测试用) */
|
||||
|
||||
@@ -61,7 +61,15 @@ export type Expectation =
|
||||
*/
|
||||
const DEFAULT_WITHIN = 5000
|
||||
|
||||
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
|
||||
const PROP_KEYS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
ledText: 'LedText',
|
||||
enabled: 'Enabled',
|
||||
checked: 'Checked',
|
||||
visible: 'Visible',
|
||||
on: 'IsOn',
|
||||
value: 'Value',
|
||||
}
|
||||
const STRING_PROPS = new Set(['Text', 'LedText'])
|
||||
const NUMBER_PROPS = new Set(['Value'])
|
||||
|
||||
@@ -96,7 +104,8 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
|
||||
if ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
|
||||
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
|
||||
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
|
||||
if ('window' in raw) return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
|
||||
if ('window' in raw)
|
||||
return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
|
||||
if ('exited' in raw) {
|
||||
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
|
||||
return { kind: 'exited' }
|
||||
@@ -122,7 +131,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
|
||||
return { kind: 'row', control, cells, present: 'row' in raw }
|
||||
}
|
||||
// image: true 或 image: 资源名;background: 资源名
|
||||
for (const [key, prop] of [['image', 'Image'], ['background', 'BackgroundImage']] as const) {
|
||||
for (const [key, prop] of [
|
||||
['image', 'Image'],
|
||||
['background', 'BackgroundImage'],
|
||||
] as const) {
|
||||
if (!(key in raw)) continue
|
||||
const value = raw[key]
|
||||
if (value === true) return { kind: 'image', control, prop }
|
||||
|
||||
@@ -135,7 +135,8 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
||||
const index = items.indexOf(step.item)
|
||||
if (index < 0) {
|
||||
// 串口列表是空的:最常见的原因是没有填充
|
||||
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
|
||||
const hint =
|
||||
items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
|
||||
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
|
||||
}
|
||||
driver.act(node, { action: 'select', index })
|
||||
@@ -151,7 +152,14 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
||||
const rows = gridRows(node)
|
||||
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
|
||||
const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column)
|
||||
if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`)
|
||||
if (column < 0)
|
||||
throw new Failure(
|
||||
`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${
|
||||
gridColumns(node)
|
||||
.map((c) => c.header)
|
||||
.join('、') || '没有'
|
||||
})`,
|
||||
)
|
||||
driver.act(node, { action: 'cellClick', row: step.row, column })
|
||||
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
|
||||
await driver.settle()
|
||||
@@ -302,8 +310,10 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
||||
case 'prop': {
|
||||
const actual = need(driver, e.control).props[e.prop]
|
||||
if (e.prop === 'Text' || e.prop === 'LedText') return `期望${prefix} ${e.control} ${desc ?? `显示"${e.value}"`},实际是${quote(actual)}`
|
||||
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
|
||||
if (e.prop === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
|
||||
if (e.prop === 'Enabled')
|
||||
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
|
||||
if (e.prop === 'Visible')
|
||||
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
|
||||
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
|
||||
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
|
||||
if (e.prop === 'Value') return `期望${prefix} ${e.control} ${desc ?? `的值是 ${String(e.value)}`},实际是 ${String(actual)}`
|
||||
@@ -312,7 +322,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
||||
case 'contains': {
|
||||
const node = need(driver, e.control)
|
||||
const items = listItems(node)
|
||||
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
|
||||
if (items)
|
||||
return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
|
||||
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
|
||||
}
|
||||
case 'matches':
|
||||
@@ -354,12 +365,18 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
||||
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
|
||||
case 'row': {
|
||||
const node = need(driver, e.control)
|
||||
const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',')
|
||||
const wanted = Object.entries(e.cells)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(',')
|
||||
const columns = gridColumns(node)
|
||||
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
|
||||
if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
|
||||
if (missing.length)
|
||||
return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
|
||||
const rows = gridRows(node)
|
||||
const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';')
|
||||
const shown = rows
|
||||
.slice(0, 5)
|
||||
.map((r) => r.join(' | '))
|
||||
.join(';')
|
||||
return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { config } from '../config'
|
||||
import { safeJoin } from '../run-session'
|
||||
import { grade } from './grader'
|
||||
|
||||
|
||||
/** 把作品写进一个干净的工程目录(评分和自测用) */
|
||||
export async function writeProject(dir: string, files: SourceFile[]) {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
|
||||
+9
-2
@@ -30,7 +30,11 @@ if (sandboxError) console.error(sandboxError)
|
||||
const db = openDatabase()
|
||||
ensureTeacher(db)
|
||||
const aiQueue = createAiQueue(db)
|
||||
const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue)
|
||||
const app = createApp(
|
||||
db,
|
||||
createGradingQueue(db, (id) => aiQueue.enqueue(id)),
|
||||
aiQueue,
|
||||
)
|
||||
|
||||
interface SocketData {
|
||||
userId?: number
|
||||
@@ -58,7 +62,10 @@ const server = Bun.serve({
|
||||
sendPings: true,
|
||||
open(ws) {
|
||||
const userId = ws.data.userId!
|
||||
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
|
||||
ws.data.session = new RunSession(
|
||||
(message) => ws.send(JSON.stringify(message)),
|
||||
() => userDatabaseDir(userId),
|
||||
)
|
||||
},
|
||||
async message(ws, raw) {
|
||||
let message: ClientMessage
|
||||
|
||||
+17
-5
@@ -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 })),
|
||||
|
||||
@@ -33,6 +33,14 @@ export async function loadSample(id: string): Promise<SourceFile[] | null> {
|
||||
return files.length > 0 ? files.sort((a, b) => a.path.localeCompare(b.path)) : null
|
||||
}
|
||||
|
||||
/** 示例的任务说明(task.md 原文,Markdown);没有说明返回 null */
|
||||
export async function loadTask(id: string): Promise<string | null> {
|
||||
const path = resolve(SAMPLES_DIR, id, 'task.md')
|
||||
if (!path.startsWith(`${SAMPLES_DIR}/`)) return null
|
||||
const file = Bun.file(path)
|
||||
return (await file.exists()) ? file.text() : null
|
||||
}
|
||||
|
||||
/** 示例的评分规则(checks.yaml 原文);没有规则返回 null */
|
||||
export async function loadChecks(id: string): Promise<string | null> {
|
||||
const path = resolve(SAMPLES_DIR, id, 'checks.yaml')
|
||||
|
||||
+30
-11
@@ -75,13 +75,20 @@ export function runCommand(projectDir: string, extraEnv: Record<string, string>,
|
||||
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
|
||||
const cmd = [
|
||||
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
|
||||
'nice', '-n', '10',
|
||||
'nice',
|
||||
'-n',
|
||||
'10',
|
||||
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
|
||||
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
|
||||
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
|
||||
'prlimit',
|
||||
`--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`,
|
||||
'--core=0',
|
||||
...bwrapArgs(projectDir),
|
||||
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
|
||||
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
|
||||
'dotnet',
|
||||
`${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`,
|
||||
'run',
|
||||
APP_DIR,
|
||||
]
|
||||
return { cmd, env }
|
||||
}
|
||||
@@ -105,15 +112,27 @@ function bwrapArgs(projectDir: string) {
|
||||
'--unshare-all',
|
||||
'--die-with-parent',
|
||||
'--new-session',
|
||||
'--ro-bind', '/usr', '/usr',
|
||||
'--ro-bind',
|
||||
'/usr',
|
||||
'/usr',
|
||||
...rootLinks(),
|
||||
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
|
||||
'--ro-bind-try',
|
||||
'/etc/localtime',
|
||||
'/etc/localtime',
|
||||
...dotnetMounts(),
|
||||
'--proc', '/proc',
|
||||
'--dev', '/dev',
|
||||
'--tmpfs', '/tmp',
|
||||
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
|
||||
'--ro-bind', projectDir, APP_DIR,
|
||||
'--chdir', APP_DIR,
|
||||
'--proc',
|
||||
'/proc',
|
||||
'--dev',
|
||||
'/dev',
|
||||
'--tmpfs',
|
||||
'/tmp',
|
||||
'--ro-bind',
|
||||
dirname(config.runnerDll),
|
||||
RUNNER_DIR,
|
||||
'--ro-bind',
|
||||
projectDir,
|
||||
APP_DIR,
|
||||
'--chdir',
|
||||
APP_DIR,
|
||||
]
|
||||
}
|
||||
@@ -25,9 +25,7 @@ export interface SqlColumnInfo {
|
||||
}
|
||||
|
||||
export type SqlRequest =
|
||||
| { type: 'exec'; dir: string; database: string; sql: string }
|
||||
| { type: 'script'; dir: string; sql: string }
|
||||
| { type: 'explorer'; dir: string }
|
||||
{ type: 'exec'; dir: string; database: string; sql: string } | { type: 'script'; dir: string; sql: string } | { type: 'explorer'; dir: string }
|
||||
|
||||
/** SQL 报错(编号和信息与 SQL Server 一致) */
|
||||
export class SqlError extends Error {
|
||||
@@ -78,7 +76,13 @@ class SqlService {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'inherit',
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', TZ: config.timeZone },
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
HOME: process.env.HOME ?? '/tmp',
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
||||
DOTNET_NOLOGO: '1',
|
||||
TZ: config.timeZone,
|
||||
},
|
||||
})
|
||||
this.proc = proc
|
||||
this.ready = new Promise<void>((resolveReady) => {
|
||||
|
||||
+129
-19
@@ -9,7 +9,7 @@ import { openDatabase } from '../src/db'
|
||||
import { createGradingQueue, type GradingQueue } from '../src/grading/queue'
|
||||
import { languageService } from '../src/language-service'
|
||||
import { sqlService } from '../src/sql-service'
|
||||
import { loadSample } from '../src/samples'
|
||||
import { loadSample, loadTask } from '../src/samples'
|
||||
|
||||
let app: ReturnType<typeof createApp>
|
||||
let queue: GradingQueue
|
||||
@@ -33,9 +33,7 @@ class Browser {
|
||||
const headers: Record<string, string> = { 'x-real-ip': this.ip }
|
||||
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
|
||||
if (json !== undefined) headers['content-type'] = 'application/json'
|
||||
const response = await app.fetch(
|
||||
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
|
||||
)
|
||||
const response = await app.fetch(new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }))
|
||||
const body = (await response.json()) as any
|
||||
if (body?.accessToken) {
|
||||
this.accessToken = body.accessToken
|
||||
@@ -287,6 +285,28 @@ describe('作业', () => {
|
||||
return { t, student, classId, assignmentId: created.body.id as number, files: template }
|
||||
}
|
||||
|
||||
test('任务说明:布置时保存,老师可以改;没有保存的作业用实验现在的说明', async () => {
|
||||
const { t, student, classId, assignmentId } = await setup()
|
||||
// setup 没传任务说明(像加这个功能之前布置的作业)
|
||||
expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe(await loadTask(SAMPLE))
|
||||
|
||||
await t.patch(`/api/teacher/assignments/${assignmentId}`, { title: '控制报警灯', deadline: null, scoring, task: '## 老师要求\n只做前 3 步' })
|
||||
expect((await student.get(`/api/assignments/${assignmentId}`)).body.assignment.task).toBe('## 老师要求\n只做前 3 步')
|
||||
|
||||
const tooLong = await t.post('/api/teacher/assignments', {
|
||||
classId,
|
||||
sampleId: SAMPLE,
|
||||
title: '太长',
|
||||
startMode: 'ui',
|
||||
template: [],
|
||||
scoring,
|
||||
deadline: null,
|
||||
task: 'x'.repeat(20_001),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
expect(tooLong.body.message).toContain('任务说明')
|
||||
})
|
||||
|
||||
test('分值项数要和评分规则一致', async () => {
|
||||
const t = await teacher()
|
||||
const classId = await classWithStudents(t, [])
|
||||
@@ -396,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()
|
||||
@@ -405,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 })
|
||||
@@ -430,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 () => {
|
||||
@@ -447,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)
|
||||
|
||||
@@ -465,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']))
|
||||
|
||||
@@ -478,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)
|
||||
@@ -507,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: [
|
||||
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)
|
||||
@@ -551,7 +628,13 @@ describe('数据库页面', () => {
|
||||
|
||||
describe('AI 评阅', () => {
|
||||
const SAMPLE = 'p4-t3-报警灯'
|
||||
const scoring = { auto: [10, 10], manual: [{ title: '窗体界面', points: 10 }, { title: '注释规范', points: 5 }] }
|
||||
const scoring = {
|
||||
auto: [10, 10],
|
||||
manual: [
|
||||
{ title: '窗体界面', points: 10 },
|
||||
{ title: '注释规范', points: 5 },
|
||||
],
|
||||
}
|
||||
/** 假的模型服务:记下收到的请求,返回固定的评分(第二项故意超出满分,检查服务器会限制) */
|
||||
const requests: any[] = []
|
||||
let fake: ReturnType<typeof Bun.serve>
|
||||
@@ -562,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 } }] })
|
||||
},
|
||||
})
|
||||
@@ -577,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('评分项个数不对')
|
||||
@@ -589,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')
|
||||
@@ -600,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 })
|
||||
|
||||
@@ -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,7 +207,11 @@ try {
|
||||
window.form = ((await shown) as Extract<ServerMessage, { type: 'formShown' }>).form
|
||||
})
|
||||
|
||||
if (!process.env.SAMPLE) await phase('点按钮到实训台', students.filter((s) => s.window?.form), async (s) => {
|
||||
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 })
|
||||
@@ -219,7 +221,8 @@ try {
|
||||
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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
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)
|
||||
},
|
||||
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)
|
||||
|
||||
+43
-16
@@ -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 () => {
|
||||
test(
|
||||
id,
|
||||
async () => {
|
||||
const result = await compileService.compile(await prepareSample(id))
|
||||
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
||||
}, 30000)
|
||||
},
|
||||
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,11 +696,13 @@ 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 () => {
|
||||
test.skipIf(config.sandbox === 'none')(
|
||||
'沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读',
|
||||
async () => {
|
||||
process.env.SIM_TEST_SECRET = '不该被看到'
|
||||
const { runner } = await runMain(
|
||||
'sandbox',
|
||||
@@ -707,7 +728,9 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别
|
||||
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
|
||||
expect(output).toContain('write=denied')
|
||||
expect(output).toContain('tmp=True')
|
||||
}, 30000)
|
||||
},
|
||||
30000,
|
||||
)
|
||||
|
||||
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
|
||||
const { runner } = await runMain(
|
||||
@@ -768,7 +791,9 @@ using System.Runtime.InteropServices;
|
||||
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
|
||||
}, 30000)
|
||||
|
||||
test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到时被结束,并提示检查死循环', async () => {
|
||||
test.skipIf(config.sandbox === 'none')(
|
||||
'CPU 时间上限:死循环的程序到时被结束,并提示检查死循环',
|
||||
async () => {
|
||||
const limit = config.cpuLimitSeconds
|
||||
config.cpuLimitSeconds = 2
|
||||
try {
|
||||
@@ -778,4 +803,6 @@ test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到
|
||||
} finally {
|
||||
config.cpuLimitSeconds = limit
|
||||
}
|
||||
}, 30000)
|
||||
},
|
||||
30000,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
// 示例的任务说明(task.md):表格里写的控件名都要在示例的 Designer.cs 里有,学生照着改名不会被带错。
|
||||
import { expect, test } from 'bun:test'
|
||||
import { listSamples, loadSample, loadTask } from '../src/samples'
|
||||
|
||||
/** 控件表格(表头有"名称")里那一列用 `反引号` 写的名字 */
|
||||
function tableNames(task: string): string[] {
|
||||
const names: string[] = []
|
||||
let column = -1
|
||||
let previous: string[] = []
|
||||
for (const line of task.split('\n')) {
|
||||
if (!line.trim().startsWith('|')) {
|
||||
column = -1
|
||||
previous = []
|
||||
continue
|
||||
}
|
||||
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]))
|
||||
previous = cells
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
test('任务说明里的控件名都在示例的界面里', async () => {
|
||||
let checked = 0
|
||||
for (const { id } of await listSamples()) {
|
||||
const task = await loadTask(id)
|
||||
if (!task) continue
|
||||
const designer = (await loadSample(id))!.filter((f) => f.path.endsWith('.Designer.cs'))
|
||||
const names = tableNames(task)
|
||||
expect(names.length, `${id} 的任务说明没有控件表格`).toBeGreaterThan(0)
|
||||
for (const name of names) {
|
||||
// 窗体本身写的是类名,控件是 this.名字
|
||||
const found = designer.some((f) => f.content.includes(`this.${name} `) || f.content.includes(`partial class ${name}`))
|
||||
expect(found, `${id}:任务说明里的 ${name} 在 Designer.cs 里找不到`).toBe(true)
|
||||
}
|
||||
checked++
|
||||
}
|
||||
expect(checked).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -49,6 +49,11 @@ export interface Assignment {
|
||||
/** 自动评分项的名称,和 scoring.auto 一一对应 */
|
||||
checkTitles: string[]
|
||||
scoring: Scoring
|
||||
/**
|
||||
* 任务说明(Markdown,见 web/src/grading/markdown.ts):布置时从实验的 task.md 复制,老师可以改写。
|
||||
* null 是加这个功能之前布置的作业,学生看到的是实验现在的说明
|
||||
*/
|
||||
task: string | null
|
||||
}
|
||||
|
||||
export interface Submission {
|
||||
|
||||
+8
-2
@@ -22,10 +22,16 @@
|
||||
if (!app || app.hasAttribute('data-v-app')) return
|
||||
app.innerHTML =
|
||||
'<div style="max-width:560px;margin:80px auto;padding:0 16px;font:16px/1.8 sans-serif;color:#333">' +
|
||||
'<h2 style="margin:0 0 12px">' + title + '</h2><p>' + text + '</p>' +
|
||||
'<h2 style="margin:0 0 12px">' +
|
||||
title +
|
||||
'</h2><p>' +
|
||||
text +
|
||||
'</p>' +
|
||||
'<p>用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到<b>极速模式</b>;或者换用新版的 <b>Chrome</b>、<b>Edge</b>。' +
|
||||
'还是打不开的话,请把下面这行文字告诉老师:</p>' +
|
||||
'<p style="font-size:13px;color:#888;word-break:break-all">' + navigator.userAgent.replace(/</g, '<') + '</p></div>'
|
||||
'<p style="font-size:13px;color:#888;word-break:break-all">' +
|
||||
navigator.userAgent.replace(/</g, '<') +
|
||||
'</p></div>'
|
||||
}
|
||||
window.addEventListener('error', function (event) {
|
||||
if (event && event.error instanceof SyntaxError) {
|
||||
|
||||
+1
-1
@@ -16,6 +16,6 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-family: system-ui, 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,11 @@ function choose(key: string) {
|
||||
const REFRESH: ExplorerAction = { kind: 'refresh' }
|
||||
|
||||
function rootMenu(event: MouseEvent) {
|
||||
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
openMenu(event, [
|
||||
['新建数据库…', { kind: 'newDatabase' }],
|
||||
['-', REFRESH],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function databaseMenu(event: MouseEvent, database: string) {
|
||||
@@ -61,7 +65,11 @@ function databaseMenu(event: MouseEvent, database: string) {
|
||||
}
|
||||
|
||||
function tablesMenu(event: MouseEvent, database: string) {
|
||||
openMenu(event, [['新建表…', { kind: 'newTable', database }], ['-', REFRESH], ['刷新', REFRESH]])
|
||||
openMenu(event, [
|
||||
['新建表…', { kind: 'newTable', database }],
|
||||
['-', REFRESH],
|
||||
['刷新', REFRESH],
|
||||
])
|
||||
}
|
||||
|
||||
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
|
||||
@@ -88,18 +96,21 @@ function columnText(column: ExplorerTable['columns'][number]) {
|
||||
<ul class="tree">
|
||||
<li>
|
||||
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
|
||||
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
|
||||
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span
|
||||
>📁 数据库
|
||||
</div>
|
||||
<ul v-if="expanded.has('root')">
|
||||
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
|
||||
<li v-for="db in databases" :key="db.name">
|
||||
<div class="node" @click="toggle(`db:${db.name}`)" @contextmenu="databaseMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span>🛢 {{ db.name }}
|
||||
<span class="arrow">{{ expanded.has(`db:${db.name}`) ? '▾' : '▸' }}</span
|
||||
>🛢 {{ db.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`db:${db.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`tables:${db.name}`)" @contextmenu="tablesMenu($event, db.name)">
|
||||
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span>📁 表
|
||||
<span class="arrow">{{ expanded.has(`tables:${db.name}`) ? '▾' : '▸' }}</span
|
||||
>📁 表
|
||||
</div>
|
||||
<ul v-if="expanded.has(`tables:${db.name}`)">
|
||||
<li v-if="db.tables.length === 0" class="empty">(没有表)</li>
|
||||
@@ -110,12 +121,14 @@ function columnText(column: ExplorerTable['columns'][number]) {
|
||||
@click="toggle(`table:${db.name}/${table.name}`)"
|
||||
@contextmenu="tableMenu($event, db.name, table)"
|
||||
>
|
||||
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>▦ dbo.{{ table.name }}
|
||||
<span class="arrow">{{ expanded.has(`table:${db.name}/${table.name}`) ? '▾' : '▸' }}</span
|
||||
>▦ dbo.{{ table.name }}
|
||||
</div>
|
||||
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
|
||||
<li>
|
||||
<div class="node" @click="toggle(`columns:${db.name}/${table.name}`)">
|
||||
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span>📁 列
|
||||
<span class="arrow">{{ expanded.has(`columns:${db.name}/${table.name}`) ? '▾' : '▸' }}</span
|
||||
>📁 列
|
||||
</div>
|
||||
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
|
||||
<li v-for="column in table.columns" :key="column.name" class="node leaf">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('编辑表数据生成的 T-SQL', () => {
|
||||
})
|
||||
|
||||
test('有主键时按主键定位', () => {
|
||||
expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe('UPDATE TOP (1) [CampusEnvironment] SET [light] = N\'600\' WHERE [ID] = 3')
|
||||
expect(updateCell(campus, 1, '600', [3, '500', '2024-01-16 09:00:00.000'])).toBe("UPDATE TOP (1) [CampusEnvironment] SET [light] = N'600' WHERE [ID] = 3")
|
||||
expect(deleteRow(campus, [3, '500', null])).toBe('DELETE TOP (1) FROM [CampusEnvironment] WHERE [ID] = 3')
|
||||
})
|
||||
|
||||
@@ -38,9 +38,16 @@ describe('编辑表数据生成的 T-SQL', () => {
|
||||
})
|
||||
|
||||
test('新增行跳过自增列;什么都没填时用 DEFAULT VALUES', () => {
|
||||
expect(insertRow(campus, new Map([[2, '2024-01-20 08:00:00'], [0, '9'], [1, null]]))).toBe(
|
||||
"INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')",
|
||||
)
|
||||
expect(
|
||||
insertRow(
|
||||
campus,
|
||||
new Map([
|
||||
[2, '2024-01-20 08:00:00'],
|
||||
[0, '9'],
|
||||
[1, null],
|
||||
]),
|
||||
),
|
||||
).toBe("INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')")
|
||||
expect(insertRow(campus, new Map())).toBe('INSERT INTO [CampusEnvironment] DEFAULT VALUES')
|
||||
})
|
||||
})
|
||||
@@ -53,16 +60,25 @@ describe('表设计器生成的 T-SQL', () => {
|
||||
{ name: 'EnvironmentDay', type: 'date', nullable: true, identity: false, primaryKey: false },
|
||||
{ name: 'EnvironmentWork', type: 'varchar(200)', nullable: true, identity: false, primaryKey: false },
|
||||
]),
|
||||
).toBe('CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)')
|
||||
).toBe(
|
||||
'CREATE TABLE [EnvironmentWorkNew]\n(\n [id] int IDENTITY(1,1) NOT NULL,\n [EnvironmentDay] date NULL,\n [EnvironmentWork] varchar(200) NULL,\n PRIMARY KEY ([id])\n)',
|
||||
)
|
||||
})
|
||||
|
||||
test('给已有的表加列', () => {
|
||||
expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe('ALTER TABLE [T] ADD [memo] nvarchar(50) NULL')
|
||||
expect(addColumns('T', [{ name: 'memo', type: 'nvarchar(50)', nullable: true, identity: false, primaryKey: false }])).toBe(
|
||||
'ALTER TABLE [T] ADD [memo] nvarchar(50) NULL',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test('消息栏和 SSMS 一致', () => {
|
||||
expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true }])
|
||||
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual(['(3 行受影响)', '(1 行受影响)'])
|
||||
expect(messages({ type: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([
|
||||
{ text: "消息 208,级别 16,状态 1,第 2 行\n对象名 'X' 无效。", error: true },
|
||||
])
|
||||
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual([
|
||||
'(3 行受影响)',
|
||||
'(1 行受影响)',
|
||||
])
|
||||
expect(messages({ type: 'result', database: 'db', results: [], affected: -1, ms: 1 }).map((m) => m.text)).toEqual(['命令已成功完成。'])
|
||||
})
|
||||
+25
-5
@@ -27,8 +27,7 @@ export interface ResultSet {
|
||||
}
|
||||
|
||||
export type QueryReply =
|
||||
| { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number }
|
||||
| { type: 'error'; number: number; message: string; line: number }
|
||||
{ type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number } | { type: 'error'; number: number; message: string; line: number }
|
||||
|
||||
export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db')
|
||||
export const runQuery = (database: string, sql: string) => api<QueryReply>('POST', '/api/db/query', { database, sql })
|
||||
@@ -89,9 +88,30 @@ export interface DesignColumn {
|
||||
|
||||
/** 设计器"数据类型"下拉里的常用类型(和 SSMS 一样也可以直接输入,如 varchar(20)) */
|
||||
export const COMMON_TYPES = [
|
||||
'int', 'bigint', 'smallint', 'tinyint', 'bit', 'float', 'real', 'decimal(18, 0)', 'money',
|
||||
'char(10)', 'varchar(50)', 'varchar(MAX)', 'nchar(10)', 'nvarchar(50)', 'nvarchar(MAX)', 'text', 'ntext',
|
||||
'date', 'datetime', 'smalldatetime', 'time(7)', 'uniqueidentifier', 'varbinary(MAX)', 'image',
|
||||
'int',
|
||||
'bigint',
|
||||
'smallint',
|
||||
'tinyint',
|
||||
'bit',
|
||||
'float',
|
||||
'real',
|
||||
'decimal(18, 0)',
|
||||
'money',
|
||||
'char(10)',
|
||||
'varchar(50)',
|
||||
'varchar(MAX)',
|
||||
'nchar(10)',
|
||||
'nvarchar(50)',
|
||||
'nvarchar(MAX)',
|
||||
'text',
|
||||
'ntext',
|
||||
'date',
|
||||
'datetime',
|
||||
'smalldatetime',
|
||||
'time(7)',
|
||||
'uniqueidentifier',
|
||||
'varbinary(MAX)',
|
||||
'image',
|
||||
]
|
||||
|
||||
export function columnDefinition(column: DesignColumn) {
|
||||
|
||||
@@ -33,7 +33,10 @@ function nextName(prefix: string, taken: string[]) {
|
||||
function addSeries() {
|
||||
const d = draft.value
|
||||
d.series.push({
|
||||
name: nextName('Series', d.series.map((s) => s.name)),
|
||||
name: nextName(
|
||||
'Series',
|
||||
d.series.map((s) => s.name),
|
||||
),
|
||||
chartType: 'Column',
|
||||
chartArea: d.areas[0] ?? '',
|
||||
legend: d.legends[0] ?? '',
|
||||
@@ -93,7 +96,7 @@ function save() {
|
||||
</section>
|
||||
|
||||
<section v-if="series" class="fields">
|
||||
<label title="Name:代码里用 chart1.Series["名称"] 访问这个系列">名称</label>
|
||||
<label title='Name:代码里用 chart1.Series["名称"] 访问这个系列'>名称</label>
|
||||
<input v-model="series.name" />
|
||||
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
|
||||
<select v-model="series.chartType">
|
||||
|
||||
@@ -35,6 +35,8 @@ const props = defineProps<{
|
||||
canRedo: boolean
|
||||
/** 项目资源(图片属性的选项) */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
/** 窗体改名时检查和工程里其他窗体、类有没有重名 */
|
||||
checkFormName?: (name: string) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -48,6 +50,8 @@ const emit = defineEmits<{
|
||||
redo: []
|
||||
/** 属性窗口里要添加项目资源 */
|
||||
addResource: []
|
||||
/** 窗体本身改名(涉及多个文件,由实验页处理) */
|
||||
renameForm: [to: string]
|
||||
}>()
|
||||
|
||||
/** Ctrl+Z 撤销,Ctrl+Y / Ctrl+Shift+Z 重做;在属性窗口的输入框里打字时不拦截(输入框有自己的撤销) */
|
||||
@@ -168,7 +172,12 @@ const surface = ref<HTMLElement>()
|
||||
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
|
||||
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
|
||||
const ZOOM_KEY = 'iot-sim:designer-zoom'
|
||||
const ZOOM_OPTIONS: [ZoomChoice, string][] = [['fit', '适应宽度'], ['1', '100%'], ['0.75', '75%'], ['0.5', '50%']]
|
||||
const ZOOM_OPTIONS: [ZoomChoice, string][] = [
|
||||
['fit', '适应宽度'],
|
||||
['1', '100%'],
|
||||
['0.75', '75%'],
|
||||
['0.5', '50%'],
|
||||
]
|
||||
const zoomChoice = ref<ZoomChoice>(readZoom())
|
||||
function readZoom(): ZoomChoice {
|
||||
try {
|
||||
@@ -321,9 +330,25 @@ function onKeyDown(event: KeyboardEvent) {
|
||||
// ---------- 工具箱 ----------
|
||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
||||
const ICONS: Record<string, string> = {
|
||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
|
||||
复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
|
||||
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
||||
按钮: '▭',
|
||||
标签: 'A',
|
||||
文本框: '▤',
|
||||
下拉框: '☰',
|
||||
列表框: '≣',
|
||||
图片框: '🖼',
|
||||
日期时间选择器: '📅',
|
||||
滑动条: '⊶',
|
||||
复选框: '☑',
|
||||
单选按钮: '◉',
|
||||
分组框: '⬚',
|
||||
面板: '▢',
|
||||
数据表格: '▦',
|
||||
图表: '📈',
|
||||
定时器: '⏱',
|
||||
媒体播放器: '🔊',
|
||||
'LED 显示屏': '▬',
|
||||
灯泡: '💡',
|
||||
报警灯: '🚨',
|
||||
}
|
||||
const DRAG_TYPE = 'application/x-winforms-control'
|
||||
|
||||
@@ -433,6 +458,11 @@ function onDoubleClick(event: MouseEvent) {
|
||||
openEvent(hit, def.defaultEvent)
|
||||
}
|
||||
|
||||
function deleteComponent(name: string) {
|
||||
commit(removeControl(parsed.value.doc!, name))
|
||||
selected.value = ''
|
||||
}
|
||||
|
||||
function onComponentDoubleClick(name: string) {
|
||||
const d = parsed.value.doc
|
||||
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
|
||||
@@ -471,7 +501,8 @@ const noop = () => {}
|
||||
@dragstart="onToolDragStart($event, def)"
|
||||
@dblclick="onToolDoubleClick(def)"
|
||||
>
|
||||
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span>{{ def.label }}
|
||||
<span class="tool-icon">{{ ICONS[def.label] ?? '▫' }}</span
|
||||
>{{ def.label }}
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
@@ -491,9 +522,7 @@ const noop = () => {}
|
||||
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">
|
||||
查看生成的代码
|
||||
</button>
|
||||
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">查看生成的代码</button>
|
||||
</div>
|
||||
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
|
||||
<div class="titlebar" @pointerdown="selected = ''">
|
||||
@@ -539,7 +568,7 @@ const noop = () => {}
|
||||
:class="{ active: selected === c.name }"
|
||||
@click="selected = c.name"
|
||||
@dblclick="onComponentDoubleClick(c.name)"
|
||||
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
|
||||
@keydown.delete="deleteComponent(c.name)"
|
||||
>
|
||||
⏱ {{ c.name }}
|
||||
</button>
|
||||
@@ -551,9 +580,11 @@ const noop = () => {}
|
||||
:doc="doc"
|
||||
:selected="selected"
|
||||
:resources="resources"
|
||||
:check-form-name="checkFormName"
|
||||
@set-prop="onSetProp"
|
||||
@set-items="onSetItems"
|
||||
@rename="onRename"
|
||||
@rename-form="emit('renameForm', $event)"
|
||||
@open-event="openEvent"
|
||||
@clear-event="clearEvent"
|
||||
@add-resource="emit('addResource')"
|
||||
@@ -688,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;
|
||||
}
|
||||
@@ -701,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;
|
||||
}
|
||||
|
||||
@@ -728,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;
|
||||
|
||||
@@ -15,12 +15,16 @@ const props = defineProps<{
|
||||
selected: string
|
||||
/** 项目资源(图片属性从这里选)和引用资源的前缀 global::命名空间.Properties.Resources. */
|
||||
resources?: { names: string[]; prefix: string }
|
||||
/** 窗体改名时检查新名字和工程里其他窗体、类有没有重名(没有问题返回 null) */
|
||||
checkFormName?: (name: string) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
setProp: [target: string, path: string, value: Value | null]
|
||||
setItems: [target: string, items: string[]]
|
||||
rename: [from: string, to: string]
|
||||
/** 窗体本身改名:类名和文件名一起改 */
|
||||
renameForm: [to: string]
|
||||
/** 双击事件或点"生成":绑定(必要时生成)事件处理方法并跳到代码 */
|
||||
openEvent: [target: string, event: string, handler?: string]
|
||||
clearEvent: [target: string, event: string]
|
||||
@@ -52,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) {
|
||||
@@ -73,14 +77,24 @@ function reset(prop: PropDef) {
|
||||
const nameDraft = ref('')
|
||||
const nameError = ref<string | null>(null)
|
||||
// 切换选中的控件或切换到另一个窗体时刷新
|
||||
watch([() => props.selected, () => props.doc.formName], () => {
|
||||
watch(
|
||||
[() => props.selected, () => props.doc.formName],
|
||||
() => {
|
||||
nameDraft.value = props.selected || props.doc.formName
|
||||
nameError.value = null
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function commitName() {
|
||||
const name = nameDraft.value.trim()
|
||||
if (!props.selected || name === props.selected) return
|
||||
if (!props.selected) {
|
||||
if (name === props.doc.className) return
|
||||
nameError.value = checkName(props.doc, name, props.doc.formName) ?? props.checkFormName?.(name) ?? null
|
||||
if (!nameError.value) emit('renameForm', name)
|
||||
return
|
||||
}
|
||||
if (name === props.selected) return
|
||||
nameError.value = checkName(props.doc, name, props.selected)
|
||||
if (!nameError.value) emit('rename', props.selected, name)
|
||||
}
|
||||
@@ -141,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 itemsText = computed(() =>
|
||||
getItems(props.doc, props.selected)
|
||||
.map((item) => {
|
||||
const v = parseValue(item)
|
||||
return v.kind === 'string' ? v.value : item
|
||||
}).join('\n'))
|
||||
})
|
||||
.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) ?? ''
|
||||
@@ -171,9 +196,10 @@ function renameHandler(event: string, value: string) {
|
||||
<div class="row">
|
||||
<label title="Name:控件的名称,代码里用这个名字访问它">名称</label>
|
||||
<div class="editor">
|
||||
<input v-model="nameDraft" :disabled="selected === ''" @change="commitName" @keydown.enter="commitName" />
|
||||
<input v-model="nameDraft" @change="commitName" @keydown.enter="commitName" />
|
||||
<div v-if="nameError" class="error">{{ nameError }}</div>
|
||||
<div v-else-if="selected !== ''" class="note">改名后,代码里用到旧名字的地方要自己修改</div>
|
||||
<div v-else class="note">改名后,类名、文件名和 Program.cs 里的 new 跟着改;Form1_Load 这样的方法名不变</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,35 +208,80 @@ function renameHandler(event: string, value: string) {
|
||||
<div class="editor">
|
||||
<code v-if="prop.type !== 'image' && prop.type !== 'chart' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
|
||||
|
||||
<input v-else-if="prop.type === 'string'" :value="str(prop)" @change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })" />
|
||||
<input
|
||||
v-else-if="prop.type === 'string'"
|
||||
:value="str(prop)"
|
||||
@change="set(prop, { kind: 'string', value: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
|
||||
<textarea v-else-if="prop.type === 'multiline'" rows="2" :value="str(prop)"
|
||||
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })" />
|
||||
<textarea
|
||||
v-else-if="prop.type === 'multiline'"
|
||||
rows="2"
|
||||
:value="str(prop)"
|
||||
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })"
|
||||
/>
|
||||
|
||||
<input v-else-if="prop.type === 'char'" maxlength="1" class="short" :value="str(prop)"
|
||||
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)" />
|
||||
<input
|
||||
v-else-if="prop.type === 'char'"
|
||||
maxlength="1"
|
||||
class="short"
|
||||
:value="str(prop)"
|
||||
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)"
|
||||
/>
|
||||
|
||||
<select v-else-if="prop.type === 'bool'" :value="String(boolOf(prop))"
|
||||
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })">
|
||||
<select
|
||||
v-else-if="prop.type === 'bool'"
|
||||
:value="String(boolOf(prop))"
|
||||
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })"
|
||||
>
|
||||
<option value="true">是</option>
|
||||
<option value="false">否</option>
|
||||
</select>
|
||||
|
||||
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
|
||||
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })" />
|
||||
<input
|
||||
v-else-if="prop.type === 'int'"
|
||||
type="number"
|
||||
:value="num(prop)"
|
||||
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
|
||||
<span v-else-if="prop.type === 'point'" class="pair">
|
||||
X <input type="number" :value="point(prop).x" @change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })" />
|
||||
Y <input type="number" :value="point(prop).y" @change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })" />
|
||||
X
|
||||
<input
|
||||
type="number"
|
||||
:value="point(prop).x"
|
||||
@change="set(prop, { kind: 'point', x: Number(($event.target as HTMLInputElement).value), y: point(prop).y })"
|
||||
/>
|
||||
Y
|
||||
<input
|
||||
type="number"
|
||||
:value="point(prop).y"
|
||||
@change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span v-else-if="prop.type === 'size'" class="pair">
|
||||
宽 <input type="number" min="1" :value="size(prop).width" @change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })" />
|
||||
高 <input type="number" min="1" :value="size(prop).height" @change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })" />
|
||||
宽
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="size(prop).width"
|
||||
@change="set(prop, { kind: 'size', width: Number(($event.target as HTMLInputElement).value), height: size(prop).height })"
|
||||
/>
|
||||
高
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
:value="size(prop).height"
|
||||
@change="set(prop, { kind: 'size', width: size(prop).width, height: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<select v-else-if="prop.type === 'enum'" :value="enumOf(prop)"
|
||||
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })">
|
||||
<select
|
||||
v-else-if="prop.type === 'enum'"
|
||||
:value="enumOf(prop)"
|
||||
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })"
|
||||
>
|
||||
<option v-for="option in prop.options" :key="option" :value="option">{{ prop.optionLabels?.[option] ?? option }}</option>
|
||||
</select>
|
||||
|
||||
@@ -221,39 +292,70 @@ function renameHandler(event: string, value: string) {
|
||||
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
|
||||
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
|
||||
</select>
|
||||
<input type="color" title="自定义颜色" :value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
|
||||
@change="setColor(prop, ($event.target as HTMLInputElement).value)" />
|
||||
<input
|
||||
type="color"
|
||||
title="自定义颜色"
|
||||
:value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
|
||||
@change="setColor(prop, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span v-else-if="prop.type === 'font'" class="font">
|
||||
<select :value="font(prop)?.family ?? ''" @change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)">
|
||||
<select
|
||||
:value="font(prop)?.family ?? ''"
|
||||
@change="($event.target as HTMLSelectElement).value ? setFont(prop, { family: ($event.target as HTMLSelectElement).value }) : reset(prop)"
|
||||
>
|
||||
<option value="">(默认:宋体 9)</option>
|
||||
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
|
||||
</select>
|
||||
<template v-if="font(prop)">
|
||||
<input type="number" min="5" max="72" step="0.5" class="short" :value="font(prop)!.size"
|
||||
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })" />
|
||||
<label class="check"><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label>
|
||||
<label class="check"><input type="checkbox" :checked="font(prop)!.italic" @change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })" />斜</label>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="72"
|
||||
step="0.5"
|
||||
class="short"
|
||||
:value="font(prop)!.size"
|
||||
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
<label class="check"
|
||||
><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label
|
||||
>
|
||||
<label class="check"
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="font(prop)!.italic"
|
||||
@change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })"
|
||||
/>斜</label
|
||||
>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span v-else-if="prop.type === 'chart'" class="chart-summary">
|
||||
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`">{{ chartModel.series.length }} 个系列</span>
|
||||
<span :title="`系列:${chartModel.series.map((s) => s.name).join('、') || '无'};图例:${chartModel.legends.join('、') || '无'}`"
|
||||
>{{ chartModel.series.length }} 个系列</span
|
||||
>
|
||||
<button @click="chartOpen = true">编辑…</button>
|
||||
</span>
|
||||
|
||||
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
|
||||
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
|
||||
<textarea
|
||||
v-else-if="prop.type === 'items'"
|
||||
rows="4"
|
||||
:value="itemsText"
|
||||
placeholder="每行一项"
|
||||
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
|
||||
<select v-else-if="prop.type === 'image'" :value="imageOf(prop)"
|
||||
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
|
||||
<select
|
||||
v-else-if="prop.type === 'image'"
|
||||
:value="imageOf(prop)"
|
||||
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)"
|
||||
>
|
||||
<option value="">(无)</option>
|
||||
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</option>
|
||||
<option v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
|
||||
<option value="+">添加资源…</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -347,7 +449,7 @@ function renameHandler(event: string, value: string) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor input:not([type="checkbox"]):not([type="color"]),
|
||||
.editor input:not([type='checkbox']):not([type='color']),
|
||||
.editor select,
|
||||
.editor textarea {
|
||||
width: 100%;
|
||||
@@ -383,7 +485,7 @@ function renameHandler(event: string, value: string) {
|
||||
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
|
||||
}
|
||||
|
||||
.color input[type="color"] {
|
||||
.color input[type='color'] {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
|
||||
@@ -36,14 +36,19 @@ function listen(file: string) {
|
||||
|
||||
function save() {
|
||||
// 按素材库的顺序保存,生成的代码稳定
|
||||
emit('save', gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)))
|
||||
emit(
|
||||
'save',
|
||||
gallery.value.map((g) => g.name).filter((name) => chosen.value.has(name)),
|
||||
)
|
||||
emit('update:show', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
|
||||
<p class="note">勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。</p>
|
||||
<p class="note">
|
||||
勾选要加入项目的图片和声音(平台素材库,名字和教材一致)。代码里写 <code>Properties.Resources.名字</code>,图片框的"图片"属性也可以直接选。
|
||||
</p>
|
||||
<div class="items">
|
||||
<!-- 点整行都能勾选;复选框和"试听"自己处理点击,不再传给这一行 -->
|
||||
<div v-for="item in gallery" :key="item.name" class="item" :class="{ on: chosen.has(item.name) }" @click="toggle(item.name, !chosen.has(item.name))">
|
||||
@@ -123,7 +128,7 @@ function save() {
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
+232
-60
@@ -75,9 +75,15 @@ const COMMON_PROPS: PropDef[] = [
|
||||
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
|
||||
{
|
||||
name: 'Dock', label: '停靠', description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方', type: 'enum', enumType: `${FORMS}.DockStyle`,
|
||||
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'], optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
|
||||
default: enumValue(`${FORMS}.DockStyle`, 'None'), category: '布局',
|
||||
name: 'Dock',
|
||||
label: '停靠',
|
||||
description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.DockStyle`,
|
||||
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'],
|
||||
optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
|
||||
default: enumValue(`${FORMS}.DockStyle`, 'None'),
|
||||
category: '布局',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -85,9 +91,15 @@ const COMMON_PROPS: PropDef[] = [
|
||||
const BACKGROUND_PROPS: PropDef[] = [
|
||||
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
||||
{
|
||||
name: 'BackgroundImageLayout', label: '背景排列', description: '背景图片怎样摆放', type: 'enum', enumType: `${FORMS}.ImageLayout`,
|
||||
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'], optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
|
||||
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'), category: '外观',
|
||||
name: 'BackgroundImageLayout',
|
||||
label: '背景排列',
|
||||
description: '背景图片怎样摆放',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.ImageLayout`,
|
||||
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'],
|
||||
optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
|
||||
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'),
|
||||
category: '外观',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -112,7 +124,6 @@ export function textWidth(text: string) {
|
||||
return width
|
||||
}
|
||||
|
||||
|
||||
export const CONTROLS: ControlDef[] = [
|
||||
{
|
||||
type: `${FORMS}.Button`,
|
||||
@@ -122,7 +133,11 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Click',
|
||||
props: [...COMMON_PROPS],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['Size', size(75, 23)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
initial: (name) => [
|
||||
['Size', size(75, 23)],
|
||||
['Text', str(name)],
|
||||
['UseVisualStyleBackColor', bool(true)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.Label`,
|
||||
@@ -130,12 +145,27 @@ export const CONTROLS: ControlDef[] = [
|
||||
group: '常用控件',
|
||||
namePrefix: 'label',
|
||||
defaultEvent: 'Click',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, {
|
||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
|
||||
}],
|
||||
props: [
|
||||
...COMMON_PROPS,
|
||||
AUTO_SIZE,
|
||||
{
|
||||
name: 'BorderStyle',
|
||||
label: '边框',
|
||||
description: '边框',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'],
|
||||
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
|
||||
default: enumValue(`${FORMS}.BorderStyle`, 'None'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 5, 12)], ['Text', str(name)]],
|
||||
initial: (name) => [
|
||||
['AutoSize', bool(true)],
|
||||
['Size', size(textWidth(name) + 5, 12)],
|
||||
['Text', str(name)],
|
||||
],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
|
||||
},
|
||||
{
|
||||
@@ -151,8 +181,15 @@ export const CONTROLS: ControlDef[] = [
|
||||
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
|
||||
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
|
||||
{
|
||||
name: 'ScrollBars', label: '滚动条', description: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
|
||||
options: ['None', 'Horizontal', 'Vertical', 'Both'], optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' }, default: enumValue(`${FORMS}.ScrollBars`, 'None'), category: '外观',
|
||||
name: 'ScrollBars',
|
||||
label: '滚动条',
|
||||
description: '滚动条',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.ScrollBars`,
|
||||
options: ['None', 'Horizontal', 'Vertical', 'Both'],
|
||||
optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' },
|
||||
default: enumValue(`${FORMS}.ScrollBars`, 'None'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: COMMON_EVENTS,
|
||||
@@ -168,12 +205,22 @@ export const CONTROLS: ControlDef[] = [
|
||||
...COMMON_PROPS,
|
||||
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
|
||||
{
|
||||
name: 'DropDownStyle', label: '下拉样式', description: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
|
||||
options: ['DropDown', 'DropDownList', 'Simple'], optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' }, default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'), category: '外观',
|
||||
name: 'DropDownStyle',
|
||||
label: '下拉样式',
|
||||
description: '下拉样式',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.ComboBoxStyle`,
|
||||
options: ['DropDown', 'DropDownList', 'Simple'],
|
||||
optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' },
|
||||
default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: [...COMMON_EVENTS, { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }, { name: 'DropDown', label: '展开列表', ...EVENT }],
|
||||
initial: () => [['FormattingEnabled', bool(true)], ['Size', size(121, 20)]],
|
||||
initial: () => [
|
||||
['FormattingEnabled', bool(true)],
|
||||
['Size', size(121, 20)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.ListBox`,
|
||||
@@ -185,15 +232,23 @@ export const CONTROLS: ControlDef[] = [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
|
||||
{
|
||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观',
|
||||
name: 'BorderStyle',
|
||||
label: '边框',
|
||||
description: '边框',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'],
|
||||
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
|
||||
default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
{ name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT },
|
||||
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }],
|
||||
initial: () => [
|
||||
['FormattingEnabled', bool(true)],
|
||||
['ItemHeight', int(12)],
|
||||
['Size', size(120, 88)],
|
||||
],
|
||||
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.PictureBox`,
|
||||
@@ -206,18 +261,33 @@ export const CONTROLS: ControlDef[] = [
|
||||
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
||||
...BACKGROUND_PROPS,
|
||||
{
|
||||
name: 'SizeMode', label: '显示方式', description: '图片在图片框里怎样显示', type: 'enum', enumType: `${FORMS}.PictureBoxSizeMode`,
|
||||
name: 'SizeMode',
|
||||
label: '显示方式',
|
||||
description: '图片在图片框里怎样显示',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.PictureBoxSizeMode`,
|
||||
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
|
||||
optionLabels: { Normal: '原样放在左上角', StretchImage: '拉伸填满', AutoSize: '图片框随图片大小', CenterImage: '居中', Zoom: '按比例缩放' },
|
||||
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'), category: '外观',
|
||||
default: enumValue(`${FORMS}.PictureBoxSizeMode`, 'Normal'),
|
||||
category: '外观',
|
||||
},
|
||||
{
|
||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
|
||||
name: 'BorderStyle',
|
||||
label: '边框',
|
||||
description: '边框',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.BorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D'],
|
||||
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
|
||||
default: enumValue(`${FORMS}.BorderStyle`, 'None'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
initial: () => [['Size', size(100, 50)], ['TabStop', bool(false)]],
|
||||
initial: () => [
|
||||
['Size', size(100, 50)],
|
||||
['TabStop', bool(false)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.DataGridView`,
|
||||
@@ -228,19 +298,43 @@ export const CONTROLS: ControlDef[] = [
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'),
|
||||
{ name: 'ReadOnly', label: '只读', description: '只读:不能在表格里修改数据', type: 'bool', default: bool(false), category: '行为' },
|
||||
{ name: 'AllowUserToAddRows', label: '允许添加行', description: '最下面显示一行空行,可以在里面输入新数据', type: 'bool', default: bool(true), category: '行为' },
|
||||
{
|
||||
name: 'AllowUserToAddRows',
|
||||
label: '允许添加行',
|
||||
description: '最下面显示一行空行,可以在里面输入新数据',
|
||||
type: 'bool',
|
||||
default: bool(true),
|
||||
category: '行为',
|
||||
},
|
||||
{ name: 'AllowUserToDeleteRows', label: '允许删除行', description: '允许删除行', type: 'bool', default: bool(true), category: '行为' },
|
||||
{ name: 'RowHeadersVisible', label: '显示行标题', description: '显示最左边的行标题列', type: 'bool', default: bool(true), category: '外观' },
|
||||
{
|
||||
name: 'SelectionMode', label: '选择方式', description: '点单元格时选中什么', type: 'enum', enumType: `${FORMS}.DataGridViewSelectionMode`,
|
||||
name: 'SelectionMode',
|
||||
label: '选择方式',
|
||||
description: '点单元格时选中什么',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.DataGridViewSelectionMode`,
|
||||
options: ['CellSelect', 'FullRowSelect', 'FullColumnSelect', 'RowHeaderSelect', 'ColumnHeaderSelect'],
|
||||
optionLabels: { CellSelect: '单元格', FullRowSelect: '整行', FullColumnSelect: '整列', RowHeaderSelect: '单元格(点行标题选整行)', ColumnHeaderSelect: '单元格(点列标题选整列)' },
|
||||
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'), category: '行为',
|
||||
optionLabels: {
|
||||
CellSelect: '单元格',
|
||||
FullRowSelect: '整行',
|
||||
FullColumnSelect: '整列',
|
||||
RowHeaderSelect: '单元格(点行标题选整行)',
|
||||
ColumnHeaderSelect: '单元格(点列标题选整列)',
|
||||
},
|
||||
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'),
|
||||
category: '行为',
|
||||
},
|
||||
{
|
||||
name: 'AutoSizeColumnsMode', label: '列宽', description: '列宽怎样自动调整', type: 'enum', enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
|
||||
options: ['None', 'Fill'], optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
|
||||
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'), category: '布局',
|
||||
name: 'AutoSizeColumnsMode',
|
||||
label: '列宽',
|
||||
description: '列宽怎样自动调整',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
|
||||
options: ['None', 'Fill'],
|
||||
optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
|
||||
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'),
|
||||
category: '布局',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
@@ -251,7 +345,10 @@ export const CONTROLS: ControlDef[] = [
|
||||
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||
{ name: 'SelectionChanged', label: '选中改变', ...EVENT },
|
||||
],
|
||||
initial: () => [['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')], ['Size', size(240, 150)]],
|
||||
initial: () => [
|
||||
['ColumnHeadersHeightSizeMode', enumValue(`${FORMS}.DataGridViewColumnHeadersHeightSizeMode`, 'AutoSize')],
|
||||
['Size', size(240, 150)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'System.Windows.Forms.DataVisualization.Charting.Chart',
|
||||
@@ -265,7 +362,10 @@ export const CONTROLS: ControlDef[] = [
|
||||
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
|
||||
],
|
||||
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
||||
initial: (name) => [['Size', size(300, 300)], ['Text', str(name)]],
|
||||
initial: (name) => [
|
||||
['Size', size(300, 300)],
|
||||
['Text', str(name)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.DateTimePicker`,
|
||||
@@ -276,9 +376,15 @@ export const CONTROLS: ControlDef[] = [
|
||||
props: [
|
||||
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
{
|
||||
name: 'Format', label: '格式', description: '日期和时间的显示格式', type: 'enum', enumType: `${FORMS}.DateTimePickerFormat`,
|
||||
options: ['Long', 'Short', 'Time', 'Custom'], optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
|
||||
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'), category: '外观',
|
||||
name: 'Format',
|
||||
label: '格式',
|
||||
description: '日期和时间的显示格式',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.DateTimePickerFormat`,
|
||||
options: ['Long', 'Short', 'Time', 'Custom'],
|
||||
optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
|
||||
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'),
|
||||
category: '外观',
|
||||
},
|
||||
{ name: 'CustomFormat', label: '自定义格式', description: '格式为"自定义"时使用,如 yyyy-MM-dd HH:mm', type: 'string', category: '外观' },
|
||||
{ name: 'ShowUpDown', label: '上下箭头', description: '用上下箭头调整,代替下拉日历', type: 'bool', default: bool(false), category: '外观' },
|
||||
@@ -301,14 +407,26 @@ export const CONTROLS: ControlDef[] = [
|
||||
{ name: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
|
||||
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
|
||||
{
|
||||
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
|
||||
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
|
||||
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
|
||||
name: 'TickStyle',
|
||||
label: '刻度位置',
|
||||
description: '刻度画在哪一边',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.TickStyle`,
|
||||
options: ['None', 'TopLeft', 'BottomRight', 'Both'],
|
||||
optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
|
||||
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'),
|
||||
category: '外观',
|
||||
},
|
||||
{
|
||||
name: 'Orientation', label: '方向', description: '横向或竖向', type: 'enum', enumType: `${FORMS}.Orientation`,
|
||||
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
|
||||
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
|
||||
name: 'Orientation',
|
||||
label: '方向',
|
||||
description: '横向或竖向',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.Orientation`,
|
||||
options: ['Horizontal', 'Vertical'],
|
||||
optionLabels: { Horizontal: '横向', Vertical: '竖向' },
|
||||
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'),
|
||||
category: '外观',
|
||||
},
|
||||
],
|
||||
events: [
|
||||
@@ -326,7 +444,12 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'CheckedChanged',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '勾选', description: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
|
||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '勾选状态改变', ...EVENT }],
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 24, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
initial: (name) => [
|
||||
['AutoSize', bool(true)],
|
||||
['Size', size(textWidth(name) + 24, 16)],
|
||||
['Text', str(name)],
|
||||
['UseVisualStyleBackColor', bool(true)],
|
||||
],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
|
||||
},
|
||||
{
|
||||
@@ -337,7 +460,12 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'CheckedChanged',
|
||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '选中', description: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
|
||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '选中状态改变', ...EVENT }],
|
||||
initial: (name) => [['AutoSize', bool(true)], ['Size', size(textWidth(name) + 17, 16)], ['Text', str(name)], ['UseVisualStyleBackColor', bool(true)]],
|
||||
initial: (name) => [
|
||||
['AutoSize', bool(true)],
|
||||
['Size', size(textWidth(name) + 17, 16)],
|
||||
['Text', str(name)],
|
||||
['UseVisualStyleBackColor', bool(true)],
|
||||
],
|
||||
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
|
||||
},
|
||||
{
|
||||
@@ -349,7 +477,11 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Enter',
|
||||
props: [...COMMON_PROPS],
|
||||
events: COMMON_EVENTS,
|
||||
initial: (name) => [['Size', size(200, 100)], ['TabStop', bool(false)], ['Text', str(name)]],
|
||||
initial: (name) => [
|
||||
['Size', size(200, 100)],
|
||||
['TabStop', bool(false)],
|
||||
['Text', str(name)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: `${FORMS}.Panel`,
|
||||
@@ -371,7 +503,14 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Tick',
|
||||
props: [
|
||||
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
|
||||
{ name: 'Enabled', label: '启用', description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()', type: 'bool', default: bool(false), category: '行为' },
|
||||
{
|
||||
name: 'Enabled',
|
||||
label: '启用',
|
||||
description: '程序启动后是否立即开始计时;也可以在代码里调用 Start()',
|
||||
type: 'bool',
|
||||
default: bool(false),
|
||||
category: '行为',
|
||||
},
|
||||
],
|
||||
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
|
||||
initial: () => [],
|
||||
@@ -384,7 +523,10 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Enter',
|
||||
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
|
||||
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
|
||||
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
|
||||
initial: () => [
|
||||
['Enabled', bool(true)],
|
||||
['Size', size(245, 240)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.LED',
|
||||
@@ -404,7 +546,10 @@ export const CONTROLS: ControlDef[] = [
|
||||
defaultEvent: 'Load',
|
||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
|
||||
initial: () => [['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }], ['Size', size(105, 185)]],
|
||||
initial: () => [
|
||||
['BackColor', { kind: 'color', css: '#ffffff', name: 'White', r: 255, g: 255, b: 255, a: 255 }],
|
||||
['Size', size(105, 185)],
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'WinFormsDeviceControlLibrary.Alarm',
|
||||
@@ -433,16 +578,40 @@ export const FORM_DEF: ControlDef = {
|
||||
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
|
||||
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', category: '布局' },
|
||||
{
|
||||
name: 'FormBorderStyle', label: '边框样式', description: '边框样式', type: 'enum', enumType: `${FORMS}.FormBorderStyle`,
|
||||
name: 'FormBorderStyle',
|
||||
label: '边框样式',
|
||||
description: '边框样式',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.FormBorderStyle`,
|
||||
options: ['None', 'FixedSingle', 'Fixed3D', 'FixedDialog', 'Sizable', 'FixedToolWindow', 'SizableToolWindow'],
|
||||
optionLabels: { None: '无边框', FixedSingle: '固定单线', Fixed3D: '固定立体', FixedDialog: '对话框', Sizable: '可调整大小', FixedToolWindow: '固定工具窗口', SizableToolWindow: '可调整工具窗口' },
|
||||
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'), category: '外观',
|
||||
optionLabels: {
|
||||
None: '无边框',
|
||||
FixedSingle: '固定单线',
|
||||
Fixed3D: '固定立体',
|
||||
FixedDialog: '对话框',
|
||||
Sizable: '可调整大小',
|
||||
FixedToolWindow: '固定工具窗口',
|
||||
SizableToolWindow: '可调整工具窗口',
|
||||
},
|
||||
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'),
|
||||
category: '外观',
|
||||
},
|
||||
{
|
||||
name: 'StartPosition', label: '启动位置', description: '启动位置', type: 'enum', enumType: `${FORMS}.FormStartPosition`,
|
||||
name: 'StartPosition',
|
||||
label: '启动位置',
|
||||
description: '启动位置',
|
||||
type: 'enum',
|
||||
enumType: `${FORMS}.FormStartPosition`,
|
||||
options: ['Manual', 'CenterScreen', 'WindowsDefaultLocation', 'WindowsDefaultBounds', 'CenterParent'],
|
||||
optionLabels: { Manual: '手动指定', CenterScreen: '屏幕中央', WindowsDefaultLocation: '系统默认位置', WindowsDefaultBounds: '系统默认位置和大小', CenterParent: '父窗体中央' },
|
||||
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
|
||||
optionLabels: {
|
||||
Manual: '手动指定',
|
||||
CenterScreen: '屏幕中央',
|
||||
WindowsDefaultLocation: '系统默认位置',
|
||||
WindowsDefaultBounds: '系统默认位置和大小',
|
||||
CenterParent: '父窗体中央',
|
||||
},
|
||||
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'),
|
||||
category: '布局',
|
||||
},
|
||||
...BACKGROUND_PROPS,
|
||||
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
|
||||
@@ -468,7 +637,10 @@ export function controlDef(type: string): ControlDef {
|
||||
type,
|
||||
label: type.split('.').at(-1)!,
|
||||
group: '常用控件',
|
||||
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
|
||||
namePrefix: type
|
||||
.split('.')
|
||||
.at(-1)!
|
||||
.replace(/^./, (c) => c.toLowerCase()),
|
||||
defaultEvent: 'Click',
|
||||
props: COMMON_PROPS,
|
||||
events: COMMON_EVENTS,
|
||||
|
||||
@@ -42,8 +42,26 @@ describe('图表的系列和图例', () => {
|
||||
areas: ['ChartArea1'],
|
||||
legends: ['MaxLegend', 'AVGLegend'],
|
||||
series: [
|
||||
{ name: 'MaxSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'MaxLegend', legendText: '最大值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'MaxTemperature' },
|
||||
{ name: 'AVGSeries', chartType: 'Line', chartArea: 'ChartArea1', legend: 'AVGLegend', legendText: '平均值', isVisibleInLegend: true, xValueMember: '_Time', yValueMembers: 'AVGTemperature' },
|
||||
{
|
||||
name: 'MaxSeries',
|
||||
chartType: 'Line',
|
||||
chartArea: 'ChartArea1',
|
||||
legend: 'MaxLegend',
|
||||
legendText: '最大值',
|
||||
isVisibleInLegend: true,
|
||||
xValueMember: '_Time',
|
||||
yValueMembers: 'MaxTemperature',
|
||||
},
|
||||
{
|
||||
name: 'AVGSeries',
|
||||
chartType: 'Line',
|
||||
chartArea: 'ChartArea1',
|
||||
legend: 'AVGLegend',
|
||||
legendText: '平均值',
|
||||
isVisibleInLegend: true,
|
||||
xValueMember: '_Time',
|
||||
yValueMembers: 'AVGTemperature',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -70,7 +88,8 @@ describe('图表的系列和图例', () => {
|
||||
const { doc, name } = addControl(parseDesigner(EMPTY_FORM), def, '', { x: 10, y: 20 })
|
||||
expect(name).toBe('chart1')
|
||||
const out = generateDesigner(doc)
|
||||
expect(out).toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
|
||||
expect(out)
|
||||
.toContain(` System.Windows.Forms.DataVisualization.Charting.ChartArea chartArea1 = new System.Windows.Forms.DataVisualization.Charting.ChartArea();
|
||||
System.Windows.Forms.DataVisualization.Charting.Legend legend1 = new System.Windows.Forms.DataVisualization.Charting.Legend();
|
||||
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
|
||||
this.chart1 = new System.Windows.Forms.DataVisualization.Charting.Chart();`)
|
||||
|
||||
@@ -51,7 +51,18 @@ export const CHART_TYPES: Record<string, string> = {
|
||||
export const DEFAULT_CHART: ChartModel = {
|
||||
areas: ['ChartArea1'],
|
||||
legends: ['Legend1'],
|
||||
series: [{ name: 'Series1', chartType: 'Column', chartArea: 'ChartArea1', legend: 'Legend1', legendText: '', isVisibleInLegend: true, xValueMember: '', yValueMembers: '' }],
|
||||
series: [
|
||||
{
|
||||
name: 'Series1',
|
||||
chartType: 'Column',
|
||||
chartArea: 'ChartArea1',
|
||||
legend: 'Legend1',
|
||||
legendText: '',
|
||||
isVisibleInLegend: true,
|
||||
xValueMember: '',
|
||||
yValueMembers: '',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
interface ChartObject {
|
||||
|
||||
@@ -9,13 +9,7 @@ test('插入事件处理方法:放在类的最后,与上一个方法空一
|
||||
const code = await Bun.file(FORM1).text()
|
||||
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
|
||||
const lines = next.split('\n')
|
||||
expect(lines.slice(line - 4, line + 1)).toEqual([
|
||||
'',
|
||||
' private void Form1_Load(object sender, EventArgs e)',
|
||||
' {',
|
||||
'',
|
||||
' }',
|
||||
])
|
||||
expect(lines.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', '', ' }'])
|
||||
// 方法的 "}" 之后就是类的 "}"
|
||||
expect(lines[line + 1]).toBe(' }')
|
||||
})
|
||||
@@ -30,7 +24,9 @@ test('方法已经存在:不重复插入,只返回它的位置', async () =>
|
||||
test('类里还没有成员时不留多余空行', () => {
|
||||
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
|
||||
const { code: next, line } = ensureHandler(code, 'F', 'F_Load', 'EventArgs')
|
||||
expect(next).toBe('namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n')
|
||||
expect(next).toBe(
|
||||
'namespace A\n{\n public partial class F : Form\n {\n private void F_Load(object sender, EventArgs e)\n {\n\n }\n }\n}\n',
|
||||
)
|
||||
expect(line).toBe(7)
|
||||
})
|
||||
|
||||
|
||||
+48
-30
@@ -20,13 +20,7 @@ export function ensureHandler(code: string, className: string, handler: string,
|
||||
// 类里已有成员时,与上一个成员之间空一行
|
||||
const before = code.slice(0, lineStart)
|
||||
const hasMembers = !/\{\s*$/.test(before)
|
||||
const method = [
|
||||
...(hasMembers ? [''] : []),
|
||||
`${indent}private void ${handler}(object sender, ${args} e)`,
|
||||
`${indent}{`,
|
||||
'',
|
||||
`${indent}}`,
|
||||
].join(eol)
|
||||
const method = [...(hasMembers ? [''] : []), `${indent}private void ${handler}(object sender, ${args} e)`, `${indent}{`, '', `${indent}}`].join(eol)
|
||||
const next = `${before}${method}${eol}${code.slice(lineStart)}`
|
||||
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
|
||||
}
|
||||
@@ -45,32 +39,56 @@ export function classClosingBrace(code: string, className: string): number {
|
||||
if (i < 0) return -1
|
||||
let depth = 0
|
||||
while (i < code.length) {
|
||||
const c = code[i]
|
||||
if (code.startsWith('//', i)) {
|
||||
const end = code.indexOf('\n', i)
|
||||
i = end < 0 ? code.length : end
|
||||
const skipped = skipLiteral(code, i)
|
||||
if (skipped >= 0) {
|
||||
i = skipped
|
||||
continue
|
||||
}
|
||||
if (code.startsWith('/*', i)) {
|
||||
const end = code.indexOf('*/', i + 2)
|
||||
i = end < 0 ? code.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (c === '@' && code[i + 1] === '"') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i++
|
||||
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '{') depth++
|
||||
else if (c === '}' && --depth === 0) return i
|
||||
if (code[i] === '{') depth++
|
||||
else if (code[i] === '}' && --depth === 0) return i
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** i 处是注释、字符串或字符时返回它结束后的位置,否则返回 -1 */
|
||||
function skipLiteral(code: string, i: number): number {
|
||||
const c = code[i]
|
||||
if (code.startsWith('//', i)) {
|
||||
const end = code.indexOf('\n', i)
|
||||
return end < 0 ? code.length : end
|
||||
}
|
||||
if (code.startsWith('/*', i)) {
|
||||
const end = code.indexOf('*/', i + 2)
|
||||
return end < 0 ? code.length : end + 2
|
||||
}
|
||||
if (c === '@' && code[i + 1] === '"') {
|
||||
i += 2
|
||||
while (i < code.length && !(code[i] === '"' && code[i + 1] !== '"')) i += code[i] === '"' ? 2 : 1
|
||||
return i + 1
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
i++
|
||||
while (i < code.length && code[i] !== c && code[i] !== '\n') i += code[i] === '\\' ? 2 : 1
|
||||
return i + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** 把代码里的标识符 from 换成 to(整词,跳过字符串、字符和注释):Form1_Load、"Form1" 都不动 */
|
||||
export function renameIdentifier(code: string, from: string, to: string): string {
|
||||
const word = new RegExp(`(?<![\\p{L}\\p{N}_@])${from}(?![\\p{L}\\p{N}_])`, 'gu')
|
||||
let out = ''
|
||||
let start = 0
|
||||
let i = 0
|
||||
while (i < code.length) {
|
||||
const skipped = skipLiteral(code, i)
|
||||
if (skipped < 0) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out += code.slice(start, i).replace(word, to) + code.slice(i, skipped)
|
||||
start = i = skipped
|
||||
}
|
||||
return out + code.slice(start).replace(word, to)
|
||||
}
|
||||
@@ -18,10 +18,7 @@ async function designerFiles(dir: string) {
|
||||
}
|
||||
|
||||
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
|
||||
const files = [
|
||||
...(await designerFiles(resolve(ROOT, 'samples'))),
|
||||
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
|
||||
]
|
||||
const files = [...(await designerFiles(resolve(ROOT, 'samples'))), ...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : [])]
|
||||
for (const file of files) {
|
||||
test(file.replace(`${ROOT}/`, ''), async () => {
|
||||
const text = await Bun.file(file).text()
|
||||
|
||||
+18
-15
@@ -19,9 +19,7 @@ export function listControls(doc: DesignerDocument): ControlInfo[] {
|
||||
for (const block of doc.blocks) {
|
||||
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
|
||||
}
|
||||
return doc.prologue.flatMap((stmt) =>
|
||||
stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : [],
|
||||
)
|
||||
return doc.prologue.flatMap((stmt) => (stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : []))
|
||||
}
|
||||
|
||||
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
|
||||
@@ -133,7 +131,9 @@ export function setEvent(doc: DesignerDocument, target: string, event: string, d
|
||||
}
|
||||
|
||||
const KEYWORDS = new Set(
|
||||
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' '),
|
||||
'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(
|
||||
' ',
|
||||
),
|
||||
)
|
||||
|
||||
/** 检查控件名是否可用;可用返回 null,否则返回原因 */
|
||||
@@ -206,11 +206,7 @@ export function removeControl(doc: DesignerDocument, name: string): DesignerDocu
|
||||
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
|
||||
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
|
||||
const keep = (s: Stmt) =>
|
||||
!(
|
||||
(s.kind === 'create' && doomed.has(s.name)) ||
|
||||
(s.kind === 'add' && doomed.has(s.child)) ||
|
||||
(s.kind === 'layout' && doomed.has(s.target))
|
||||
)
|
||||
!((s.kind === 'create' && doomed.has(s.name)) || (s.kind === 'add' && doomed.has(s.child)) || (s.kind === 'layout' && doomed.has(s.target)))
|
||||
next.prologue = next.prologue.filter(keep)
|
||||
next.epilogue = next.epilogue.filter(keep)
|
||||
next.blocks = next.blocks.filter((b) => !doomed.has(b.name))
|
||||
@@ -229,7 +225,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
|
||||
return s.name === from ? { ...s, name: to, text: undefined } : s
|
||||
case 'prop':
|
||||
if (s.target !== from) return s
|
||||
return s.path === 'Name' ? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined } : { ...s, target: to, text: undefined }
|
||||
return s.path === 'Name'
|
||||
? { ...s, target: to, value: renderValue({ kind: 'string', value: to }), text: undefined }
|
||||
: { ...s, target: to, text: undefined }
|
||||
case 'event':
|
||||
case 'items':
|
||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
||||
@@ -240,7 +238,9 @@ export function renameControl(doc: DesignerDocument, from: string, to: string):
|
||||
case 'layout':
|
||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
||||
case 'raw':
|
||||
return s.text.includes(`this.${from}`) ? { kind: 'raw', text: s.text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])this\\.${from}(?![\\p{L}\\p{N}_])`, 'gu'), `this.${to}`) } : s
|
||||
return s.text.includes(`this.${from}`)
|
||||
? { kind: 'raw', text: s.text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])this\\.${from}(?![\\p{L}\\p{N}_])`, 'gu'), `this.${to}`) }
|
||||
: s
|
||||
}
|
||||
}
|
||||
next.prologue = next.prologue.map(fix)
|
||||
@@ -274,7 +274,12 @@ export function moveControl(doc: DesignerDocument, name: string, parent: string,
|
||||
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
|
||||
*/
|
||||
export function syncLayout(doc: DesignerDocument) {
|
||||
const containers = ['', ...listControls(doc).filter((c) => isContainerType(c.type)).map((c) => c.name)]
|
||||
const containers = [
|
||||
'',
|
||||
...listControls(doc)
|
||||
.filter((c) => isContainerType(c.type))
|
||||
.map((c) => c.name),
|
||||
]
|
||||
for (const container of containers) {
|
||||
const children = childrenOf(doc, container)
|
||||
const hasChildren = children.length > 0 || container === ''
|
||||
@@ -331,9 +336,7 @@ function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout'
|
||||
} else {
|
||||
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
|
||||
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
|
||||
const at = op === 'PerformLayout' && resume >= 0
|
||||
? resume + 1
|
||||
: list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
|
||||
const at = op === 'PerformLayout' && resume >= 0 ? resume + 1 : list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
|
||||
list.splice(at < 0 ? list.length : at, 0, stmt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,7 @@ function dockOf(doc: DesignerDocument, name: string) {
|
||||
function paddingOf(doc: DesignerDocument, name: string) {
|
||||
const m = /Padding\((\d+)(?:,\s*(\d+),\s*(\d+),\s*(\d+))?\)/.exec(getPropText(doc, name, 'Padding') ?? '')
|
||||
if (!m) return { left: 0, top: 0, right: 0, bottom: 0 }
|
||||
return m[2] === undefined
|
||||
? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] }
|
||||
: { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
|
||||
return m[2] === undefined ? { left: +m[1], top: +m[1], right: +m[1], bottom: +m[1] } : { left: +m[1], top: +m[2], right: +m[3], bottom: +m[4] }
|
||||
}
|
||||
|
||||
/** 容器里用来停靠的区域:客户区去掉内边距;带边框的面板去掉边框,分组框去掉边框和标题 */
|
||||
@@ -63,7 +61,8 @@ export function dockLayout(doc: DesignerDocument): DesignerDocument {
|
||||
const point = pointOf(next, name)
|
||||
if (point.x !== bounds.x || point.y !== bounds.y) next = setProp(next, name, 'Location', { kind: 'point', x: bounds.x, y: bounds.y })
|
||||
const size = sizeOf(next, name)
|
||||
if (size?.width !== bounds.width || size?.height !== bounds.height) next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
|
||||
if (size?.width !== bounds.width || size?.height !== bounds.height)
|
||||
next = setProp(next, name, 'Size', { kind: 'size', width: bounds.width, height: bounds.height })
|
||||
}
|
||||
|
||||
const layout = (container: string) => {
|
||||
|
||||
@@ -61,7 +61,11 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
|
||||
|
||||
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
|
||||
const DEVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
||||
'WinFormsDeviceControlLibrary.LED': { BackColor: '#000000', ForeColor: '#ff0000', Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false } },
|
||||
'WinFormsDeviceControlLibrary.LED': {
|
||||
BackColor: '#000000',
|
||||
ForeColor: '#ff0000',
|
||||
Font: { family: '宋体', size: 70, bold: false, italic: false, underline: false, strikeout: false },
|
||||
},
|
||||
'WinFormsDeviceControlLibrary.Lamp': { BackColor: '#ffffff' },
|
||||
'WinFormsDeviceControlLibrary.Alarm': { BackColor: '#ffffff' },
|
||||
}
|
||||
@@ -73,7 +77,8 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
|
||||
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
|
||||
props.Visible = true
|
||||
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox') props.Items = getItems(doc, name).map((item) => {
|
||||
if (type === 'System.Windows.Forms.ComboBox' || type === 'System.Windows.Forms.ListBox')
|
||||
props.Items = getItems(doc, name).map((item) => {
|
||||
const v = parseValue(item)
|
||||
return v.kind === 'string' ? v.value : item
|
||||
})
|
||||
@@ -83,7 +88,15 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
if (type === CHART_TYPE) props.Chart = previewChart(readChart(doc, name))
|
||||
// 和 VS 一样显示今天的日期
|
||||
if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long'))
|
||||
return { id: name, type: clientType(type), name, props, children: childrenOf(doc, name).filter((c) => types.has(c)).map(build) }
|
||||
return {
|
||||
id: name,
|
||||
type: clientType(type),
|
||||
name,
|
||||
props,
|
||||
children: childrenOf(doc, name)
|
||||
.filter((c) => types.has(c))
|
||||
.map(build),
|
||||
}
|
||||
}
|
||||
const formProps = propsOf(doc, '')
|
||||
return {
|
||||
@@ -91,7 +104,9 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
||||
type: 'Form',
|
||||
name: doc.formName,
|
||||
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
|
||||
children: childrenOf(doc, '').filter((c) => types.has(c)).map(build),
|
||||
children: childrenOf(doc, '')
|
||||
.filter((c) => types.has(c))
|
||||
.map(build),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,24 @@ import { expect, test } from 'bun:test'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { addControl } from './edit'
|
||||
import { controlDef } from './catalog'
|
||||
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, startupForm } from './templates'
|
||||
import {
|
||||
assignmentTemplate,
|
||||
classPathError,
|
||||
formNameTaken,
|
||||
newClassFile,
|
||||
newFormFiles,
|
||||
nextFormName,
|
||||
projectNamespace,
|
||||
renameForm,
|
||||
startupForm,
|
||||
} from './templates'
|
||||
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: 'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n' },
|
||||
{
|
||||
path: 'Program.cs',
|
||||
content:
|
||||
'namespace 登录\n{\n static class Program\n {\n static void Main()\n {\n Application.Run(new Login());\n }\n }\n}\n',
|
||||
},
|
||||
{ path: 'Login.cs', content: 'namespace 登录\n{\n public partial class Login : Form\n {\n }\n}\n' },
|
||||
]
|
||||
|
||||
@@ -68,10 +82,14 @@ test('作业起点"空白工程":Program.cs 启动空的 Form1', async () => {
|
||||
})
|
||||
|
||||
test('新建类:可以带文件夹,文件夹也是命名空间的一段;名字不能重复', () => {
|
||||
const files = [{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' }, { path: 'AppSet.cs', content: 'class AppSet { }' }]
|
||||
const files = [
|
||||
{ path: 'Program.cs', content: 'namespace 智慧校园\n{\n static class Program { }\n}\n' },
|
||||
{ path: 'AppSet.cs', content: 'class AppSet { }' },
|
||||
]
|
||||
expect(newClassFile('智慧校园', 'SQLHelpers/SQLHelper')).toEqual({
|
||||
path: 'SQLHelpers/SQLHelper.cs',
|
||||
content: 'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
|
||||
content:
|
||||
'using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing System.Threading.Tasks;\n\nnamespace 智慧校园.SQLHelpers\n{\n class SQLHelper\n {\n }\n}\n',
|
||||
})
|
||||
expect(newClassFile('智慧校园', 'RecordClass.cs').path).toBe('RecordClass.cs')
|
||||
expect(classPathError(files, 'SQLHelpers/SQLBLL')).toBeNull()
|
||||
@@ -80,3 +98,30 @@ test('新建类:可以带文件夹,文件夹也是命名空间的一段;
|
||||
expect(classPathError(files, '1abc')).toContain('不能以数字开头')
|
||||
expect(classPathError(files, 'Properties/X')).toContain('平台管理')
|
||||
})
|
||||
|
||||
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
|
||||
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
|
||||
const code = blank.find((f) => f.path === 'Form1.cs')!
|
||||
code.content = code.content.replace(
|
||||
'InitializeComponent();',
|
||||
'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();',
|
||||
)
|
||||
const files = renameForm(blank, 'Form1.Designer.cs', 'Main')
|
||||
expect(files.map((f) => f.path).sort()).toEqual(['Main.Designer.cs', 'Main.cs', 'Program.cs'])
|
||||
expect(files.find((f) => f.path === 'Program.cs')!.content).toContain('Application.Run(new Main())')
|
||||
const main = files.find((f) => f.path === 'Main.cs')!.content
|
||||
expect(main).toContain('public partial class Main : Form')
|
||||
expect(main).toContain('public Main()')
|
||||
expect(main).toContain('MessageBox.Show("Form1"); // Form1')
|
||||
expect(main).toContain('private void Form1_Load(object sender, EventArgs e)')
|
||||
expect(main).toContain('Main other = new Main();')
|
||||
const designer = files.find((f) => f.path === 'Main.Designer.cs')!.content
|
||||
const doc = parseDesigner(designer)
|
||||
expect(doc.className).toBe('Main')
|
||||
expect(doc.formName).toBe('Main')
|
||||
expect(designer).toContain(' // \n // Main\n // \n')
|
||||
expect(designer).toContain('this.Name = "Main";')
|
||||
// 窗体标题是 Text,VS 改名时也不改
|
||||
expect(designer).toContain('this.Text = "Form1";')
|
||||
expect(startupForm(files)).toBe('Main')
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件),以及布置作业时学生的起点工程。
|
||||
// 工程模板:新建窗体(与 VS 中文版新建窗体并保存一次后相同的两个文件)、窗体改名,以及布置作业时学生的起点工程。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
import { renameIdentifier } from './code'
|
||||
import { generateDesigner, parseDesigner } from './document'
|
||||
import { renderValue } from './values'
|
||||
|
||||
/** 工程的命名空间:取 Program.cs(没有就取任意 .cs)里的 namespace */
|
||||
export function projectNamespace(files: SourceFile[]): string {
|
||||
@@ -19,7 +21,10 @@ export function nextFormName(files: SourceFile[]): string {
|
||||
|
||||
/** 窗体名是否已被使用(同名文件或同名类) */
|
||||
export function formNameTaken(files: SourceFile[], name: string): boolean {
|
||||
return files.some((f) => f.path === `${name}.cs` || f.path === `${name}.Designer.cs` || new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${name}(?![\\p{L}\\p{N}_])`, 'u').test(f.content))
|
||||
return files.some(
|
||||
(f) =>
|
||||
f.path === `${name}.cs` || f.path === `${name}.Designer.cs` || new RegExp(`(?<![\\p{L}\\p{N}_])class\\s+${name}(?![\\p{L}\\p{N}_])`, 'u').test(f.content),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,5 +176,34 @@ export function assignmentTemplate(files: SourceFile[], mode: StartMode): Source
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 窗体改名(属性窗口里窗体的"名称"):和 VS 一样,类名、构造函数和各文件里的 new X()、X 类型的变量都改,
|
||||
* Designer.cs 里窗体的块头注释和 this.Name 也改;两个文件跟着改名。
|
||||
* 事件处理方法名(如 Form1_Load)和字符串里的文字不动,VS 也不改。
|
||||
*/
|
||||
export function renameForm(files: SourceFile[], designerPath: string, to: string): SourceFile[] {
|
||||
const designer = files.find((f) => f.path === designerPath)
|
||||
if (!designer) return files
|
||||
const from = parseDesigner(designer.content).className
|
||||
const codePath = designerPath.replace(/\.Designer\.cs$/, '.cs')
|
||||
const dir = designerPath.slice(0, designerPath.lastIndexOf('/') + 1)
|
||||
return files.map((f) => {
|
||||
if (!f.path.endsWith('.cs')) return f
|
||||
const content = renameIdentifier(f.content, from, to)
|
||||
if (f.path === codePath) return { path: `${dir}${to}.cs`, content }
|
||||
if (f.path !== designerPath) return content === f.content ? f : { ...f, content }
|
||||
const doc = parseDesigner(content)
|
||||
const formBlock = doc.blocks.at(-1)
|
||||
if (formBlock) {
|
||||
formBlock.name = to
|
||||
formBlock.stmts = formBlock.stmts.map((s) =>
|
||||
s.kind === 'prop' && s.target === '' && s.path === 'Name' ? { ...s, value: renderValue({ kind: 'string', value: to }), text: undefined } : s,
|
||||
)
|
||||
}
|
||||
doc.formName = to
|
||||
return { path: `${dir}${to}.Designer.cs`, content: generateDesigner(doc) }
|
||||
})
|
||||
}
|
||||
|
||||
/** 窗体的 Designer.cs(Properties/ 下平台生成的 Resources.Designer.cs 不是窗体) */
|
||||
export const isFormDesigner = (path: string) => path.endsWith('.Designer.cs') && !path.startsWith('Properties/')
|
||||
@@ -88,12 +88,9 @@ export function renderValue(value: Value): string {
|
||||
? `System.Drawing.Color.FromArgb(${[value.r, value.g, value.b].map(byteArg).join(', ')})`
|
||||
: `System.Drawing.Color.FromArgb(${[value.a, value.r, value.g, value.b].map(byteArg).join(', ')})`
|
||||
case 'font': {
|
||||
const styles = [
|
||||
value.bold && 'Bold',
|
||||
value.italic && 'Italic',
|
||||
value.underline && 'Underline',
|
||||
value.strikeout && 'Strikeout',
|
||||
].filter(Boolean) as string[]
|
||||
const styles = [value.bold && 'Bold', value.italic && 'Italic', value.underline && 'Underline', value.strikeout && 'Strikeout'].filter(
|
||||
Boolean,
|
||||
) as string[]
|
||||
const style = styles.length ? styles.map((s) => `System.Drawing.FontStyle.${s}`).join(' | ') : 'System.Drawing.FontStyle.Regular'
|
||||
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
|
||||
return `new System.Drawing.Font("${escapeString(value.family)}", ${value.size}F, ${style}, System.Drawing.GraphicsUnit.Point, ((byte)(${value.charset ?? 134})))`
|
||||
|
||||
@@ -73,7 +73,7 @@ function applyMarkers() {
|
||||
startColumn: d.column,
|
||||
endLineNumber: d.line,
|
||||
// 编译器只给出起点;标到该行的下一个词结尾,至少一个字符
|
||||
endColumn: (model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1),
|
||||
endColumn: model.getWordAtPosition({ lineNumber: d.line, column: d.column })?.endColumn ?? d.column + 1,
|
||||
}))
|
||||
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
|
||||
}
|
||||
@@ -134,11 +134,14 @@ onMounted(() => {
|
||||
applyMarkers()
|
||||
})
|
||||
|
||||
watch(() => props.files, () => {
|
||||
watch(
|
||||
() => props.files,
|
||||
() => {
|
||||
syncModels()
|
||||
showActive()
|
||||
applyMarkers()
|
||||
})
|
||||
},
|
||||
)
|
||||
watch(() => props.activeFile, showActive)
|
||||
watch(
|
||||
() => props.diagnostics,
|
||||
@@ -158,9 +161,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="code-editor">
|
||||
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
|
||||
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
|
||||
</div>
|
||||
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。</div>
|
||||
<div ref="container" class="monaco-host" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -84,8 +84,7 @@ export function registerLanguageFeatures() {
|
||||
if (!result) return { suggestions: [] }
|
||||
const range = toRange(result.range)
|
||||
return {
|
||||
suggestions: result.items.map(
|
||||
(item, index): Item => ({
|
||||
suggestions: result.items.map((item, index): Item => ({
|
||||
index,
|
||||
label: item.label,
|
||||
insertText: item.insert,
|
||||
@@ -94,8 +93,7 @@ export function registerLanguageFeatures() {
|
||||
kind: KINDS[item.kind] ?? Kind.Text,
|
||||
detail: item.detail || undefined,
|
||||
range,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
}
|
||||
},
|
||||
// 选中某一项时再取它的签名和说明
|
||||
|
||||
@@ -20,7 +20,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
|
||||
<div class="grade">
|
||||
<div v-if="submission.status === 'pending'" class="pending">
|
||||
<NSpin size="small" />
|
||||
<span>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span>
|
||||
<span
|
||||
>评分中…<template v-if="submission.queueAhead">前面还有 {{ submission.queueAhead }} 份在排队</template></span
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
|
||||
<template v-else-if="submission.result">
|
||||
@@ -47,7 +49,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="(m, i) in manual" :key="i">
|
||||
<span class="title">{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span>
|
||||
<span class="title"
|
||||
>{{ m.title }}<span class="points">{{ review.scores[i] ?? '未打分' }} / {{ m.points }} 分</span></span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
|
||||
@@ -138,6 +142,6 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
|
||||
}
|
||||
|
||||
.manual span + span::before {
|
||||
content: "、";
|
||||
content: '、';
|
||||
}
|
||||
</style>
|
||||
@@ -1,179 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 评价项面板:列出这个实验(或作业)自动检查的功能项;"自己检查"在服务器上运行学生现在的程序逐项检查,
|
||||
// 告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
|
||||
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { SelfCheck, SourceFile } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** 检查项名称 */
|
||||
titles: string[]
|
||||
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
|
||||
target: string
|
||||
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
|
||||
url: string
|
||||
/** 学生现在的代码 */
|
||||
files: SourceFile[]
|
||||
/** 开始检查前调用(先把没保存的改动存好) */
|
||||
beforeStart?: () => Promise<void>
|
||||
/** 作业的各项分值(自由练习没有) */
|
||||
points?: number[]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
|
||||
|
||||
const state = ref<SelfCheck | null>(null)
|
||||
const error = ref('')
|
||||
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
|
||||
const checkedFiles = ref<string>()
|
||||
const current = computed(() => (state.value?.target === props.target ? state.value : null))
|
||||
const running = computed(() => current.value?.status === 'running')
|
||||
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
|
||||
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function refresh() {
|
||||
clearTimeout(timer)
|
||||
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
|
||||
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) void refresh()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function start() {
|
||||
error.value = ''
|
||||
try {
|
||||
await props.beforeStart?.()
|
||||
checkedFiles.value = JSON.stringify(props.files)
|
||||
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
|
||||
timer = setTimeout(refresh, 2000)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer :show="show" :width="420" placement="right" @update:show="emit('update:show', $event)">
|
||||
<NDrawerContent title="评价项" closable>
|
||||
<p class="note">
|
||||
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
|
||||
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
|
||||
</p>
|
||||
<div class="actions">
|
||||
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
|
||||
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
|
||||
<span v-else-if="current?.status === 'done' && current.result" class="status">
|
||||
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
|
||||
</span>
|
||||
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
|
||||
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
|
||||
|
||||
<ul class="items">
|
||||
<li
|
||||
v-for="(title, i) in titles"
|
||||
:key="i"
|
||||
:class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''"
|
||||
>
|
||||
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
|
||||
<div>
|
||||
<div class="title">
|
||||
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
|
||||
</div>
|
||||
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
|
||||
{{ current.result.results[i].message }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.outdated {
|
||||
margin: 4px 0;
|
||||
color: #a16207;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 1em;
|
||||
flex: none;
|
||||
font-weight: bold;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.passed .mark {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.failed .mark {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.points {
|
||||
margin-left: 8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 2px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
// 任务面板:上面是任务说明(task.md,作业则是布置时的说明),下面是评价项——这个实验(或作业)自动检查的功能项;
|
||||
// "自己检查"在服务器上运行学生现在的程序逐项检查,告诉他哪几项不通过、为什么。不计分、不留记录,提交作业另算。
|
||||
// 面板不遮挡页面,打开着也能接着搭界面、写代码。
|
||||
import { NButton, NDrawer, NDrawerContent, NSpin } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { SelfCheck, SourceFile } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { renderMarkdown } from './markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
/** 任务说明(Markdown),没有时为 null */
|
||||
task: string | null
|
||||
/** 检查项名称(没有评分规则时为空) */
|
||||
titles: string[]
|
||||
/** sample:实验编号 或 assignment:作业编号,和服务器返回的 target 对应 */
|
||||
target: string
|
||||
/** 开始检查的接口(/api/samples/:id/selfcheck 或 /api/assignments/:id/selfcheck) */
|
||||
url: string
|
||||
/** 学生现在的代码 */
|
||||
files: SourceFile[]
|
||||
/** 开始检查前调用(先把没保存的改动存好) */
|
||||
beforeStart?: () => Promise<void>
|
||||
/** 作业的各项分值(自由练习没有) */
|
||||
points?: number[]
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:show': [show: boolean] }>()
|
||||
|
||||
const state = ref<SelfCheck | null>(null)
|
||||
const error = ref('')
|
||||
/** 开始检查时的代码;之后改过代码就提示结果可能过时(刷新页面后不知道,不提示) */
|
||||
const checkedFiles = ref<string>()
|
||||
const current = computed(() => (state.value?.target === props.target ? state.value : null))
|
||||
const running = computed(() => current.value?.status === 'running')
|
||||
const outdated = computed(() => checkedFiles.value !== undefined && current.value?.status === 'done' && checkedFiles.value !== JSON.stringify(props.files))
|
||||
const passed = computed(() => current.value?.result?.results.filter((r) => r.passed).length ?? 0)
|
||||
const taskHtml = computed(() => (props.task ? renderMarkdown(props.task) : ''))
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function refresh() {
|
||||
clearTimeout(timer)
|
||||
state.value = await api<SelfCheck | null>('GET', '/api/selfcheck').catch(() => state.value)
|
||||
if (state.value?.status === 'running') timer = setTimeout(refresh, 2000)
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show && props.titles.length) void refresh()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function start() {
|
||||
error.value = ''
|
||||
try {
|
||||
await props.beforeStart?.()
|
||||
checkedFiles.value = JSON.stringify(props.files)
|
||||
state.value = await api<SelfCheck>('POST', props.url, { files: props.files })
|
||||
timer = setTimeout(refresh, 2000)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="show"
|
||||
:default-width="460"
|
||||
resizable
|
||||
placement="right"
|
||||
:show-mask="false"
|
||||
:trap-focus="false"
|
||||
:block-scroll="false"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<NDrawerContent title="任务" closable>
|
||||
<!-- renderMarkdown 先转义全部 HTML,只输出它自己生成的标签 -->
|
||||
<div v-if="taskHtml" class="task" v-html="taskHtml" />
|
||||
<template v-if="titles.length">
|
||||
<h4 class="section">评价项</h4>
|
||||
<p class="note">
|
||||
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果, 告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
|
||||
</p>
|
||||
<div class="actions">
|
||||
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
|
||||
<span v-if="running" class="status"><NSpin size="small" /> 正在检查,每项都要启动一次程序,请稍等…</span>
|
||||
<span v-else-if="current?.status === 'done' && current.result" class="status">
|
||||
{{ formatTime(current.startedAt) }} 检查:通过 <b>{{ passed }}</b> / {{ current.result.results.length }} 项
|
||||
</span>
|
||||
<span v-else-if="current?.status === 'error'" class="status error">检查时服务器出错了,请再试一次</span>
|
||||
</div>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="outdated" class="outdated">检查之后代码改过了,结果可能已经过时</div>
|
||||
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
|
||||
|
||||
<ul class="items">
|
||||
<li v-for="(title, i) in titles" :key="i" :class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''">
|
||||
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
|
||||
<div>
|
||||
<div class="title">
|
||||
{{ title }}<span v-if="points" class="points">{{ points[i] }} 分</span>
|
||||
</div>
|
||||
<div v-if="current?.result && !running && current.result.results[i]?.message" class="message">
|
||||
{{ current.result.results[i].message }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.task :deep(h4),
|
||||
.section {
|
||||
margin: 16px 0 6px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.task :deep(h4:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.task :deep(h5) {
|
||||
margin: 12px 0 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task :deep(p),
|
||||
.task :deep(ul),
|
||||
.task :deep(ol) {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.task :deep(ul),
|
||||
.task :deep(ol) {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.task :deep(code) {
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
background: #f3f4f6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task :deep(table) {
|
||||
width: 100%;
|
||||
margin: 6px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task :deep(th),
|
||||
.task :deep(td) {
|
||||
padding: 3px 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task :deep(th) {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
/* 控件类型和控件名不折行,说明列让出宽度 */
|
||||
.task :deep(th),
|
||||
.task :deep(td:first-child),
|
||||
.task :deep(td code) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task :deep(blockquote) {
|
||||
margin: 8px 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.task + .section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.outdated {
|
||||
margin: 4px 0;
|
||||
color: #a16207;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.items li {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 1em;
|
||||
flex: none;
|
||||
font-weight: bold;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.passed .mark {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.failed .mark {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.points {
|
||||
margin-left: 8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 2px;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { renderMarkdown } from './markdown'
|
||||
|
||||
describe('任务说明的 Markdown', () => {
|
||||
test('标题、段落(段内换行保留)、加粗、代码', () => {
|
||||
expect(renderMarkdown('## 任务目标\n\n勾选**人体开关**后,\n每 5 秒检查一次 `timer1`')).toBe(
|
||||
'<h4>任务目标</h4><p>勾选<b>人体开关</b>后,<br>每 5 秒检查一次 <code>timer1</code></p>',
|
||||
)
|
||||
})
|
||||
|
||||
test('无序、有序列表,缩进的续行接在上一项', () => {
|
||||
expect(renderMarkdown('- 甲\n- 乙\n 续行\n\n1. 一\n2. 二')).toBe('<ul><li>甲</li><li>乙<br>续行</li></ul><ol><li>一</li><li>二</li></ol>')
|
||||
expect(renderMarkdown('3. 接着第 3 步')).toBe('<ol start="3"><li>接着第 3 步</li></ol>')
|
||||
expect(renderMarkdown('1. 勾选的 Click:\n - 勾上时:启动\n - 取消时:停止\n 最后刷新\n2. 下一步')).toBe(
|
||||
'<ol><li>勾选的 Click:<ul><li>勾上时:启动</li><li>取消时:停止</li></ul><br>最后刷新</li><li>下一步</li></ol>',
|
||||
)
|
||||
expect(renderMarkdown('- 甲\n - 甲1\n- 乙')).toBe('<ul><li>甲<ul><li>甲1</li></ul></li><li>乙</li></ul>')
|
||||
})
|
||||
|
||||
test('表格:列数以表头为准', () => {
|
||||
expect(renderMarkdown('| 控件 | 名称 |\n|---|---|\n| 按钮 | `btn_open` |\n| 复选框 |')).toBe(
|
||||
'<table><thead><tr><th>控件</th><th>名称</th></tr></thead><tbody><tr><td>按钮</td><td><code>btn_open</code></td></tr><tr><td>复选框</td><td></td></tr></tbody></table>',
|
||||
)
|
||||
})
|
||||
|
||||
test('引用变成提示框', () => {
|
||||
expect(renderMarkdown('> 书上写成了 `True`\n> 要改成小写')).toBe('<blockquote>书上写成了 <code>True</code><br>要改成小写</blockquote>')
|
||||
})
|
||||
|
||||
test('HTML 一律转义,不会插入标签或脚本', () => {
|
||||
const html = renderMarkdown('<script>alert(1)</script>\n- <img src=x onerror=alert(1)>\n| <b> |\n|---|\n| `<i>` |')
|
||||
expect(html).not.toContain('<script')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).toContain('<script>')
|
||||
expect(html).toContain('<code><i></code>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// 任务说明用的 Markdown 子集,转成 HTML:标题(# ## ###)、段落、无序 / 有序列表(缩进的 - 是下一级)、表格、引用(> 提示框)、**加粗**、`代码`。
|
||||
// 老师可以改写任务说明,所以先把文字里的 HTML 全部转义,只输出这里生成的标签,不会被插入脚本。
|
||||
|
||||
const escapeHtml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
/** 行内:`代码` 里的内容原样显示,其余部分处理 **加粗** */
|
||||
function inline(text: string): string {
|
||||
return text
|
||||
.split(/(`[^`]+`)/)
|
||||
.map((part) =>
|
||||
part.length > 2 && part.startsWith('`') && part.endsWith('`')
|
||||
? `<code>${escapeHtml(part.slice(1, -1))}</code>`
|
||||
: escapeHtml(part).replace(/\*\*(.+?)\*\*/g, '<b>$1</b>'),
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
|
||||
const cells = (line: string) =>
|
||||
line
|
||||
.trim()
|
||||
.replace(/^\||\|$/g, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
|
||||
const isTableSeparator = (line: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line)
|
||||
|
||||
/** 列表项的内容:续行里以 - 开头的是下一级列表,其余续行换行接在后面 */
|
||||
function listItemHtml(text: string): string {
|
||||
const [head, ...rest] = text.split('\n')
|
||||
let html = inline(head)
|
||||
let sub: string[] = []
|
||||
const flush = () => {
|
||||
if (sub.length) html += `<ul>${sub.map((t) => `<li>${inline(t)}</li>`).join('')}</ul>`
|
||||
sub = []
|
||||
}
|
||||
for (const line of rest) {
|
||||
const bullet = /^[-*]\s+(.*)$/.exec(line)
|
||||
if (bullet) sub.push(bullet[1])
|
||||
else {
|
||||
flush()
|
||||
html += `<br>${inline(line)}`
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return html
|
||||
}
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
const lines = source.replace(/\r\n?/g, '\n').split('\n')
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line.trim()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const heading = /^(#{1,3})\s+(.*)$/.exec(line)
|
||||
if (heading) {
|
||||
// 面板里地方小:# 和 ## 都是小标题
|
||||
const level = heading[1].length === 3 ? 'h5' : 'h4'
|
||||
out.push(`<${level}>${inline(heading[2])}</${level}>`)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (line.trim().startsWith('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||
const head = cells(line)
|
||||
i += 2
|
||||
const rows: string[][] = []
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) rows.push(cells(lines[i++]))
|
||||
out.push(
|
||||
`<table><thead><tr>${head.map((c) => `<th>${inline(c)}</th>`).join('')}</tr></thead>` +
|
||||
`<tbody>${rows.map((r) => `<tr>${head.map((_, k) => `<td>${inline(r[k] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const listItem = /^\s*(?:([-*])|(\d+)[.、])\s+(.*)$/
|
||||
const first = listItem.exec(line)
|
||||
if (first) {
|
||||
const ordered = !first[1]
|
||||
const items: string[] = []
|
||||
while (i < lines.length) {
|
||||
const item = listItem.exec(lines[i])
|
||||
if (items.length && /^\s{2,}\S/.test(lines[i])) {
|
||||
// 缩进的行(续行或下一级列表)接在上一项后面
|
||||
items[items.length - 1] += `\n${lines[i].trim()}`
|
||||
i++
|
||||
} else if (item && !item[1] === ordered) {
|
||||
items.push(item[3])
|
||||
i++
|
||||
} else break
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul'
|
||||
const start = ordered && first[2] !== '1' ? ` start="${Number(first[2])}"` : ''
|
||||
out.push(`<${tag}${start}>${items.map((t) => `<li>${listItemHtml(t)}</li>`).join('')}</${tag}>`)
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('>')) {
|
||||
const quoted: string[] = []
|
||||
while (i < lines.length && lines[i].startsWith('>')) quoted.push(lines[i++].replace(/^>\s?/, ''))
|
||||
out.push(`<blockquote>${quoted.map(inline).join('<br>')}</blockquote>`)
|
||||
continue
|
||||
}
|
||||
// 段落:到空行、标题、列表、表格、引用为止,段内换行保留
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!/^#{1,3}\s/.test(lines[i]) &&
|
||||
!listItem.test(lines[i]) &&
|
||||
!lines[i].startsWith('>') &&
|
||||
!(lines[i].trim().startsWith('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
|
||||
) {
|
||||
paragraph.push(lines[i++])
|
||||
}
|
||||
out.push(`<p>${paragraph.map(inline).join('<br>')}</p>`)
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
@@ -18,13 +18,15 @@ onMounted(async () => {
|
||||
loaded.value = true
|
||||
})
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
|
||||
|
||||
function status(row: Row) {
|
||||
const s = row.latest
|
||||
if (!s) return row.deadline && Date.now() > row.deadline ? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止') : h(NTag, { size: 'small' }, () => '未提交')
|
||||
if (!s)
|
||||
return row.deadline && Date.now() > row.deadline
|
||||
? h(NTag, { type: 'error', size: 'small' }, () => '未提交,已截止')
|
||||
: h(NTag, { size: 'small' }, () => '未提交')
|
||||
if (s.status === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
|
||||
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
|
||||
if (row.manualScore !== null) {
|
||||
|
||||
@@ -24,14 +24,13 @@ import { currentUser } from '../auth'
|
||||
<h2>学生</h2>
|
||||
|
||||
<h3>1. 登录</h3>
|
||||
<p>
|
||||
用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。
|
||||
</p>
|
||||
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
|
||||
|
||||
<h3>2. 自由练习和做作业</h3>
|
||||
<ul>
|
||||
<li>登录后进入<b>自由练习</b>:左上角的下拉框选教材上的任务,可以随便改、随便运行。</li>
|
||||
<li>点右上角的<b>我的作业</b>,看老师布置的作业、截止时间和得分;点作业名开始做。</li>
|
||||
<li>工具栏的<b>任务</b>:任务目标、控件名、实现步骤和在平台上怎么试,第一次打开一个实验时自动弹出。面板不挡住设计器和代码,可以开着做。</li>
|
||||
<li>
|
||||
作业的起点有两种:<b>界面已做好</b>(只写事件和代码)、<b>空白工程</b>(从搭界面做起)。控件名照书上的写,自动评分按书上的控件名操作你的程序。
|
||||
</li>
|
||||
@@ -65,10 +64,11 @@ import { currentUser } from '../auth'
|
||||
|
||||
<h3>6. 自己检查、提交</h3>
|
||||
<ul>
|
||||
<li>点<b>评价项</b>看这个任务要检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
|
||||
<li>
|
||||
做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
|
||||
<b>任务</b
|
||||
>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
|
||||
</li>
|
||||
<li>做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。</li>
|
||||
<li>点工具栏上的分数,看每一项的得分;老师评阅后,这里能看到老师的打分和评语。</li>
|
||||
</ul>
|
||||
|
||||
@@ -97,6 +97,7 @@ import { currentUser } from '../auth'
|
||||
<ul>
|
||||
<li><b>作业</b>标签页 → <b>布置作业</b>:选实验、起点(界面已做好 / 空白工程)、截止时间(可以不限)。</li>
|
||||
<li>自动评分项默认每项 10 分;老师评分项(界面、注释、命名)由你在评阅时打分,可以增删、改分值。</li>
|
||||
<li><b>任务说明</b>预填实验的说明,可以改写,比如在最前面加一段"这次只做前 3 步";布置后也能在<b>修改</b>里改。</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. 评阅、成绩</h3>
|
||||
@@ -109,9 +110,7 @@ import { currentUser } from '../auth'
|
||||
|
||||
<h3>4. 上课前后</h3>
|
||||
<ul>
|
||||
<li>
|
||||
第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。
|
||||
</li>
|
||||
<li>第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。</li>
|
||||
<li>上课时间不要更新平台(推送代码):更新会重启平台,正在运行的学生程序会结束(页面会提示并自动重连)。</li>
|
||||
<li>全班同时"自己检查"或提交时要排队,项目 8 这种大任务最后一个人大约等 1.5 分钟。</li>
|
||||
<li>重要节点前后备份数据:在服务器上执行 <code>deploy/backup.sh</code>(说明见 deploy/README.md)。</li>
|
||||
@@ -124,9 +123,7 @@ import { currentUser } from '../auth'
|
||||
<dt>窗体上的按钮点了没反应</dt>
|
||||
<dd>看运行区有没有显示"与服务器的连接断开了"。断开后程序已经结束,等它自动连上,再按 F5 重新运行。</dd>
|
||||
<dt>代码改乱了,想重来</dt>
|
||||
<dd>
|
||||
工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。
|
||||
</dd>
|
||||
<dd>工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。</dd>
|
||||
<dt>登录时提示密码输错太多次</dt>
|
||||
<dd>同一个账号连续输错 10 次要等 10 分钟,或者请老师在名单里查看、重置密码。</dd>
|
||||
<dt>网页打不开、一片空白</dt>
|
||||
|
||||
@@ -34,13 +34,7 @@ async function submit() {
|
||||
<h1>物联网编程仿真平台</h1>
|
||||
<p>用老师发给你的账号和密码登录</p>
|
||||
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
|
||||
<NInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
placeholder="密码"
|
||||
:input-props="{ autocomplete: 'current-password' }"
|
||||
/>
|
||||
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
|
||||
<RouterLink to="/help" target="_blank" class="help">第一次使用?看使用说明</RouterLink>
|
||||
|
||||
@@ -237,8 +237,7 @@ watch(selectedId, (_, old) => {
|
||||
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
|
||||
})
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success' | 'info'; text: string } {
|
||||
if (!row.status) return { type: 'default', text: '未提交' }
|
||||
@@ -262,7 +261,15 @@ function exportCsv() {
|
||||
const a = assignment.value
|
||||
if (!a) return
|
||||
downloadCsv(`${a.title}-成绩.csv`, [
|
||||
['学号', '姓名', `自动评分(${autoTotal.value})`, ...a.scoring.manual.map((m) => `${m.title}(${m.points})`), `总分(${autoTotal.value + manualTotal.value})`, '提交次数', '评语'],
|
||||
[
|
||||
'学号',
|
||||
'姓名',
|
||||
`自动评分(${autoTotal.value})`,
|
||||
...a.scoring.manual.map((m) => `${m.title}(${m.points})`),
|
||||
`总分(${autoTotal.value + manualTotal.value})`,
|
||||
'提交次数',
|
||||
'评语',
|
||||
],
|
||||
...rows.value.map((r) => [
|
||||
`\t${r.username}`,
|
||||
r.name,
|
||||
@@ -375,7 +382,8 @@ async function signOut() {
|
||||
<template v-else-if="detail.aiReview.status === 'done'">
|
||||
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
|
||||
<span class="score-title">{{ m.title }}</span>
|
||||
<b>{{ detail.aiReview.scores[i]?.score }}</b><span class="muted"> / {{ m.points }}</span>
|
||||
<b>{{ detail.aiReview.scores[i]?.score }}</b
|
||||
><span class="muted"> / {{ m.points }}</span>
|
||||
<div class="ai-reason">{{ detail.aiReview.scores[i]?.reason }}</div>
|
||||
</div>
|
||||
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
|
||||
@@ -401,9 +409,7 @@ async function signOut() {
|
||||
</div>
|
||||
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
|
||||
<div class="save-row">
|
||||
<span class="muted">
|
||||
总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }}
|
||||
</span>
|
||||
<span class="muted"> 总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }} </span>
|
||||
<span class="spacer" />
|
||||
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
|
||||
|
||||
@@ -53,9 +53,7 @@ async function loadClasses() {
|
||||
}
|
||||
|
||||
async function loadStudents() {
|
||||
students.value = selectedId.value
|
||||
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
|
||||
: []
|
||||
students.value = selectedId.value ? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? []) : []
|
||||
}
|
||||
|
||||
async function selectClass(id: number) {
|
||||
@@ -173,18 +171,14 @@ async function confirmImport() {
|
||||
else
|
||||
dialog.warning({
|
||||
title: '部分学生没有导入',
|
||||
content: () =>
|
||||
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
||||
content: () => h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
||||
positiveText: '知道了',
|
||||
})
|
||||
}
|
||||
|
||||
function exportRoster() {
|
||||
if (!selected.value) return
|
||||
downloadCsv(`${selected.value.name}-名单.csv`, [
|
||||
['学号', '姓名', '密码'],
|
||||
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
|
||||
])
|
||||
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])])
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
@@ -226,13 +220,7 @@ async function signOut() {
|
||||
</form>
|
||||
</NPopover>
|
||||
</div>
|
||||
<button
|
||||
v-for="c in classes"
|
||||
:key="c.id"
|
||||
class="class-item"
|
||||
:class="{ active: c.id === selectedId }"
|
||||
@click="selectClass(c.id)"
|
||||
>
|
||||
<button v-for="c in classes" :key="c.id" class="class-item" :class="{ active: c.id === selectedId }" @click="selectClass(c.id)">
|
||||
<span>{{ c.name }}</span>
|
||||
<span class="count">{{ c.studentCount }} 人</span>
|
||||
</button>
|
||||
@@ -267,7 +255,8 @@ async function signOut() {
|
||||
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||
</div>
|
||||
<p class="tip">
|
||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6
|
||||
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||
</p>
|
||||
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||
</NTabPane>
|
||||
@@ -292,14 +281,19 @@ async function signOut() {
|
||||
@update:show="(v: boolean) => !v && (preview = null)"
|
||||
>
|
||||
<p>
|
||||
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
|
||||
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
||||
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length"
|
||||
>, <span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
||||
>。
|
||||
</p>
|
||||
<div class="preview">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
|
||||
<tr>
|
||||
<th>学号</th>
|
||||
<th>姓名</th>
|
||||
<th>密码</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 实验页:编辑代码 → 运行 → 在网页里操作学生的窗体和虚拟实训台。
|
||||
// 三种用法:自由练习(选择示例工程);做作业(/assignments/:id,从作业起点开始,可以提交评分);
|
||||
// 老师运行学生提交的程序(/teacher/submissions/:id/run,改动只用于试运行,不保存)。
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NInput, NModal, NPopconfirm, NPopover, NSelect, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
||||
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
|
||||
@@ -10,7 +10,17 @@ import FormDesigner from '../designer/FormDesigner.vue'
|
||||
import { createDesignerHistory } from '../designer/history'
|
||||
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
|
||||
import ResourcesDialog from '../designer/ResourcesDialog.vue'
|
||||
import { classPathError, formNameTaken, isFormDesigner, newClassFile, newFormFiles, nextFormName, projectNamespace, startupForm } from '../designer/templates'
|
||||
import {
|
||||
classPathError,
|
||||
formNameTaken,
|
||||
isFormDesigner,
|
||||
newClassFile,
|
||||
newFormFiles,
|
||||
nextFormName,
|
||||
projectNamespace,
|
||||
renameForm,
|
||||
startupForm,
|
||||
} from '../designer/templates'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
@@ -18,7 +28,7 @@ import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import RunArea from '../run/RunArea.vue'
|
||||
import { createRunSession } from '../run/session'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import SelfCheckPanel from '../grading/SelfCheckPanel.vue'
|
||||
import TaskPanel from '../grading/TaskPanel.vue'
|
||||
import { captureForms } from '../grading/screenshots'
|
||||
|
||||
const props = defineProps<{ assignmentId?: string; submissionId?: string }>()
|
||||
@@ -36,11 +46,13 @@ const DESIGN = '设计:'
|
||||
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
|
||||
const forms = computed(() => {
|
||||
const startup = startupForm(files.value)
|
||||
return files.value
|
||||
return (
|
||||
files.value
|
||||
.filter((f) => isFormDesigner(f.path))
|
||||
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
||||
// 启动窗体排在最前
|
||||
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
||||
)
|
||||
})
|
||||
/**
|
||||
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
|
||||
@@ -79,7 +91,9 @@ const message = useMessage()
|
||||
const workKey = ref<string>()
|
||||
|
||||
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
||||
const statusType = computed(() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success')
|
||||
const statusType = computed(
|
||||
() => ({ idle: 'default', compiling: 'warning', running: 'success', stopped: 'default' })[state.status] as 'default' | 'warning' | 'success',
|
||||
)
|
||||
const running = computed(() => state.status === 'compiling' || state.status === 'running')
|
||||
|
||||
/**
|
||||
@@ -136,25 +150,33 @@ async function loadSample(id: string, discardWork = false) {
|
||||
sampleId.value = id
|
||||
void router.replace({ query: { sample: id } })
|
||||
sampleChecks.value = []
|
||||
sampleTask.value = null
|
||||
void api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
|
||||
.then((titles) => {
|
||||
if (sampleId.value === id) sampleChecks.value = titles
|
||||
})
|
||||
.catch(() => {})
|
||||
void api<{ task: string | null }>('GET', `/api/samples/${encodeURIComponent(id)}/task`)
|
||||
.then(({ task }) => {
|
||||
if (sampleId.value === id) sampleTask.value = task
|
||||
})
|
||||
.catch(() => {})
|
||||
await openProject(sampleWorkKey(id), () => api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(id)}`), discardWork)
|
||||
}
|
||||
|
||||
// ---------- 评价项和自测 ----------
|
||||
// ---------- 任务说明、评价项和自测 ----------
|
||||
const sampleChecks = ref<string[]>([])
|
||||
const selfCheckOpen = ref(false)
|
||||
/** 作业用布置时的评分规则,自由练习用实验的评分规则 */
|
||||
const selfCheck = computed(() => {
|
||||
const sampleTask = ref<string | null>(null)
|
||||
const taskOpen = ref(false)
|
||||
/** 作业用布置时的任务说明和评分规则,自由练习用实验的 */
|
||||
const taskPanel = computed(() => {
|
||||
if (props.assignmentId) {
|
||||
const a = assignment.value
|
||||
return a ? { titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
|
||||
return a ? { task: a.task, titles: a.checkTitles, target: `assignment:${a.id}`, url: `/api/assignments/${a.id}/selfcheck`, points: a.scoring.auto } : null
|
||||
}
|
||||
const id = sampleId.value
|
||||
return id && sampleChecks.value.length ? { titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` } : null
|
||||
if (!id || (!sampleTask.value && !sampleChecks.value.length)) return null
|
||||
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
|
||||
})
|
||||
|
||||
// ---------- 作业 ----------
|
||||
@@ -197,6 +219,23 @@ async function loadAssignment(discardWork = false) {
|
||||
// ---------- 老师运行学生提交的程序 ----------
|
||||
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
|
||||
|
||||
// 第一次打开一个实验(或作业)时自动打开任务面板;记在浏览器里,之后不再自动打开
|
||||
const TASK_SEEN_KEY = 'iot-sim:task-seen'
|
||||
watch(
|
||||
() => (taskPanel.value?.task && !viewing.value ? taskPanel.value.target : null),
|
||||
(target: string | null) => {
|
||||
if (!target) return
|
||||
try {
|
||||
const seen: string[] = JSON.parse(localStorage.getItem(TASK_SEEN_KEY) ?? '[]')
|
||||
if (seen.includes(target)) return
|
||||
localStorage.setItem(TASK_SEEN_KEY, JSON.stringify([...seen, target].slice(-200)))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
taskOpen.value = true
|
||||
},
|
||||
)
|
||||
|
||||
async function loadSubmission() {
|
||||
try {
|
||||
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
|
||||
@@ -239,8 +278,7 @@ function pollSubmission() {
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(pollTimer))
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
// ---------- 自动保存到服务器 ----------
|
||||
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
||||
@@ -391,6 +429,18 @@ function deleteForm() {
|
||||
activeTab.value = forms.value[0]?.tab ?? files.value[0]?.path
|
||||
}
|
||||
|
||||
const checkFormName = (name: string) => (formNameTaken(files.value, name) ? `已经有名为“${name}”的窗体或类` : null)
|
||||
|
||||
/** 属性窗口里给窗体改名:类名、引用和两个文件名一起改(这一步不进设计器的撤销历史) */
|
||||
function renameActiveForm(name: string) {
|
||||
const form = activeForm.value
|
||||
if (!form) return
|
||||
files.value = renameForm(files.value, form.designer, name)
|
||||
designerHistory.clear(form.designer)
|
||||
filesChanged()
|
||||
activeTab.value = DESIGN + form.designer.replace(/[^/]*\.Designer\.cs$/, `${name}.Designer.cs`)
|
||||
}
|
||||
|
||||
/** 设计器改动了 Designer.cs(以及插入事件处理方法时的代码文件) */
|
||||
function onDesignerUpdate(designerText: string, codeText?: string) {
|
||||
const form = activeForm.value
|
||||
@@ -472,13 +522,7 @@ function run() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
:options="samples"
|
||||
placeholder="选择示例工程"
|
||||
@update:value="loadSample"
|
||||
/>
|
||||
<NSelect class="sample-select" :value="sampleId" :options="samples" placeholder="选择示例工程" @update:value="loadSample" />
|
||||
</template>
|
||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||
@@ -496,8 +540,8 @@ function run() {
|
||||
{{ scoreText }}
|
||||
</NButton>
|
||||
</template>
|
||||
<NButton v-if="selfCheck && !viewing" size="small" title="这个任务自动检查哪些功能;可以自己先检查一遍(不计分)" @click="selfCheckOpen = true">
|
||||
评价项
|
||||
<NButton v-if="taskPanel && !viewing" size="small" title="任务说明、自动检查哪些功能;可以自己先检查一遍(不计分)" @click="taskOpen = !taskOpen">
|
||||
任务
|
||||
</NButton>
|
||||
<NPopconfirm v-if="modified && !viewing" positive-text="恢复" negative-text="取消" @positive-click="restoreOriginal">
|
||||
<template #trigger>
|
||||
@@ -508,8 +552,7 @@ function run() {
|
||||
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||
<span v-if="!assignmentId && !viewing && currentUser?.role === 'teacher'" class="metrics">
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
||||
{{ state.metrics.lastRoundTripMs }} ms
|
||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返 {{ state.metrics.lastRoundTripMs }} ms
|
||||
</span>
|
||||
<span class="spacer" />
|
||||
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
||||
@@ -532,13 +575,14 @@ function run() {
|
||||
<GradeResultView :assignment="assignment" :submission="submission" :review="review" />
|
||||
</NModal>
|
||||
|
||||
<SelfCheckPanel
|
||||
v-if="selfCheck"
|
||||
v-model:show="selfCheckOpen"
|
||||
:titles="selfCheck.titles"
|
||||
:target="selfCheck.target"
|
||||
:url="selfCheck.url"
|
||||
:points="selfCheck.points"
|
||||
<TaskPanel
|
||||
v-if="taskPanel"
|
||||
v-model:show="taskOpen"
|
||||
:task="taskPanel.task"
|
||||
:titles="taskPanel.titles"
|
||||
:target="taskPanel.target"
|
||||
:url="taskPanel.url"
|
||||
:points="taskPanel.points"
|
||||
:files="files"
|
||||
:before-start="flushSave"
|
||||
/>
|
||||
@@ -577,7 +621,12 @@ function run() {
|
||||
</template>
|
||||
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
|
||||
</NPopconfirm>
|
||||
<NButton size="small" quaternary title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字" @click="resourcesOpen = true">
|
||||
<NButton
|
||||
size="small"
|
||||
quaternary
|
||||
title="相当于 VS 的“项目属性 → 资源”:添加图片和声音,代码里用 Properties.Resources.名字"
|
||||
@click="resourcesOpen = true"
|
||||
>
|
||||
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
|
||||
</NButton>
|
||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||
@@ -600,7 +649,9 @@ function run() {
|
||||
@open-code="openCode"
|
||||
@open-designer-file="activeTab = activeForm.designer"
|
||||
:resources="designerResources"
|
||||
:check-form-name="checkFormName"
|
||||
@add-resource="resourcesOpen = true"
|
||||
@rename-form="renameActiveForm"
|
||||
/>
|
||||
<ResourcesDialog v-model:show="resourcesOpen" :selected="resourceNames" @save="saveResources" />
|
||||
<CodeEditor
|
||||
@@ -614,11 +665,7 @@ function run() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="run-toggle"
|
||||
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
|
||||
@click="toggleRunArea()"
|
||||
>
|
||||
<button class="run-toggle" :title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'" @click="toggleRunArea()">
|
||||
{{ runCollapsed ? '◂' : '▸' }}
|
||||
</button>
|
||||
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
|
||||
|
||||
+3
-13
@@ -59,10 +59,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
<template>
|
||||
<section class="run-area" :class="{ maximized }">
|
||||
<div ref="desktop" class="desktop">
|
||||
<div
|
||||
class="desktop-canvas"
|
||||
:style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined"
|
||||
>
|
||||
<div class="desktop-canvas" :style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined">
|
||||
<FormWindow
|
||||
v-for="(form, index) in state.forms"
|
||||
:key="form.root.id"
|
||||
@@ -73,21 +70,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
||||
:act="session.act"
|
||||
/>
|
||||
</div>
|
||||
<MessageBoxDialog
|
||||
v-for="box in state.messageBoxes"
|
||||
:key="box.requestId"
|
||||
:box="box"
|
||||
@reply="session.replyMessageBox(box.requestId, $event)"
|
||||
/>
|
||||
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
|
||||
<div v-if="state.connection === 'lost'" class="desktop-hint lost">与服务器的连接断开了,正在重新连接…</div>
|
||||
<div v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
|
||||
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
|
||||
</div>
|
||||
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||
<div class="desktop-tools">
|
||||
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚">
|
||||
缩放 {{ Math.round(scale * 100) }}%
|
||||
</span>
|
||||
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'> 缩放 {{ Math.round(scale * 100) }}% </span>
|
||||
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
|
||||
{{ maximized ? '还原' : '放大' }}
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { reactive, shallowReactive } from 'vue'
|
||||
import { validAccessToken } from '../tokens'
|
||||
import type {
|
||||
BoardState,
|
||||
ClientMessage,
|
||||
ControlNode,
|
||||
Diagnostic,
|
||||
ServerMessage,
|
||||
SourceFile,
|
||||
UserAction,
|
||||
} from '@iot/shared'
|
||||
import type { BoardState, ClientMessage, ControlNode, Diagnostic, ServerMessage, SourceFile, UserAction } from '@iot/shared'
|
||||
|
||||
export interface OpenForm {
|
||||
root: ControlNode
|
||||
|
||||
@@ -151,20 +151,18 @@ const cameraTitle = computed(() =>
|
||||
|
||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||
<div class="ceiling">
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
红外对射
|
||||
</button>
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
||||
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
||||
</div>
|
||||
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
|
||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
|
||||
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" /></svg>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
红外对射
|
||||
</button>
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
||||
</div>
|
||||
|
||||
<!-- LED 显示屏 -->
|
||||
@@ -227,7 +225,14 @@ const cameraTitle = computed(() =>
|
||||
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
|
||||
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
|
||||
<path v-if="playing" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
<path
|
||||
v-if="playing"
|
||||
d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div class="card-title">广播</div>
|
||||
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
||||
@@ -278,22 +283,16 @@ const cameraTitle = computed(() =>
|
||||
<div class="module">
|
||||
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
|
||||
<i />DI{{ i }} {{ input.label }}
|
||||
</span>
|
||||
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }"> <i />DI{{ i }} {{ input.label }} </span>
|
||||
</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
|
||||
<i />DO{{ i }} {{ label }}
|
||||
</span>
|
||||
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }"> <i />DO{{ i }} {{ label }} </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module ports">
|
||||
<div class="module-title">串口服务器</div>
|
||||
<div class="port-list">
|
||||
<span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span>
|
||||
</div>
|
||||
<div class="port-list"><span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span></div>
|
||||
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -498,7 +497,7 @@ button {
|
||||
white-space: pre;
|
||||
color: #ff2a2a;
|
||||
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
|
||||
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-family: 'SimHei', 'Noto Sans CJK SC', sans-serif;
|
||||
font-size: 30px;
|
||||
line-height: 36px;
|
||||
animation: led-scroll 12s linear infinite;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 布置作业 / 修改作业。布置时选实验、起点和截止时间,给每个评分项设分值;
|
||||
// 起点工程由浏览器用设计器引擎从参考答案生成(界面已做好:去掉事件和代码;空白工程:只有空的 Form1)。
|
||||
// 修改时只能改名称、截止时间和分值。
|
||||
// 任务说明从实验的 task.md 预填,老师可以改写(比如在前面加一段要求);修改时只能改名称、截止时间、分值和任务说明。
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { NButton, NDatePicker, NInput, NInputNumber, NModal, NRadio, NRadioGroup, NSelect, useMessage } from 'naive-ui'
|
||||
import type { Assignment, Scoring, SourceFile, StartMode } from '@iot/shared'
|
||||
@@ -26,14 +26,25 @@ const deadline = ref<number | null>(null)
|
||||
const checkTitles = ref<string[]>([])
|
||||
const auto = ref<number[]>([])
|
||||
const manual = ref<Scoring['manual']>([])
|
||||
const task = ref('')
|
||||
/** 实验原来的任务说明,"恢复成实验的说明"用 */
|
||||
const sampleTask = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
async function loadSampleTask(id: string) {
|
||||
sampleTask.value = (await api<{ task: string | null }>('GET', `/api/samples/${encodeURIComponent(id)}/task`).catch(() => ({ task: null }))).task ?? ''
|
||||
}
|
||||
|
||||
const total = computed(() => [...auto.value, ...manual.value.map((m) => m.points)].reduce((a, b) => a + (b || 0), 0))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
async (show) => {
|
||||
if (!show) return
|
||||
if (samples.value.length === 0) {
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
}
|
||||
const e = props.editing
|
||||
if (e) {
|
||||
sampleId.value = e.sampleId
|
||||
@@ -43,6 +54,10 @@ watch(
|
||||
checkTitles.value = e.checkTitles
|
||||
auto.value = [...e.scoring.auto]
|
||||
manual.value = e.scoring.manual.map((m) => ({ ...m }))
|
||||
task.value = e.task ?? ''
|
||||
await loadSampleTask(e.sampleId)
|
||||
// 加这个功能之前布置的作业没有保存说明,学生看到的是实验的说明
|
||||
if (e.task === null) task.value = sampleTask.value
|
||||
return
|
||||
}
|
||||
sampleId.value = null
|
||||
@@ -52,16 +67,17 @@ watch(
|
||||
checkTitles.value = []
|
||||
auto.value = []
|
||||
manual.value = DEFAULT_MANUAL.map((m) => ({ ...m }))
|
||||
if (samples.value.length === 0) {
|
||||
const list = await api<{ id: string; title: string }[]>('GET', '/api/samples')
|
||||
samples.value = list.map((s) => ({ label: s.title, value: s.id }))
|
||||
}
|
||||
task.value = ''
|
||||
sampleTask.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
async function chooseSample(id: string) {
|
||||
sampleId.value = id
|
||||
title.value = samples.value.find((s) => s.value === id)?.label ?? ''
|
||||
void loadSampleTask(id).then(() => {
|
||||
if (sampleId.value === id) task.value = sampleTask.value
|
||||
})
|
||||
try {
|
||||
checkTitles.value = await api<string[]>('GET', `/api/samples/${encodeURIComponent(id)}/checks`)
|
||||
auto.value = checkTitles.value.map(() => 10)
|
||||
@@ -78,7 +94,7 @@ async function save() {
|
||||
const scoring: Scoring = { auto: auto.value.map((p) => p || 0), manual: manual.value.map((m) => ({ title: m.title.trim(), points: m.points || 0 })) }
|
||||
try {
|
||||
if (props.editing) {
|
||||
await api('PATCH', `/api/teacher/assignments/${props.editing.id}`, { title: title.value, deadline: deadline.value, scoring })
|
||||
await api('PATCH', `/api/teacher/assignments/${props.editing.id}`, { title: title.value, deadline: deadline.value, scoring, task: task.value })
|
||||
} else {
|
||||
const files = await api<SourceFile[]>('GET', `/api/samples/${encodeURIComponent(sampleId.value)}`)
|
||||
await api('POST', '/api/teacher/assignments', {
|
||||
@@ -89,6 +105,7 @@ async function save() {
|
||||
template: assignmentTemplate(files, startMode.value),
|
||||
scoring,
|
||||
deadline: deadline.value,
|
||||
task: task.value,
|
||||
})
|
||||
}
|
||||
emit('saved')
|
||||
@@ -136,6 +153,15 @@ async function save() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<label>任务说明</label>
|
||||
<div class="task">
|
||||
<NInput v-model:value="task" type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" placeholder="这个实验还没有任务说明" />
|
||||
<div class="hint">
|
||||
学生在实验页的"任务"面板里看到。可以在最前面加一段要求;格式:<code>## 标题</code>、<code>- 列表</code>、<code>**加粗**</code>
|
||||
<NButton v-if="sampleTask && task !== sampleTask" size="tiny" quaternary @click="task = sampleTask">恢复成实验的说明</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>老师评分</label>
|
||||
<div class="points">
|
||||
<div v-for="(m, i) in manual" :key="i" class="point-row">
|
||||
@@ -174,6 +200,22 @@ async function save() {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.task {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
padding: 0 3px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -53,8 +53,7 @@ function review(row: Row) {
|
||||
void router.push(`/teacher/assignments/${row.id}`)
|
||||
}
|
||||
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
const columns: DataTableColumns<Row> = [
|
||||
{ title: '作业', key: 'title' },
|
||||
|
||||
@@ -46,8 +46,7 @@ function cell(row: Row, assignmentId: number) {
|
||||
const c = row.scores[assignmentId]
|
||||
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
|
||||
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
|
||||
const text =
|
||||
c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
|
||||
const text = c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
|
||||
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,46 @@
|
||||
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
|
||||
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
|
||||
import {
|
||||
ArcElement, BarController, BarElement, CategoryScale, Chart, DoughnutController, Filler, Legend, LinearScale, LineController,
|
||||
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip, type ChartConfiguration,
|
||||
ArcElement,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
DoughnutController,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PieController,
|
||||
PointElement,
|
||||
RadarController,
|
||||
RadialLinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
type ChartConfiguration,
|
||||
} from 'chart.js'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
|
||||
import type { ControlNode } from '@iot/shared'
|
||||
|
||||
Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, Filler, Legend, LinearScale, LineController,
|
||||
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip)
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
DoughnutController,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PieController,
|
||||
PointElement,
|
||||
RadarController,
|
||||
RadialLinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
|
||||
interface ChartSeries {
|
||||
name: string
|
||||
@@ -33,7 +65,23 @@ interface ChartData {
|
||||
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
|
||||
|
||||
/** WinForms 图表默认的 BrightPastel 配色 */
|
||||
const PALETTE = ['#418CF0', '#FCB441', '#E0400A', '#056492', '#BFBFBF', '#1A3B69', '#FFE382', '#129CDD', '#CA6B4B', '#005CDB', '#F3D288', '#506381', '#F1B9A8', '#E0830A', '#7893BE']
|
||||
const PALETTE = [
|
||||
'#418CF0',
|
||||
'#FCB441',
|
||||
'#E0400A',
|
||||
'#056492',
|
||||
'#BFBFBF',
|
||||
'#1A3B69',
|
||||
'#FFE382',
|
||||
'#129CDD',
|
||||
'#CA6B4B',
|
||||
'#005CDB',
|
||||
'#F3D288',
|
||||
'#506381',
|
||||
'#F1B9A8',
|
||||
'#E0830A',
|
||||
'#7893BE',
|
||||
]
|
||||
|
||||
const data = computed(() => props.node.props.Chart as ChartData | undefined)
|
||||
const canvas = ref<HTMLCanvasElement>()
|
||||
@@ -84,7 +132,8 @@ function config(d: ChartData): ChartConfiguration {
|
||||
legend: { display: d.series.some((s) => s.inLegend), position, labels: { filter: (item) => d.series[item.datasetIndex ?? 0]?.inLegend ?? true } },
|
||||
title: { display: d.titles.length > 0, text: d.titles },
|
||||
},
|
||||
scales: pie || chartKind === 'radar'
|
||||
scales:
|
||||
pie || chartKind === 'radar'
|
||||
? {}
|
||||
: {
|
||||
x: { stacked, grid: { color: '#d9d9d9' } },
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user