用 bun fmt 格式化全部代码和文档
- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致 - .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化 - 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4b8bf9241
commit
de277d3e9d
102 files changed
+1497
-585
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
# bun fmt 不格式化的文件
|
||||||
|
# 书上的摘录,保持原样
|
||||||
|
docs/教材摘要/
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
| 目录 | 内容 |
|
| 目录 | 内容 |
|
||||||
|---|---|
|
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
| `web/` | Vue 3 前端:代码编辑、虚拟窗体渲染、虚拟实训台 |
|
||||||
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
| `server/` | Bun 后端:示例工程接口,WebSocket 会话,为每次运行启动一个 C# 运行器 |
|
||||||
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
| `shared/` | 浏览器、服务器、运行器之间的消息协议(`protocol.ts`) |
|
||||||
@@ -56,7 +56,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到
|
|||||||
本机实测(16 核、28 GB):
|
本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
|---|---|
|
| ------------------------ | ----------------------------------------------------------- |
|
||||||
| 编译(编译服务预热后) | 约 10–300 ms |
|
| 编译(编译服务预热后) | 约 10–300 ms |
|
||||||
| 启动到窗体显示 | 约 100 ms |
|
| 启动到窗体显示 | 约 100 ms |
|
||||||
| 单次操作往返 | 约 15 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 拉这个镜像
|
# 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像
|
||||||
build: .
|
build: .
|
||||||
user: root
|
user: root
|
||||||
command: ["sh", "-c", "chown -R \"$$APP_UID\" /data"]
|
command: ['sh', '-c', 'chown -R "$$APP_UID" /data']
|
||||||
volumes:
|
volumes:
|
||||||
- data:/data
|
- data:/data
|
||||||
restart: "no"
|
restart: 'no'
|
||||||
networks: [default]
|
networks: [default]
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
# 整个平台的资源上限(两个班同时运行约需 1.5 GB),可在 .env 里用 APP_MEM_LIMIT 调整
|
||||||
mem_limit: ${APP_MEM_LIMIT:-4g}
|
mem_limit: ${APP_MEM_LIMIT:-4g}
|
||||||
pids_limit: 4096
|
pids_limit: 4096
|
||||||
expose: ["3000"]
|
expose: ['3000']
|
||||||
networks: [default]
|
networks: [default]
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
|
|||||||
+2
-2
@@ -97,7 +97,7 @@
|
|||||||
在服务器的 `~/IOT_SIM` 目录下:
|
在服务器的 `~/IOT_SIM` 目录下:
|
||||||
|
|
||||||
| 做什么 | 命令 |
|
| 做什么 | 命令 |
|
||||||
|---|---|
|
| ------------ | --------------------------------------------------------------------------------------- |
|
||||||
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` |
|
||||||
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
| 改老师密码 | 修改 `.env` 后 `docker compose up -d` |
|
||||||
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 |
|
||||||
@@ -125,7 +125,7 @@ deploy/restore.sh backups/20261025-153000 # 用某份备份替换现在的全部
|
|||||||
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
写在 `.env` 里,改完 `docker compose up -d` 生效。
|
||||||
|
|
||||||
| 变量 | 默认 | 说明 |
|
| 变量 | 默认 | 说明 |
|
||||||
|---|---|---|
|
| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
| `TEACHER_PASSWORD` | (必填) | 老师账号的密码 |
|
||||||
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
| `TEACHER_USERNAME` | `admin` | 老师账号 |
|
||||||
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
| `DEEPSEEK_API_KEY` | (空) | AI 评阅用的 DeepSeek 密钥;不填就不做 AI 评阅 |
|
||||||
|
|||||||
+6
-6
@@ -15,7 +15,7 @@
|
|||||||
## 已确定的关键决策
|
## 已确定的关键决策
|
||||||
|
|
||||||
| 事项 | 决定 | 理由 |
|
| 事项 | 决定 | 理由 |
|
||||||
| --- | --- | --- |
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
| 学生编程语言 | C# | 课程要求 |
|
| 学生编程语言 | C# | 课程要求 |
|
||||||
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
| 课程方向 | 上位机:WinForms 界面 + 事件 | 教学重点是界面和事件,不是协议 |
|
||||||
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
| 教材 | 《物联网编程与应用(第 2 版)》及配套资料 | 现用教材 |
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
平台提供与教材同名、同签名的仿真类库(API 已从配套 dll 和使用说明中完整整理),教材代码一字不改即可运行。教材自带的仿真版返回随机数;平台的数值由实训台场景控制,联动逻辑可以被确定地验证和评测。
|
||||||
|
|
||||||
| 对象 | 主要成员 | 平台仿真行为 |
|
| 对象 | 主要成员 | 平台仿真行为 |
|
||||||
| --- | --- | --- |
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
| `DeviceCtrlHelper.ledHelper` | `OpenLedCom(串口)`、`SendLedString(文本)` | 成功时原样返回文本,并在实训台 LED 屏上滚动显示;未打开串口返回 `"Error:发送失败"` |
|
||||||
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
| `adam4017Helper` | `Open(串口)`、`SetData()`、`Close()` | 返回 `ADAM4017ResModel`,温度、湿度、光照、气压、空气质量、CO2、风速为整数字符串,取自实训台滑块 |
|
||||||
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
| `adam4150Helper` | `Open(串口)`、`SetData()`、`OnOff(DO, 开关)`、`Close()` | `SetData()` 返回人体红外、火焰、烟雾、红外对射四个 bool;`OnOff` 控制报警灯(DO0)、照明灯(DO1)、路灯(DO2)、插座(DO3、DO4) |
|
||||||
@@ -151,7 +151,7 @@ IOT/
|
|||||||
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。
|
||||||
|
|
||||||
| 层 | 用途 | 选择 |
|
| 层 | 用途 | 选择 |
|
||||||
| --- | --- | --- |
|
| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
| 前端 | 框架 | Vue 3 + TypeScript + Vite |
|
||||||
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router |
|
||||||
| 前端 | 组件库(网站外壳) | Naive UI |
|
| 前端 | 组件库(网站外壳) | Naive UI |
|
||||||
@@ -207,7 +207,7 @@ IOT/
|
|||||||
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
实验直接对应教材的 8 个项目,平台按项目顺序逐步补齐所需能力。下表依据配套 PPT、19 个参考工程和整本教材整理;项目 1–3 只需约 10 种控件、不涉及线程和数据库。
|
||||||
|
|
||||||
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
| 项目 | 教材内容 | 平台需要支持 | 状态 |
|
||||||
| --- | --- | --- | --- |
|
| ------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------ |
|
||||||
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
| 1 C# 入门基础 | 登录与新增用户(两个窗体)、计算器、继承与多态演示 | 多窗体、Label、TextBox(PasswordChar)、Button、ComboBox、MessageBox、控制台输出 | 已完成 |
|
||||||
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
| 2 环境监测 | LED 屏推送文字;4017 采集温湿度和光照;采集后推送 LED | LED 控件、ledHelper、adam4017Helper、GroupBox、虚拟 COM 口 | 已完成 |
|
||||||
| 3 智慧路灯 | ZigBee 开关路灯;人体红外每 5 秒检测并联动路灯 | Lamp 控件、zigBeeHelper、adam4150Helper、CheckBox、RadioButton、Timer | 已完成 |
|
| 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):
|
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
| --- | --- |
|
| ---------------------- | ---------------------------------------------------- |
|
||||||
| 编译(编译服务预热后) | 10–300 ms |
|
| 编译(编译服务预热后) | 10–300 ms |
|
||||||
| 点"运行"到窗体出现 | 约 100 ms |
|
| 点"运行"到窗体出现 | 约 100 ms |
|
||||||
| 单次操作往返 | 约 15 ms |
|
| 单次操作往返 | 约 15 ms |
|
||||||
@@ -337,7 +337,7 @@ flowchart TB
|
|||||||
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
最大的风险是仿真 WinForms 的覆盖面和交互流畅度;交互流畅度已在第 0 阶段验证。
|
||||||
|
|
||||||
| 风险 | 担心什么 | 应对 |
|
| 风险 | 担心什么 | 应对 |
|
||||||
| --- | --- | --- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
| 仿真 WinForms 工作量 | 项目 4–8 需要的控件和窗体特性较多 | 按项目逐步实现;项目 1–3 只需约 10 种控件 |
|
||||||
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
| 事件顺序保真 | 书上代码依赖真实 WinForms 的事件顺序:单选按钮先触发"取消选中"再触发"选中",ComboBox 的 Click 先于展开,CheckBox 的 Click 时 Checked 已切换 | 逐项对照真实行为实现,并用书上代码做回归测试(单选、复选、下拉已实现并有测试) |
|
||||||
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
| 书与配套不一致 | 控件名、步骤在书、表格和配套工程之间经常不同,书上代码有编译错误 | 以书为准逐任务整理模板和修正版答案 |
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "iot-sim",
|
"name": "iot-sim",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["shared", "server", "web"],
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"web"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "bun run --cwd server dev",
|
"dev:server": "bun run --cwd server dev",
|
||||||
"dev:web": "bun run --cwd web 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(framesDir, { recursive: true })
|
||||||
await mkdir(work, { recursive: true })
|
await mkdir(work, { recursive: true })
|
||||||
try {
|
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 svgPath = join(work, `scene-${flag}.svg`)
|
||||||
const pngPath = join(work, `scene-${flag}.png`)
|
const pngPath = join(work, `scene-${flag}.png`)
|
||||||
await Bun.write(svgPath, svg)
|
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": "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": "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": "ico_title",
|
||||||
{"name": "icon_02", "file": "icon_02.png", "label": "图标:校园环境", "kind": "image", "key": "fa6a1eaa87c87913803c77f928fa9c16b7f4bf6d", "hidpi": "icon_02.2x.png"},
|
"file": "ico_title.png",
|
||||||
{"name": "icon_03", "file": "icon_03.png", "label": "图标:校园安防", "kind": "image", "key": "d085064a505544968278a85233b7d9ac23661169", "hidpi": "icon_03.2x.png"},
|
"label": "标题:智慧校园",
|
||||||
{"name": "icon_04", "file": "icon_04.png", "label": "图标:退出", "kind": "image", "key": "c853d74ecab64a9ee85bdf24ccfa63e80123cce3", "hidpi": "icon_04.2x.png"},
|
"kind": "image",
|
||||||
{"name": "day_01", "file": "day_01.png", "label": "路灯场景:亮灯", "kind": "image", "key": "05065abdb4538906bd7c86e940ab5fb02d51469d", "hidpi": "day_01.2x.png"},
|
"key": "8a4db5ad7bc26136ae48056d6e71fa077e36e80f",
|
||||||
{"name": "day_02", "file": "day_02.png", "label": "路灯场景:灭灯", "kind": "image", "key": "470d523e90e188fe5c423d8cffeae6993d5528ff", "hidpi": "day_02.2x.png"},
|
"hidpi": "ico_title.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": "bg_home",
|
||||||
{"name": "panel_video_ctrl", "file": "panel_video_ctrl.png", "label": "监控:方向盘底座", "kind": "image", "key": "396443a1d8c141942c0f447e0c69786cf6ad3350", "hidpi": "panel_video_ctrl.2x.png"},
|
"file": "bg_home.png",
|
||||||
{"name": "btn_arrow_up_normal", "file": "btn_arrow_up_normal.png", "label": "方向键:上", "kind": "image", "key": "3c8b75ad465c8a61d0279facc233a0c64b62205a", "hidpi": "btn_arrow_up_normal.2x.png"},
|
"label": "主界面背景:校园",
|
||||||
{"name": "btn_arrow_up_press", "file": "btn_arrow_up_press.png", "label": "方向键:上(按下)", "kind": "image", "key": "51e1b9926d2bd611e3fda74a01231b46a930c460", "hidpi": "btn_arrow_up_press.2x.png"},
|
"kind": "image",
|
||||||
{"name": "btn_arrow_down_normal", "file": "btn_arrow_down_normal.png", "label": "方向键:下", "kind": "image", "key": "caf46f81c15aaca788462ebc47e58df299f097d9", "hidpi": "btn_arrow_down_normal.2x.png"},
|
"key": "e56bcc13f7128dec305b6012b255d63fb0f94450",
|
||||||
{"name": "btn_arrow_down_press", "file": "btn_arrow_down_press.png", "label": "方向键:下(按下)", "kind": "image", "key": "88c1bea8d6fad99b14658d8265a88b04bc595006", "hidpi": "btn_arrow_down_press.2x.png"},
|
"hidpi": "bg_home.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": "icon_02",
|
||||||
{"name": "btn_arrow_right_press", "file": "btn_arrow_right_press.png", "label": "方向键:右(按下)", "kind": "image", "key": "afb1f5fe5a14b7cef298a7d4c67c4cece9e01170", "hidpi": "btn_arrow_right_press.2x.png"},
|
"file": "icon_02.png",
|
||||||
{"name": "arrow_left_normal", "file": "arrow_left_normal.png", "label": "翻页:上一张", "kind": "image", "key": "5a9036955f791f0c41ba52afffb5ecc640d137f1", "hidpi": "arrow_left_normal.2x.png"},
|
"label": "图标:校园环境",
|
||||||
{"name": "arrow_right_normal", "file": "arrow_right_normal.png", "label": "翻页:下一张", "kind": "image", "key": "62c53dfb3acf291ef07d6e52fcb989cd48c020b9", "hidpi": "arrow_right_normal.2x.png"},
|
"kind": "image",
|
||||||
{"name": "windowstate_closed_hover", "file": "windowstate_closed_hover.png", "label": "关闭按钮", "kind": "image", "key": "e361965dbd881dc21511208ce11f649517e8ddb6", "hidpi": "windowstate_closed_hover.2x.png"}
|
"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:
|
steps:
|
||||||
- click: btnAdd
|
- click: btnAdd
|
||||||
- text: { control: txtNewUser1, value: zhangsan }
|
- text: { control: txtNewUser1, value: zhangsan }
|
||||||
- text: { control: txtPass1, value: "123" }
|
- text: { control: txtPass1, value: '123' }
|
||||||
- text: { control: txtPass2, value: "123" }
|
- text: { control: txtPass2, value: '123' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- text: { control: txtUserName, value: zhangsan }
|
- text: { control: txtUserName, value: zhangsan }
|
||||||
- text: { control: txtPassword, value: "123" }
|
- text: { control: txtPassword, value: '123' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- expect: { messageBox: 登录成功 }
|
- expect: { messageBox: 登录成功 }
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
steps:
|
steps:
|
||||||
- click: btnAdd
|
- click: btnAdd
|
||||||
- text: { control: txtNewUser1, value: lisi }
|
- text: { control: txtNewUser1, value: lisi }
|
||||||
- text: { control: txtPass1, value: "123" }
|
- text: { control: txtPass1, value: '123' }
|
||||||
- text: { control: txtPass2, value: "456" }
|
- text: { control: txtPass2, value: '456' }
|
||||||
- click: btnOk
|
- click: btnOk
|
||||||
- expect: { messageBox: 用户名或密码错误 }
|
- expect: { messageBox: 用户名或密码错误 }
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
登录窗体:
|
登录窗体:
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------- | -------------------------------------------------------------------- |
|
||||||
| 窗体 | `Login` | 登录 |
|
| 窗体 | `Login` | 登录 |
|
||||||
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
|
| 文本框 | `txtUserName` | 输入用户名(前面放标签 `LblUsername`) |
|
||||||
| 文本框 | `txtPassword` | 输入密码,PasswordChar 设为星号 *(前面放标签 `LblPwd`,文字"密码") |
|
| 文本框 | `txtPassword` | 输入密码,PasswordChar 设为星号 *(前面放标签 `LblPwd`,文字"密码") |
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
增加用户窗体(点"+ 新建窗体",名字填 AddUser):
|
增加用户窗体(点"+ 新建窗体",名字填 AddUser):
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------- | --------------------------------- |
|
||||||
| 窗体 | `AddUser` | 增加用户 |
|
| 窗体 | `AddUser` | 增加用户 |
|
||||||
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
|
| 文本框 | `txtNewUser1` | 新用户名(前面放标签"用户名") |
|
||||||
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
|
| 文本框 | `txtPass1` | 密码,PasswordChar 设为星号 * |
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
# 格式说明见 server/src/grading/checks.ts
|
# 格式说明见 server/src/grading/checks.ts
|
||||||
- title: 计算器能正确计算(装箱与拆箱)
|
- title: 计算器能正确计算(装箱与拆箱)
|
||||||
steps:
|
steps:
|
||||||
- text: { control: txtNum1, value: "7" }
|
- text: { control: txtNum1, value: '7' }
|
||||||
- text: { control: txtNum2, value: "2" }
|
- text: { control: txtNum2, value: '2' }
|
||||||
- select: { control: cmboxType, item: "+" }
|
- select: { control: cmboxType, item: '+' }
|
||||||
- click: btnCalculate
|
- click: btnCalculate
|
||||||
- expect: { control: txtResult, text: "9" }
|
- expect: { control: txtResult, text: '9' }
|
||||||
- select: { control: cmboxType, item: "/" }
|
- select: { control: cmboxType, item: '/' }
|
||||||
- click: btnCalculate
|
- click: btnCalculate
|
||||||
- expect: { control: txtResult, text: "3.5" }
|
- expect: { control: txtResult, text: '3.5' }
|
||||||
|
|
||||||
- title: 多态:不同国家的人说不同的语言
|
- title: 多态:不同国家的人说不同的语言
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | -------------- | ---------------------------------------- |
|
||||||
| 窗体 | `Form1` | C#入门基础 |
|
| 窗体 | `Form1` | C#入门基础 |
|
||||||
| 分组框 | `groupBox1` | 简易计算器 |
|
| 分组框 | `groupBox1` | 简易计算器 |
|
||||||
| 文本框 | `txtNum1` | 第一个数 |
|
| 文本框 | `txtNum1` | 第一个数 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ---------- | -------------- | ----------------------------------------------- |
|
||||||
| 窗体 | `Form1` | LED显示屏 |
|
| 窗体 | `Form1` | LED显示屏 |
|
||||||
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
|
| LED 显示屏 | `led` | 窗体上方的大块 LED 控件,在工具箱"物联网设备"里 |
|
||||||
| 分组框 | `groupBox1` | LED控制 |
|
| 分组框 | `groupBox1` | LED控制 |
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
- click: btn_Open
|
- click: btn_Open
|
||||||
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
|
- expect: { control: btn_Open, text: 关闭, desc: 按钮文字变成"关闭" }
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "25" }
|
- expect: { control: txt_temperature, text: '25' }
|
||||||
- expect: { control: txt_humidity, text: "60" }
|
- expect: { control: txt_humidity, text: '60' }
|
||||||
- expect: { control: txt_light, text: "500" }
|
- expect: { control: txt_light, text: '500' }
|
||||||
|
|
||||||
- title: 环境变化后能获取到新的数值
|
- title: 环境变化后能获取到新的数值
|
||||||
steps:
|
steps:
|
||||||
@@ -16,5 +16,5 @@
|
|||||||
- click: btn_Open
|
- click: btn_Open
|
||||||
- board: { temperature: 31, light: 300 }
|
- board: { temperature: 31, light: 300 }
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "31" }
|
- expect: { control: txt_temperature, text: '31' }
|
||||||
- expect: { control: txt_light, text: "300" }
|
- expect: { control: txt_light, text: '300' }
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ---------------------------------------------- | -------------------------- |
|
||||||
| 窗体 | `frm_analog` | 模拟量采集 |
|
| 窗体 | `frm_analog` | 模拟量采集 |
|
||||||
| 分组框 | `grp_show` | 数据显示 |
|
| 分组框 | `grp_show` | 数据显示 |
|
||||||
| 标签 | `lbl_light`、`lbl_temperature`、`lbl_humidity` | 光照度、温度、湿度 |
|
| 标签 | `lbl_light`、`lbl_temperature`、`lbl_humidity` | 光照度、温度、湿度 |
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
- select: { control: com_ledPort, item: COM5 }
|
- select: { control: com_ledPort, item: COM5 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { control: txt_temperature, text: "25" }
|
- expect: { control: txt_temperature, text: '25' }
|
||||||
- expect: { led: "25", desc: 显示温度 25 }
|
- expect: { led: '25', desc: 显示温度 25 }
|
||||||
- expect: { led: "60", desc: 显示湿度 60 }
|
- expect: { led: '60', desc: 显示湿度 60 }
|
||||||
- expect: { led: "500", desc: 显示光照度 500 }
|
- expect: { led: '500', desc: 显示光照度 500 }
|
||||||
|
|
||||||
- title: 能推送最新的环境信息
|
- title: 能推送最新的环境信息
|
||||||
steps:
|
steps:
|
||||||
@@ -19,4 +19,4 @@
|
|||||||
- click: btn_get
|
- click: btn_get
|
||||||
- board: { temperature: 31 }
|
- board: { temperature: 31 }
|
||||||
- click: btn_get
|
- click: btn_get
|
||||||
- expect: { led: "31", desc: 显示最新的温度 31 }
|
- expect: { led: '31', desc: 显示最新的温度 31 }
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ---------- | ---------------------------------------------- | ---------------------------------------- |
|
||||||
| 窗体 | `frm_LED` | LED推送模拟量 |
|
| 窗体 | `frm_LED` | LED推送模拟量 |
|
||||||
| LED 显示屏 | `led` | 窗体上方的 LED 控件 |
|
| LED 显示屏 | `led` | 窗体上方的 LED 控件 |
|
||||||
| 分组框 | `grp_show` | 数据显示 |
|
| 分组框 | `grp_show` | 数据显示 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| -------- | ------------ | ------------------------------------------------------ |
|
||||||
| 窗体 | `Form1` | ZigBee路灯 |
|
| 窗体 | `Form1` | ZigBee路灯 |
|
||||||
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
|
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭(工具箱"物联网设备"里的**灯泡**) |
|
||||||
| 单选按钮 | `rb_Open` | 开(开灯) |
|
| 单选按钮 | `rb_Open` | 开(开灯) |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ----------------- | ---------------------------------------------- |
|
||||||
| 窗体 | `Form1` | 人体红外ZigBee联动 |
|
| 窗体 | `Form1` | 人体红外ZigBee联动 |
|
||||||
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 |
|
| 灯泡 | `lamp1` | 窗体上显示路灯的亮灭 |
|
||||||
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
|
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------ | -------------------------- |
|
||||||
| 窗体 | `Form1` | 红外对射 |
|
| 窗体 | `Form1` | 红外对射 |
|
||||||
| 标签 | `lbl_port` | 环境串口 |
|
| 标签 | `lbl_port` | 环境串口 |
|
||||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------- | -------------------------------------------------------------- |
|
||||||
| 窗体 | `Form1` | 网络摄像机 |
|
| 窗体 | `Form1` | 网络摄像机 |
|
||||||
| 图片框 | `pictureBox1` | 显示画面;背景色设为黑色,显示方式选"拉伸填满"(StretchImage) |
|
| 图片框 | `pictureBox1` | 显示画面;背景色设为黑色,显示方式选"拉伸填满"(StretchImage) |
|
||||||
| 标签 | `label1` | 摄像机IP |
|
| 标签 | `label1` | 摄像机IP |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ---------------- | ---------------------------------------------------------- |
|
||||||
| 窗体 | `Form1` | 报警灯 |
|
| 窗体 | `Form1` | 报警灯 |
|
||||||
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
|
| 报警灯 | `alarm1` | 窗体上显示报警灯的亮灭(工具箱"物联网设备"里的**报警灯**) |
|
||||||
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
|
| 按钮 | `btn_alarmOpen` | 开(打开报警灯) |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------ | ---------------------------------------------- |
|
||||||
| 窗体 | `Form1` | 火焰 |
|
| 窗体 | `Form1` | 火焰 |
|
||||||
| 标签 | `lbl_port` | 串口 |
|
| 标签 | `lbl_port` | 串口 |
|
||||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------ | ------------------------ |
|
||||||
| 窗体 | `Form1` | 烟雾 |
|
| 窗体 | `Form1` | 烟雾 |
|
||||||
| 标签 | `lbl_port` | 串口 |
|
| 标签 | `lbl_port` | 串口 |
|
||||||
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
| 下拉框 | `com_port` | ADAM-4150 的串口 |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
主窗体 Form1:
|
主窗体 Form1:
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ---------- | ----------------------------------- | ---------------------------- |
|
||||||
| 窗体 | `Form1` | 火焰烟雾 |
|
| 窗体 | `Form1` | 火焰烟雾 |
|
||||||
| LED 显示屏 | `led1` | 窗体顶部,显示推送的灾情 |
|
| LED 显示屏 | `led1` | 窗体顶部,显示推送的灾情 |
|
||||||
| 分组框 | `groupBox2` | 数据采集 |
|
| 分组框 | `groupBox2` | 数据采集 |
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
历史记录窗体 Form2:
|
历史记录窗体 Form2:
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ------------- | ------------------ |
|
||||||
| 窗体 | `Form2` | 历史灾情记录 |
|
| 窗体 | `Form2` | 历史灾情记录 |
|
||||||
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
|
| 列表框 | `lstbRecored` | 一行一条灾情记录 |
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| -------- | ---------------------------------------------- | ------------------------ |
|
||||||
| 窗体 | `Form1` | 校园灾情数据存储 |
|
| 窗体 | `Form1` | 校园灾情数据存储 |
|
||||||
| 分组框 | `groupBox2` | 配置 |
|
| 分组框 | `groupBox2` | 配置 |
|
||||||
| 下拉框 | `com_port` | ADAM-4017+ 的串口 |
|
| 下拉框 | `com_port` | ADAM-4017+ 的串口 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------------ | --------------- | ------------------ |
|
||||||
| 窗体 | `Form1` | 查询校园灾情数据 |
|
| 窗体 | `Form1` | 查询校园灾情数据 |
|
||||||
| 标签 | `LblStartTime` | 开始日期 |
|
| 标签 | `LblStartTime` | 开始日期 |
|
||||||
| 日期时间控件 | `StartTime` | 选开始日期 |
|
| 日期时间控件 | `StartTime` | 选开始日期 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ---------------------------------------------- | ----------------------------------------------- |
|
||||||
| 窗体 | `frm_control` | 智慧教室情景控制 |
|
| 窗体 | `frm_control` | 智慧教室情景控制 |
|
||||||
| 分组框 | `grp_mode` | 模式选择 |
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
| 标签 | `lbl_mode` | 智慧教室将开启(启用后变成"智慧教室已开启") |
|
| 标签 | `lbl_mode` | 智慧教室将开启(启用后变成"智慧教室已开启") |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
做一个"智慧寝室"控制窗体:点"开启情景模式"后,程序每秒显示当前时间和光照度、温度、湿度,并按时间段自动切换四种情景模式,控制灯光、窗帘和广播:
|
做一个"智慧寝室"控制窗体:点"开启情景模式"后,程序每秒显示当前时间和光照度、温度、湿度,并按时间段自动切换四种情景模式,控制灯光、窗帘和广播:
|
||||||
|
|
||||||
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
|
| 时间段 | 模式 | 灯光 | 窗帘(滑动条) | 广播 |
|
||||||
|---|---|---|---|---|
|
| ----------- | -------- | ---- | -------------- | ------ |
|
||||||
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
|
| 6:45–7:30 | 起床模式 | 开 | 5(半开) | 放铃声 |
|
||||||
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
|
| 7:30–21:00 | 上课模式 | 关 | 0(全开) | 关 |
|
||||||
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
|
| 21:00–22:00 | 回寝模式 | 开 | 10(全关) | 放铃声 |
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ---------- | ---------------------------------------------- | ------------------------------------------------------------- |
|
||||||
| 窗体 | `frm_control` | 智慧寝室情景控制 |
|
| 窗体 | `frm_control` | 智慧寝室情景控制 |
|
||||||
| 分组框 | `grp_mode` | 模式选择 |
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
| 文本框 | `txt_time` | 显示当前时间 |
|
| 文本框 | `txt_time` | 显示当前时间 |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
做一个"智慧会议室"预约窗体:程序一启动就每秒显示当前时间和光照度、温度、湿度、气压、空气质量。填好时、分、秒,选一种模式(会议、讨论、展示),点"预约";到了预约时间,程序自动开启这种模式,控制灯光、窗帘和主、客话筒:
|
做一个"智慧会议室"预约窗体:程序一启动就每秒显示当前时间和光照度、温度、湿度、气压、空气质量。填好时、分、秒,选一种模式(会议、讨论、展示),点"预约";到了预约时间,程序自动开启这种模式,控制灯光、窗帘和主、客话筒:
|
||||||
|
|
||||||
| 模式 | 灯光 | 窗帘(滑动条) | 主话筒 | 客话筒 |
|
| 模式 | 灯光 | 窗帘(滑动条) | 主话筒 | 客话筒 |
|
||||||
|---|---|---|---|---|
|
| ---- | ---- | -------------- | ------ | ------ |
|
||||||
| 会议 | 开 | 0(全开) | 开 | 关 |
|
| 会议 | 开 | 0(全开) | 开 | 关 |
|
||||||
| 讨论 | 开 | 0(全开) | 开 | 开 |
|
| 讨论 | 开 | 0(全开) | 开 | 开 |
|
||||||
| 展示 | 开 | 10(全关) | 开 | 关 |
|
| 展示 | 开 | 10(全关) | 开 | 关 |
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ---------------------------------------------- | ---------------------------------------- |
|
||||||
| 窗体 | `frm_control` | 智慧会议室情景模式 |
|
| 窗体 | `frm_control` | 智慧会议室情景模式 |
|
||||||
| 分组框 | `grp_mode` | 模式选择 |
|
| 分组框 | `grp_mode` | 模式选择 |
|
||||||
| 标签 | `lbl_time` | 当前时间:00:00:00(运行时显示当前时间) |
|
| 标签 | `lbl_time` | 当前时间:00:00:00(运行时显示当前时间) |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ----------------- | ------------------------------------------------------------------------------ |
|
||||||
| 窗体 | `Main` | 主界面;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
| 窗体 | `Main` | 主界面;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
||||||
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 53, 147, 237 |
|
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 53, 147, 237 |
|
||||||
| 图片框 | `pictureBox1` | 放在 panel1 上,图片 `ico_title`("智慧校园"标题),SizeMode 设为 StretchImage |
|
| 图片框 | `pictureBox1` | 放在 panel1 上,图片 `ico_title`("智慧校园"标题),SizeMode 设为 StretchImage |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
## 界面和控件名
|
## 界面和控件名
|
||||||
|
|
||||||
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
| 控件 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| 窗体 | `Environment` | 校园环境;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
| 窗体 | `Environment` | 校园环境;FormBorderStyle 设为 None,StartPosition 设为 CenterScreen |
|
||||||
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 100, 116, 133,放标题图 `ico_title` |
|
| 面板 | `panel1` | 顶部标题栏:停靠 Top,背景色 100, 116, 133,放标题图 `ico_title` |
|
||||||
| 面板 | `panel2` | 停靠 Fill,下面的面板都放在它里面 |
|
| 面板 | `panel2` | 停靠 Fill,下面的面板都放在它里面 |
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
界面在任务 2 已经做好,这次要写代码的控件:
|
界面在任务 2 已经做好,这次要写代码的控件:
|
||||||
|
|
||||||
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| -------- | ---------------------------------------------- | ------------------------------------------ |
|
||||||
| 主界面 | `Main` | Load 里打开所有设备的串口 |
|
| 主界面 | `Main` | Load 里打开所有设备的串口 |
|
||||||
| 主界面 | `pic_environment` | "校园环境":打开 Environment 窗体 |
|
| 主界面 | `pic_environment` | "校园环境":打开 Environment 窗体 |
|
||||||
| 校园环境 | `Environment` | Load 里启动采集线程,FormClosed 里停止线程 |
|
| 校园环境 | `Environment` | Load 里启动采集线程,FormClosed 里停止线程 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
接着任务 3 的程序做。Security 窗体顶部和 Environment 一样:标题栏 + "< 返回主窗体"。
|
接着任务 3 的程序做。Security 窗体顶部和 Environment 一样:标题栏 + "< 返回主窗体"。
|
||||||
|
|
||||||
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| -------- | --------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||||
| 主界面 | `pic_security` | "校园安防":打开 Security 窗体 |
|
| 主界面 | `pic_security` | "校园安防":打开 Security 窗体 |
|
||||||
| 校园安防 | `Security` | 无边框、屏幕居中 |
|
| 校园安防 | `Security` | 无边框、屏幕居中 |
|
||||||
| 校园安防 | `panel1`、`panel3` | 标题栏(放标题图 `ico_title`)、返回栏,都停靠 Top |
|
| 校园安防 | `panel1`、`panel3` | 标题栏(放标题图 `ico_title`)、返回栏,都停靠 Top |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
接着任务 4 的程序做。
|
接着任务 4 的程序做。
|
||||||
|
|
||||||
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
| 窗体 | 名称(Name) | 文字(Text)和用途 |
|
||||||
|---|---|---|
|
| ------------ | ------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||||
| 校园安防 | `btn_history` | 查看历史图像:打开 SecurityCameraData 窗体 |
|
| 校园安防 | `btn_history` | 查看历史图像:打开 SecurityCameraData 窗体 |
|
||||||
| 历史报警记录 | `SecurityCameraData` | 无边框、屏幕居中 |
|
| 历史报警记录 | `SecurityCameraData` | 无边框、屏幕居中 |
|
||||||
| 历史报警记录 | `panel1` | 标题栏:背景色 100, 116, 133,左边标签 `label1`"历史报警记录"(白字) |
|
| 历史报警记录 | `panel1` | 标题栏:背景色 100, 116, 133,左边标签 `label1`"历史报警记录"(白字) |
|
||||||
|
|||||||
+11
-7
@@ -64,7 +64,10 @@ function userPrompt(input: {
|
|||||||
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
|
? 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
|
const code = input.files
|
||||||
.filter((f) => f.path.endsWith('.cs'))
|
.filter((f) => f.path.endsWith('.cs'))
|
||||||
.map((f) => `===== ${f.path} =====\n${f.content}`)
|
.map((f) => `===== ${f.path} =====\n${f.content}`)
|
||||||
@@ -75,8 +78,12 @@ function userPrompt(input: {
|
|||||||
${items}
|
${items}
|
||||||
|
|
||||||
${BOOK_CRITERIA}
|
${BOOK_CRITERIA}
|
||||||
${input.bookNames.length ? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
|
${
|
||||||
` : ''}
|
input.bookNames.length
|
||||||
|
? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
平台自动评分的结果(功能,供参考,不用再评):
|
平台自动评分的结果(功能,供参考,不用再评):
|
||||||
${auto}
|
${auto}
|
||||||
|
|
||||||
@@ -101,10 +108,7 @@ async function callModel(system: string, user: string, screenshots: Screenshot[]
|
|||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [
|
content: [{ type: 'text', text: user }, ...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } }))],
|
||||||
{ type: 'text', text: user },
|
|
||||||
...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } })),
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
+27
-4
@@ -259,7 +259,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
|||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
|
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
|
||||||
|
|
||||||
app.post('/api/teacher/classes', async (c) => {
|
app.post('/api/teacher/classes', async (c) => {
|
||||||
@@ -308,7 +307,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.post('/api/teacher/assignments', async (c) => {
|
app.post('/api/teacher/assignments', async (c) => {
|
||||||
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c)
|
const a = await body<{
|
||||||
|
classId: number
|
||||||
|
sampleId: string
|
||||||
|
title: string
|
||||||
|
startMode: StartMode
|
||||||
|
template: unknown
|
||||||
|
scoring: Scoring
|
||||||
|
deadline: number | null
|
||||||
|
task: string | null
|
||||||
|
}>(c)
|
||||||
if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404)
|
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
|
const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null
|
||||||
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
|
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
|
||||||
@@ -352,7 +360,13 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
|||||||
classSubmissions(db, id(c)).map((s) => {
|
classSubmissions(db, id(c)).map((s) => {
|
||||||
const review = getReview(db, id(c), s.userId)
|
const review = getReview(db, id(c), s.userId)
|
||||||
const ai = s.submissionId ? getAiReview(db, s.submissionId) : null
|
const ai = s.submissionId ? getAiReview(db, s.submissionId) : null
|
||||||
return { ...s, manualScore: manualScore(review), review, aiStatus: ai?.status ?? null, aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null }
|
return {
|
||||||
|
...s,
|
||||||
|
manualScore: manualScore(review),
|
||||||
|
review,
|
||||||
|
aiStatus: ai?.status ?? null,
|
||||||
|
aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -378,7 +392,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
|
|||||||
if (!s.submissionId || getReview(db, id(c), s.userId)) continue
|
if (!s.submissionId || getReview(db, id(c), s.userId)) continue
|
||||||
const ai = getAiReview(db, s.submissionId)
|
const ai = getAiReview(db, s.submissionId)
|
||||||
if (ai?.status !== 'done') continue
|
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 })
|
return c.json({ adopted })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ export function updateAssignment(
|
|||||||
id,
|
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)
|
db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id)
|
||||||
}
|
}
|
||||||
return {}
|
return {}
|
||||||
@@ -180,7 +182,10 @@ export function createSubmission(db: Database, assignmentId: number, userId: num
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pendingSubmissionIds(db: Database): number[] {
|
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) {
|
export function submissionForGrading(db: Database, id: number) {
|
||||||
@@ -211,7 +216,16 @@ export function saveGrade(db: Database, id: number, result: GradeResult | null)
|
|||||||
export function classSubmissions(db: Database, assignmentId: number) {
|
export function classSubmissions(db: Database, assignmentId: number) {
|
||||||
return db
|
return db
|
||||||
.query<
|
.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]
|
[number]
|
||||||
>(
|
>(
|
||||||
`SELECT u.id AS user_id, u.username, u.name, s.id AS submission_id, s.status, s.auto_score, s.created_at,
|
`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。
|
* 恢复真实时间是 clock = null。
|
||||||
*/
|
*/
|
||||||
setClock(time: string | undefined, speed: number | undefined) {
|
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
|
if (speed !== undefined && (typeof speed !== 'number' || !Number.isFinite(speed) || speed < 1 || speed > 3600)) return false
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
this.state.clock = {
|
this.state.clock = {
|
||||||
@@ -124,6 +125,14 @@ export class Board {
|
|||||||
export function wallNow(now = new Date()) {
|
export function wallNow(now = new Date()) {
|
||||||
return new Intl.DateTimeFormat('sv-SE', {
|
return new Intl.DateTimeFormat('sv-SE', {
|
||||||
timeZone: config.timeZone,
|
timeZone: config.timeZone,
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
year: 'numeric',
|
||||||
}).format(now).replace(' ', 'T')
|
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[] {
|
export function listStudents(db: Database, classId: number): Student[] {
|
||||||
return db
|
return db.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId)
|
||||||
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
|
|
||||||
.all(classId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -61,7 +61,15 @@ export type Expectation =
|
|||||||
*/
|
*/
|
||||||
const DEFAULT_WITHIN = 5000
|
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 STRING_PROPS = new Set(['Text', 'LedText'])
|
||||||
const NUMBER_PROPS = new Set(['Value'])
|
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 ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
|
||||||
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, 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 ('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 ('exited' in raw) {
|
||||||
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
|
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
|
||||||
return { kind: 'exited' }
|
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 }
|
return { kind: 'row', control, cells, present: 'row' in raw }
|
||||||
}
|
}
|
||||||
// image: true 或 image: 资源名;background: 资源名
|
// 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
|
if (!(key in raw)) continue
|
||||||
const value = raw[key]
|
const value = raw[key]
|
||||||
if (value === true) return { kind: 'image', control, prop }
|
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)
|
const index = items.indexOf(step.item)
|
||||||
if (index < 0) {
|
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}`)
|
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
|
||||||
}
|
}
|
||||||
driver.act(node, { action: 'select', index })
|
driver.act(node, { action: 'select', index })
|
||||||
@@ -151,7 +152,14 @@ async function runStep(driver: ProgramDriver, step: Step) {
|
|||||||
const rows = gridRows(node)
|
const rows = gridRows(node)
|
||||||
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
|
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)
|
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 })
|
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 })
|
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
|
||||||
await driver.settle()
|
await driver.settle()
|
||||||
@@ -302,8 +310,10 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
|
|||||||
case 'prop': {
|
case 'prop': {
|
||||||
const actual = need(driver, e.control).props[e.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 === '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 === 'Enabled')
|
||||||
if (e.prop === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
|
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 === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
|
||||||
if (e.prop === 'IsOn') 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)}`
|
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': {
|
case 'contains': {
|
||||||
const node = need(driver, e.control)
|
const node = need(driver, e.control)
|
||||||
const items = listItems(node)
|
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)}`
|
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
|
||||||
}
|
}
|
||||||
case 'matches':
|
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('、')}` : '实际没有播放声音'}`
|
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
|
||||||
case 'row': {
|
case 'row': {
|
||||||
const node = need(driver, e.control)
|
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 columns = gridColumns(node)
|
||||||
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
|
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 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 ? ' ……' : ''}` : '没有数据'}`
|
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 { safeJoin } from '../run-session'
|
||||||
import { grade } from './grader'
|
import { grade } from './grader'
|
||||||
|
|
||||||
|
|
||||||
/** 把作品写进一个干净的工程目录(评分和自测用) */
|
/** 把作品写进一个干净的工程目录(评分和自测用) */
|
||||||
export async function writeProject(dir: string, files: SourceFile[]) {
|
export async function writeProject(dir: string, files: SourceFile[]) {
|
||||||
await rm(dir, { recursive: true, force: true })
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
|||||||
+9
-2
@@ -30,7 +30,11 @@ if (sandboxError) console.error(sandboxError)
|
|||||||
const db = openDatabase()
|
const db = openDatabase()
|
||||||
ensureTeacher(db)
|
ensureTeacher(db)
|
||||||
const aiQueue = createAiQueue(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 {
|
interface SocketData {
|
||||||
userId?: number
|
userId?: number
|
||||||
@@ -58,7 +62,10 @@ const server = Bun.serve({
|
|||||||
sendPings: true,
|
sendPings: true,
|
||||||
open(ws) {
|
open(ws) {
|
||||||
const userId = ws.data.userId!
|
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) {
|
async message(ws, raw) {
|
||||||
let message: ClientMessage
|
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} 项` }
|
if (!Array.isArray(scores) || scores.length !== manual.length) return { error: `老师评分项应有 ${manual.length} 项` }
|
||||||
for (const [i, s] of scores.entries()) {
|
for (const [i, s] of scores.entries()) {
|
||||||
if (s === null) continue
|
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(
|
db.query(
|
||||||
`INSERT INTO reviews (assignment_id, user_id, scores, comment, updated_at) VALUES (?, ?, ?, ?, ?)
|
`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)
|
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
|
if (!student) return null
|
||||||
const latest = latestSubmission(db, assignmentId, userId)
|
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 files = row ? (JSON.parse(row.files) as SourceFile[]) : null
|
||||||
const screenshots = row?.screenshots ? (JSON.parse(row.screenshots) as Screenshot[]) : []
|
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 {
|
return {
|
||||||
student,
|
student,
|
||||||
submission: latest,
|
submission: latest,
|
||||||
@@ -77,7 +81,13 @@ export function submissionFiles(db: Database, submissionId: number) {
|
|||||||
)
|
)
|
||||||
.get(submissionId)
|
.get(submissionId)
|
||||||
if (!row) return null
|
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)) }
|
return { id: a.id, title: a.title, scoring, total: sum(scoring.auto) + sum(scoring.manual.map((m) => m.points)) }
|
||||||
})
|
})
|
||||||
const students = db
|
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)
|
.all(classId)
|
||||||
return {
|
return {
|
||||||
assignments: assignments.map(({ id, title, total }) => ({ id, title, total })),
|
assignments: assignments.map(({ id, title, total }) => ({ id, title, total })),
|
||||||
|
|||||||
+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 env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
|
||||||
const cmd = [
|
const cmd = [
|
||||||
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
|
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
|
||||||
'nice', '-n', '10',
|
'nice',
|
||||||
|
'-n',
|
||||||
|
'10',
|
||||||
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
|
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
|
||||||
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
|
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
|
||||||
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
|
'prlimit',
|
||||||
|
`--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`,
|
||||||
|
'--core=0',
|
||||||
...bwrapArgs(projectDir),
|
...bwrapArgs(projectDir),
|
||||||
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
|
...(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 }
|
return { cmd, env }
|
||||||
}
|
}
|
||||||
@@ -105,15 +112,27 @@ function bwrapArgs(projectDir: string) {
|
|||||||
'--unshare-all',
|
'--unshare-all',
|
||||||
'--die-with-parent',
|
'--die-with-parent',
|
||||||
'--new-session',
|
'--new-session',
|
||||||
'--ro-bind', '/usr', '/usr',
|
'--ro-bind',
|
||||||
|
'/usr',
|
||||||
|
'/usr',
|
||||||
...rootLinks(),
|
...rootLinks(),
|
||||||
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
|
'--ro-bind-try',
|
||||||
|
'/etc/localtime',
|
||||||
|
'/etc/localtime',
|
||||||
...dotnetMounts(),
|
...dotnetMounts(),
|
||||||
'--proc', '/proc',
|
'--proc',
|
||||||
'--dev', '/dev',
|
'/proc',
|
||||||
'--tmpfs', '/tmp',
|
'--dev',
|
||||||
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
|
'/dev',
|
||||||
'--ro-bind', projectDir, APP_DIR,
|
'--tmpfs',
|
||||||
'--chdir', APP_DIR,
|
'/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 =
|
export type SqlRequest =
|
||||||
| { type: 'exec'; dir: string; database: string; sql: string }
|
{ type: 'exec'; dir: string; database: string; sql: string } | { type: 'script'; dir: string; sql: string } | { type: 'explorer'; dir: string }
|
||||||
| { type: 'script'; dir: string; sql: string }
|
|
||||||
| { type: 'explorer'; dir: string }
|
|
||||||
|
|
||||||
/** SQL 报错(编号和信息与 SQL Server 一致) */
|
/** SQL 报错(编号和信息与 SQL Server 一致) */
|
||||||
export class SqlError extends Error {
|
export class SqlError extends Error {
|
||||||
@@ -78,7 +76,13 @@ class SqlService {
|
|||||||
stdin: 'pipe',
|
stdin: 'pipe',
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'inherit',
|
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.proc = proc
|
||||||
this.ready = new Promise<void>((resolveReady) => {
|
this.ready = new Promise<void>((resolveReady) => {
|
||||||
|
|||||||
+106
-18
@@ -33,9 +33,7 @@ class Browser {
|
|||||||
const headers: Record<string, string> = { 'x-real-ip': this.ip }
|
const headers: Record<string, string> = { 'x-real-ip': this.ip }
|
||||||
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
|
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`
|
||||||
if (json !== undefined) headers['content-type'] = 'application/json'
|
if (json !== undefined) headers['content-type'] = 'application/json'
|
||||||
const response = await app.fetch(
|
const response = await app.fetch(new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }))
|
||||||
new Request(`http://localhost${path}`, { method, headers, body: json === undefined ? undefined : JSON.stringify(json) }),
|
|
||||||
)
|
|
||||||
const body = (await response.json()) as any
|
const body = (await response.json()) as any
|
||||||
if (body?.accessToken) {
|
if (body?.accessToken) {
|
||||||
this.accessToken = body.accessToken
|
this.accessToken = body.accessToken
|
||||||
@@ -418,7 +416,13 @@ describe('作业', () => {
|
|||||||
|
|
||||||
describe('评阅与成绩', () => {
|
describe('评阅与成绩', () => {
|
||||||
const SAMPLE = 'p4-t3-报警灯'
|
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() {
|
async function submitted() {
|
||||||
const t = await teacher()
|
const t = await teacher()
|
||||||
@@ -427,7 +431,15 @@ describe('评阅与成绩', () => {
|
|||||||
{ username: 's2', name: '乙', password: 'p2' },
|
{ username: 's2', name: '乙', password: 'p2' },
|
||||||
])
|
])
|
||||||
const files = (await loadSample(SAMPLE))!
|
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()
|
const student = new Browser()
|
||||||
await student.login('s1', 'p1')
|
await student.login('s1', 'p1')
|
||||||
await student.post(`/api/assignments/${body.id}/submit`, { files })
|
await student.post(`/api/assignments/${body.id}/submit`, { files })
|
||||||
@@ -452,7 +464,11 @@ describe('评阅与成绩', () => {
|
|||||||
|
|
||||||
expect((await student.get(`/api/assignments/${assignmentId}`)).body.review).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' })
|
expect((await student.get(`/api/assignments/${assignmentId}`)).body.review).toMatchObject({ scores: [8, null], comment: '界面整齐,注释还要补' })
|
||||||
expect((await student.get('/api/assignments')).body[0].manualScore).toBe(8)
|
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)
|
}, 60000)
|
||||||
|
|
||||||
test('成绩表:总分 = 自动评分 + 老师评分;没提交的没有总分', async () => {
|
test('成绩表:总分 = 自动评分 + 老师评分;没提交的没有总分', async () => {
|
||||||
@@ -469,7 +485,11 @@ describe('评阅与成绩', () => {
|
|||||||
test('老师减少评分项后,已打的分按新的评分项对齐', async () => {
|
test('老师减少评分项后,已打的分按新的评分项对齐', async () => {
|
||||||
const { t, assignmentId, userId } = await submitted()
|
const { t, assignmentId, userId } = await submitted()
|
||||||
await t.put(`/api/teacher/assignments/${assignmentId}/students/${userId}/review`, { scores: [8, 4], comment: '' })
|
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])
|
expect((await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body.review.scores).toEqual([5])
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
@@ -487,7 +507,12 @@ describe('语言服务', () => {
|
|||||||
|
|
||||||
test('代码补全、边写边检查(中文提示);请求格式不对拒绝;没登录不能用', async () => {
|
test('代码补全、边写边检查(中文提示);请求格式不对拒绝;没登录不能用', async () => {
|
||||||
const t = await teacher()
|
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 })
|
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']))
|
expect(completions.body.items.map((i: any) => i.label)).toEqual(expect.arrayContaining(['Text', 'Click', 'Enabled']))
|
||||||
|
|
||||||
@@ -500,7 +525,13 @@ describe('语言服务', () => {
|
|||||||
|
|
||||||
test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => {
|
test('平台支持的旧版线程 API(Suspend / Resume / Abort)不提示"已过时"', async () => {
|
||||||
const t = await teacher()
|
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 })
|
const check = await t.post('/api/language', { type: 'diagnostics', session: 'tab2', files })
|
||||||
expect(check.body.diagnostics).toEqual([])
|
expect(check.body.diagnostics).toEqual([])
|
||||||
}, 60000)
|
}, 60000)
|
||||||
@@ -529,16 +560,40 @@ describe('数据库页面', () => {
|
|||||||
database: '测试库',
|
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'张%'",
|
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 不存在的表' })
|
const error = await t.post('/api/db/query', { database: '测试库', sql: 'SELECT * FROM 不存在的表' })
|
||||||
expect(error.body).toEqual({ type: 'error', number: 208, message: "对象名 '不存在的表' 无效。", line: 1 })
|
expect(error.body).toEqual({ type: 'error', number: 208, message: "对象名 '不存在的表' 无效。", line: 1 })
|
||||||
|
|
||||||
const explorer = (await t.get('/api/db')).body
|
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: 'ID', type: 'int', nullable: false, identity: true, primaryKey: true },
|
||||||
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
|
{ name: 'name', type: 'nvarchar(20)', nullable: true, identity: false, primaryKey: false },
|
||||||
] }] }])
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
expect((await student.get('/api/db')).body.databases).toEqual([])
|
expect((await student.get('/api/db')).body.databases).toEqual([])
|
||||||
expect((await new Browser().get('/api/db')).status).toBe(401)
|
expect((await new Browser().get('/api/db')).status).toBe(401)
|
||||||
}, 60000)
|
}, 60000)
|
||||||
@@ -573,7 +628,13 @@ describe('数据库页面', () => {
|
|||||||
|
|
||||||
describe('AI 评阅', () => {
|
describe('AI 评阅', () => {
|
||||||
const SAMPLE = 'p4-t3-报警灯'
|
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[] = []
|
const requests: any[] = []
|
||||||
let fake: ReturnType<typeof Bun.serve>
|
let fake: ReturnType<typeof Bun.serve>
|
||||||
@@ -584,7 +645,13 @@ describe('AI 评阅', () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
async fetch(req) {
|
async fetch(req) {
|
||||||
requests.push(await req.json())
|
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 } }] })
|
return Response.json({ choices: [{ message: { content } }] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -599,9 +666,15 @@ describe('AI 评阅', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('分数限制在 0 到满分并取整;评分项个数不对时报错', () => {
|
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({
|
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: '好',
|
comment: '好',
|
||||||
})
|
})
|
||||||
expect(() => normalizeAiResult({ scores: [{ score: 1 }] }, manual)).toThrow('评分项个数不对')
|
expect(() => normalizeAiResult({ scores: [{ score: 1 }] }, manual)).toThrow('评分项个数不对')
|
||||||
@@ -611,7 +684,15 @@ describe('AI 评阅', () => {
|
|||||||
const t = await teacher()
|
const t = await teacher()
|
||||||
const classId = await classWithStudents(t, [{ username: 's1', name: '甲同学', password: 'p1' }])
|
const classId = await classWithStudents(t, [{ username: 's1', name: '甲同学', password: 'p1' }])
|
||||||
const files = (await loadSample(SAMPLE))!
|
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 assignmentId = body.id as number
|
||||||
const student = new Browser()
|
const student = new Browser()
|
||||||
await student.login('s1', 'p1')
|
await student.login('s1', 'p1')
|
||||||
@@ -622,7 +703,14 @@ describe('AI 评阅', () => {
|
|||||||
|
|
||||||
const userId = (await student.get('/api/me')).body.user.id
|
const userId = (await student.get('/api/me')).body.user.id
|
||||||
const detail = (await t.get(`/api/teacher/assignments/${assignmentId}/students/${userId}`)).body
|
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(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/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 })
|
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 s.client.request('PUT', `/api/works/${encodeURIComponent(`assignment:${assignment.id}`)}`, { files: s.files })
|
||||||
})
|
})
|
||||||
|
|
||||||
await phase('边写边检查', students, (s) =>
|
await phase('边写边检查', students, (s) => s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }))
|
||||||
s.client.request('POST', '/api/language', { type: 'diagnostics', session: 'tab', files: s.files }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 老师说"运行看看":全班 SPREAD 内先后按 F5
|
// 老师说"运行看看":全班 SPREAD 内先后按 F5
|
||||||
await phase('运行到窗体出现', students, async (s) => {
|
await phase('运行到窗体出现', students, async (s) => {
|
||||||
@@ -209,7 +207,11 @@ try {
|
|||||||
window.form = ((await shown) as Extract<ServerMessage, { type: 'formShown' }>).form
|
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!
|
const window = s.window!
|
||||||
window.action('com_lamp', { action: 'dropDown' })
|
window.action('com_lamp', { action: 'dropDown' })
|
||||||
window.action('com_lamp', { action: 'select', index: 3 })
|
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)
|
const lit = window.waitFor((m) => m.type === 'board' && m.state.zigbee[1] === true, 30_000)
|
||||||
window.action('btn_lampOpen', { action: 'click' })
|
window.action('btn_lampOpen', { action: 'click' })
|
||||||
await lit
|
await lit
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 程序都还开着,再自己检查一遍(评分进程和正在运行的程序抢 CPU)
|
// 程序都还开着,再自己检查一遍(评分进程和正在运行的程序抢 CPU)
|
||||||
await phase('自己检查', students, async (s) => {
|
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()
|
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))
|
test('每个示例都有评分规则', () => expect(ids.length).toBe(23))
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
test(id, async () => {
|
test(
|
||||||
|
id,
|
||||||
|
async () => {
|
||||||
const result = await grade(await prepare(id, id), await checksOf(id), id)
|
const result = await grade(await prepare(id, id), await checksOf(id), id)
|
||||||
expect(result.compiled).toBe(true)
|
expect(result.compiled).toBe(true)
|
||||||
expect(result.results.filter((r) => !r.passed)).toEqual([])
|
expect(result.results.filter((r) => !r.passed)).toEqual([])
|
||||||
}, 60000)
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -66,7 +70,11 @@ describe('改坏的代码', () => {
|
|||||||
|
|
||||||
test('判断写反:入侵时显示"正常"', async () => {
|
test('判断写反:入侵时显示"正常"', async () => {
|
||||||
const id = 'p4-t1-红外对射'
|
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 result = await grade(dir, await checksOf(id), id)
|
||||||
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
|
const realtime = result.results.find((r) => r.title === '能实时显示入侵数据')!
|
||||||
expect(realtime.passed).toBe(false)
|
expect(realtime.passed).toBe(false)
|
||||||
@@ -94,7 +102,11 @@ describe('改坏的代码', () => {
|
|||||||
|
|
||||||
test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {
|
test('数据库:照书上 INSERT 不写列名,温度和光照存反了', async () => {
|
||||||
const id = 'p6-t2-存储环境数据'
|
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 result = await grade(dir, await checksOf(id), id)
|
||||||
const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')!
|
const save = result.results.find((r) => r.title === '能把数据保存到数据库,并显示在表格中')!
|
||||||
expect(save.passed).toBe(false)
|
expect(save.passed).toBe(false)
|
||||||
@@ -157,7 +169,11 @@ describe('改坏的代码', () => {
|
|||||||
|
|
||||||
test('管理平台:开灯时换错了背景图片', async () => {
|
test('管理平台:开灯时换错了背景图片', async () => {
|
||||||
const id = 'p8-t3-环境采集与分析'
|
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 result = await grade(dir, await checksOf(id), id)
|
||||||
const lamp = result.results.find((r) => r.title === '路灯能手动控制')!
|
const lamp = result.results.find((r) => r.title === '路灯能手动控制')!
|
||||||
expect(lamp).toMatchObject({ passed: false, message: '期望 panel_bg 背景换成亮灯的场景(day_01),实际是别的图片' })
|
expect(lamp).toMatchObject({ passed: false, message: '期望 panel_bg 背景换成亮灯的场景(day_01),实际是别的图片' })
|
||||||
@@ -165,7 +181,11 @@ describe('改坏的代码', () => {
|
|||||||
|
|
||||||
test('报警灯接错了 DO 口', async () => {
|
test('报警灯接错了 DO 口', async () => {
|
||||||
const id = 'p4-t3-报警灯'
|
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)
|
const result = await grade(dir, await checksOf(id), id)
|
||||||
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
|
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|||||||
+43
-16
@@ -176,7 +176,13 @@ test('编译错误:书上常见的 True 大写给出中文提示', async () =>
|
|||||||
const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏')
|
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}`))
|
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()
|
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)
|
runner = await Runner.start(dir)
|
||||||
const compiled = await runner.waitFor('compiled')
|
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: port, action: 'select', index: 1 })
|
||||||
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
|
||||||
const textOf = (name: string) =>
|
const textOf = (name: string) =>
|
||||||
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
|
runner
|
||||||
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
|
.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')
|
let temperature = textOf('txt_temperature')
|
||||||
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_get'), action: 'click' })
|
||||||
@@ -279,19 +285,23 @@ test('项目1 任务1:多窗体——打开增加用户窗体、添加用户
|
|||||||
describe('所有示例都能编译通过', async () => {
|
describe('所有示例都能编译通过', async () => {
|
||||||
const ids = (await Array.fromAsync(new Bun.Glob('*/meta.json').scan({ cwd: resolve(ROOT, 'samples') }))).map((p) => p.split('/')[0]).sort()
|
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) {
|
for (const id of ids) {
|
||||||
test(id, async () => {
|
test(
|
||||||
|
id,
|
||||||
|
async () => {
|
||||||
const result = await compileService.compile(await prepareSample(id))
|
const result = await compileService.compile(await prepareSample(id))
|
||||||
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
expect(result.diagnostics.filter((d) => d.severity === 'error')).toEqual([])
|
||||||
}, 30000)
|
},
|
||||||
|
30000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('项目1 任务2:计算器(装箱拆箱)和多态', async () => {
|
test('项目1 任务2:计算器(装箱拆箱)和多态', async () => {
|
||||||
const { runner, id } = await startSample('p1-t2-面向对象特性')
|
const { runner, id } = await startSample('p1-t2-面向对象特性')
|
||||||
const textOf = (name: string) =>
|
const textOf = (name: string) =>
|
||||||
runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then(
|
runner
|
||||||
(m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value,
|
.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('txtNum1'), action: 'text', value: '7' })
|
||||||
runner.send({ type: 'action', id: id('txtNum2'), action: 'text', value: '2' })
|
runner.send({ type: 'action', id: id('txtNum2'), action: 'text', value: '2' })
|
||||||
runner.send({ type: 'action', id: id('cmboxType'), action: 'select', index: 3 })
|
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('')
|
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||||
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
|
expect(runner.messages.filter((m) => m.type === 'exception')).toEqual([])
|
||||||
for (const line of [
|
for (const line of [
|
||||||
'state0=Unstarted', 'state1=Suspended', 'frozen=True', 'abortSuspended=error', 'joined=True,Stopped',
|
'state0=Unstarted',
|
||||||
'selfState=Suspended', 'self=resumed', 'resumeRunning=error', 'sleeperJoined=True', 'unstarted=Unstarted, AbortRequested', 'param=42',
|
'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)
|
expect(output).toContain(line)
|
||||||
}
|
}
|
||||||
@@ -677,11 +696,13 @@ test('TrackBar:拖动时先 ValueChanged 再 Scroll,超出范围的值截到
|
|||||||
runner.send({ type: 'action', id: bar.id, action: 'scroll', value: '99' })
|
runner.send({ type: 'action', id: bar.id, action: 'scroll', value: '99' })
|
||||||
await runner.waitFor('console', (m) => m.text === 'scroll=32\n')
|
await runner.waitFor('console', (m) => m.text === 'scroll=32\n')
|
||||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
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')
|
expect(output).toContain('changed=26\nscroll=26\nchanged=32\nscroll=32\n')
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读', async () => {
|
test.skipIf(config.sandbox === 'none')(
|
||||||
|
'沙箱:看不到服务器文件、别的运行目录、环境变量和其他进程,工程目录只读',
|
||||||
|
async () => {
|
||||||
process.env.SIM_TEST_SECRET = '不该被看到'
|
process.env.SIM_TEST_SECRET = '不该被看到'
|
||||||
const { runner } = await runMain(
|
const { runner } = await runMain(
|
||||||
'sandbox',
|
'sandbox',
|
||||||
@@ -707,7 +728,9 @@ test.skipIf(config.sandbox === 'none')('沙箱:看不到服务器文件、别
|
|||||||
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
|
expect(Number(/procs=(\d+)/.exec(output)?.[1])).toBeLessThanOrEqual(2)
|
||||||
expect(output).toContain('write=denied')
|
expect(output).toContain('write=denied')
|
||||||
expect(output).toContain('tmp=True')
|
expect(output).toContain('tmp=True')
|
||||||
}, 30000)
|
},
|
||||||
|
30000,
|
||||||
|
)
|
||||||
|
|
||||||
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
|
test('内存上限:不停分配内存的程序得到 OutOfMemoryException,而不是吃光服务器内存', async () => {
|
||||||
const { runner } = await runMain(
|
const { runner } = await runMain(
|
||||||
@@ -768,7 +791,9 @@ using System.Runtime.InteropServices;
|
|||||||
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
|
expect(result.diagnostics.filter((d) => d.file.includes('AssemblyInfo'))).toEqual([])
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到时被结束,并提示检查死循环', async () => {
|
test.skipIf(config.sandbox === 'none')(
|
||||||
|
'CPU 时间上限:死循环的程序到时被结束,并提示检查死循环',
|
||||||
|
async () => {
|
||||||
const limit = config.cpuLimitSeconds
|
const limit = config.cpuLimitSeconds
|
||||||
config.cpuLimitSeconds = 2
|
config.cpuLimitSeconds = 2
|
||||||
try {
|
try {
|
||||||
@@ -778,4 +803,6 @@ test.skipIf(config.sandbox === 'none')('CPU 时间上限:死循环的程序到
|
|||||||
} finally {
|
} finally {
|
||||||
config.cpuLimitSeconds = limit
|
config.cpuLimitSeconds = limit
|
||||||
}
|
}
|
||||||
}, 30000)
|
},
|
||||||
|
30000,
|
||||||
|
)
|
||||||
@@ -13,7 +13,10 @@ function tableNames(task: string): string[] {
|
|||||||
previous = []
|
previous = []
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const cells = line.trim().replace(/^\||\|$/g, '').split('|')
|
const cells = line
|
||||||
|
.trim()
|
||||||
|
.replace(/^\||\|$/g, '')
|
||||||
|
.split('|')
|
||||||
// 表头是分隔行(|---|)上面那一行;数据行里出现"名称"两个字不算表头
|
// 表头是分隔行(|---|)上面那一行;数据行里出现"名称"两个字不算表头
|
||||||
if (cells.every((c) => /^\s*:?-+:?\s*$/.test(c))) column = previous.findIndex((c) => c.includes('名称'))
|
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]))
|
else if (column >= 0) names.push(...[...(cells[column] ?? '').matchAll(/`([^`]+)`/g)].map((m) => m[1]))
|
||||||
|
|||||||
+8
-2
@@ -22,10 +22,16 @@
|
|||||||
if (!app || app.hasAttribute('data-v-app')) return
|
if (!app || app.hasAttribute('data-v-app')) return
|
||||||
app.innerHTML =
|
app.innerHTML =
|
||||||
'<div style="max-width:560px;margin:80px auto;padding:0 16px;font:16px/1.8 sans-serif;color:#333">' +
|
'<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>用 360 浏览器、QQ 浏览器时,请点地址栏右边的闪电图标,切换到<b>极速模式</b>;或者换用新版的 <b>Chrome</b>、<b>Edge</b>。' +
|
||||||
'还是打不开的话,请把下面这行文字告诉老师:</p>' +
|
'还是打不开的话,请把下面这行文字告诉老师:</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) {
|
window.addEventListener('error', function (event) {
|
||||||
if (event && event.error instanceof SyntaxError) {
|
if (event && event.error instanceof SyntaxError) {
|
||||||
|
|||||||
+1
-1
@@ -16,6 +16,6 @@ import { NConfigProvider, NDialogProvider, NMessageProvider, zhCN } from 'naive-
|
|||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
@@ -82,7 +82,12 @@ function problem(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sqlFor = (name: string) =>
|
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() {
|
function save() {
|
||||||
const error = problem()
|
const error = problem()
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ defineExpose({ reload: load })
|
|||||||
.grid {
|
.grid {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 12px;
|
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;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ function choose(key: string) {
|
|||||||
const REFRESH: ExplorerAction = { kind: 'refresh' }
|
const REFRESH: ExplorerAction = { kind: 'refresh' }
|
||||||
|
|
||||||
function rootMenu(event: MouseEvent) {
|
function rootMenu(event: MouseEvent) {
|
||||||
openMenu(event, [['新建数据库…', { kind: 'newDatabase' }], ['-', REFRESH], ['刷新', REFRESH]])
|
openMenu(event, [
|
||||||
|
['新建数据库…', { kind: 'newDatabase' }],
|
||||||
|
['-', REFRESH],
|
||||||
|
['刷新', REFRESH],
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
function databaseMenu(event: MouseEvent, database: string) {
|
function databaseMenu(event: MouseEvent, database: string) {
|
||||||
@@ -61,7 +65,11 @@ function databaseMenu(event: MouseEvent, database: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tablesMenu(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) {
|
function tableMenu(event: MouseEvent, database: string, table: ExplorerTable) {
|
||||||
@@ -88,18 +96,21 @@ function columnText(column: ExplorerTable['columns'][number]) {
|
|||||||
<ul class="tree">
|
<ul class="tree">
|
||||||
<li>
|
<li>
|
||||||
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
|
<div class="node" @click="toggle('root')" @contextmenu="rootMenu">
|
||||||
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span>📁 数据库
|
<span class="arrow">{{ expanded.has('root') ? '▾' : '▸' }}</span
|
||||||
|
>📁 数据库
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="expanded.has('root')">
|
<ul v-if="expanded.has('root')">
|
||||||
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
|
<li v-if="databases.length === 0" class="empty">(还没有数据库:右键"数据库"新建,或在上方恢复实验初始数据)</li>
|
||||||
<li v-for="db in databases" :key="db.name">
|
<li v-for="db in databases" :key="db.name">
|
||||||
<div class="node" @click="toggle(`db:${db.name}`)" @contextmenu="databaseMenu($event, 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>
|
</div>
|
||||||
<ul v-if="expanded.has(`db:${db.name}`)">
|
<ul v-if="expanded.has(`db:${db.name}`)">
|
||||||
<li>
|
<li>
|
||||||
<div class="node" @click="toggle(`tables:${db.name}`)" @contextmenu="tablesMenu($event, db.name)">
|
<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>
|
</div>
|
||||||
<ul v-if="expanded.has(`tables:${db.name}`)">
|
<ul v-if="expanded.has(`tables:${db.name}`)">
|
||||||
<li v-if="db.tables.length === 0" class="empty">(没有表)</li>
|
<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}`)"
|
@click="toggle(`table:${db.name}/${table.name}`)"
|
||||||
@contextmenu="tableMenu($event, db.name, table)"
|
@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>
|
</div>
|
||||||
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
|
<ul v-if="expanded.has(`table:${db.name}/${table.name}`)">
|
||||||
<li>
|
<li>
|
||||||
<div class="node" @click="toggle(`columns:${db.name}/${table.name}`)">
|
<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>
|
</div>
|
||||||
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
|
<ul v-if="expanded.has(`columns:${db.name}/${table.name}`)">
|
||||||
<li v-for="column in table.columns" :key="column.name" class="node leaf">
|
<li v-for="column in table.columns" :key="column.name" class="node leaf">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ defineProps<{ result: ResultSet }>()
|
|||||||
.grid {
|
.grid {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 12px;
|
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;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe('编辑表数据生成的 T-SQL', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('有主键时按主键定位', () => {
|
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')
|
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', () => {
|
test('新增行跳过自增列;什么都没填时用 DEFAULT VALUES', () => {
|
||||||
expect(insertRow(campus, new Map([[2, '2024-01-20 08:00:00'], [0, '9'], [1, null]]))).toBe(
|
expect(
|
||||||
"INSERT INTO [CampusEnvironment] ([light], [addtime]) VALUES (NULL, N'2024-01-20 08:00:00')",
|
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')
|
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: 'EnvironmentDay', type: 'date', nullable: true, identity: false, primaryKey: false },
|
||||||
{ name: 'EnvironmentWork', type: 'varchar(200)', 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('给已有的表加列', () => {
|
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 一致', () => {
|
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: 'error', number: 208, message: "对象名 'X' 无效。", line: 2 })).toEqual([
|
||||||
expect(messages({ type: 'result', database: 'db', results: [{ columns: [], rows: [], total: 3 }], affected: 1, ms: 1 }).map((m) => m.text)).toEqual(['(3 行受影响)', '(1 行受影响)'])
|
{ 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(['命令已成功完成。'])
|
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 =
|
export type QueryReply =
|
||||||
| { type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number }
|
{ type: 'result'; database: string; results: ResultSet[]; affected: number; ms: number } | { type: 'error'; number: number; message: string; line: number }
|
||||||
| { type: 'error'; number: number; message: string; line: number }
|
|
||||||
|
|
||||||
export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db')
|
export const loadExplorer = () => api<{ databases: ExplorerDatabase[] }>('GET', '/api/db')
|
||||||
export const runQuery = (database: string, sql: string) => api<QueryReply>('POST', '/api/db/query', { database, sql })
|
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)) */
|
/** 设计器"数据类型"下拉里的常用类型(和 SSMS 一样也可以直接输入,如 varchar(20)) */
|
||||||
export const COMMON_TYPES = [
|
export const COMMON_TYPES = [
|
||||||
'int', 'bigint', 'smallint', 'tinyint', 'bit', 'float', 'real', 'decimal(18, 0)', 'money',
|
'int',
|
||||||
'char(10)', 'varchar(50)', 'varchar(MAX)', 'nchar(10)', 'nvarchar(50)', 'nvarchar(MAX)', 'text', 'ntext',
|
'bigint',
|
||||||
'date', 'datetime', 'smalldatetime', 'time(7)', 'uniqueidentifier', 'varbinary(MAX)', 'image',
|
'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) {
|
export function columnDefinition(column: DesignColumn) {
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ function nextName(prefix: string, taken: string[]) {
|
|||||||
function addSeries() {
|
function addSeries() {
|
||||||
const d = draft.value
|
const d = draft.value
|
||||||
d.series.push({
|
d.series.push({
|
||||||
name: nextName('Series', d.series.map((s) => s.name)),
|
name: nextName(
|
||||||
|
'Series',
|
||||||
|
d.series.map((s) => s.name),
|
||||||
|
),
|
||||||
chartType: 'Column',
|
chartType: 'Column',
|
||||||
chartArea: d.areas[0] ?? '',
|
chartArea: d.areas[0] ?? '',
|
||||||
legend: d.legends[0] ?? '',
|
legend: d.legends[0] ?? '',
|
||||||
@@ -93,7 +96,7 @@ function save() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="series" class="fields">
|
<section v-if="series" class="fields">
|
||||||
<label title="Name:代码里用 chart1.Series["名称"] 访问这个系列">名称</label>
|
<label title='Name:代码里用 chart1.Series["名称"] 访问这个系列'>名称</label>
|
||||||
<input v-model="series.name" />
|
<input v-model="series.name" />
|
||||||
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
|
<label title="ChartType:画成折线、柱形还是饼图">图表类型</label>
|
||||||
<select v-model="series.chartType">
|
<select v-model="series.chartType">
|
||||||
|
|||||||
@@ -172,7 +172,12 @@ const surface = ref<HTMLElement>()
|
|||||||
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
|
// ---------- 缩放:教材项目 8 的窗体有 1043×791,"适应"时按画布宽度缩小(不放大) ----------
|
||||||
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
|
type ZoomChoice = 'fit' | '1' | '0.75' | '0.5'
|
||||||
const ZOOM_KEY = 'iot-sim:designer-zoom'
|
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())
|
const zoomChoice = ref<ZoomChoice>(readZoom())
|
||||||
function readZoom(): ZoomChoice {
|
function readZoom(): ZoomChoice {
|
||||||
try {
|
try {
|
||||||
@@ -325,9 +330,25 @@ function onKeyDown(event: KeyboardEvent) {
|
|||||||
// ---------- 工具箱 ----------
|
// ---------- 工具箱 ----------
|
||||||
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
const TOOLBOX_GROUPS = ['常用控件', '容器', '数据', '组件', '物联网设备'] as const
|
||||||
const ICONS: Record<string, string> = {
|
const ICONS: Record<string, string> = {
|
||||||
按钮: '▭', 标签: 'A', 文本框: '▤', 下拉框: '☰', 列表框: '≣', 图片框: '🖼', 日期时间选择器: '📅', 滑动条: '⊶',
|
按钮: '▭',
|
||||||
复选框: '☑', 单选按钮: '◉', 分组框: '⬚', 面板: '▢', 数据表格: '▦', 图表: '📈',
|
标签: 'A',
|
||||||
定时器: '⏱', 媒体播放器: '🔊', 'LED 显示屏': '▬', 灯泡: '💡', 报警灯: '🚨',
|
文本框: '▤',
|
||||||
|
下拉框: '☰',
|
||||||
|
列表框: '≣',
|
||||||
|
图片框: '🖼',
|
||||||
|
日期时间选择器: '📅',
|
||||||
|
滑动条: '⊶',
|
||||||
|
复选框: '☑',
|
||||||
|
单选按钮: '◉',
|
||||||
|
分组框: '⬚',
|
||||||
|
面板: '▢',
|
||||||
|
数据表格: '▦',
|
||||||
|
图表: '📈',
|
||||||
|
定时器: '⏱',
|
||||||
|
媒体播放器: '🔊',
|
||||||
|
'LED 显示屏': '▬',
|
||||||
|
灯泡: '💡',
|
||||||
|
报警灯: '🚨',
|
||||||
}
|
}
|
||||||
const DRAG_TYPE = 'application/x-winforms-control'
|
const DRAG_TYPE = 'application/x-winforms-control'
|
||||||
|
|
||||||
@@ -437,6 +458,11 @@ function onDoubleClick(event: MouseEvent) {
|
|||||||
openEvent(hit, def.defaultEvent)
|
openEvent(hit, def.defaultEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteComponent(name: string) {
|
||||||
|
commit(removeControl(parsed.value.doc!, name))
|
||||||
|
selected.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function onComponentDoubleClick(name: string) {
|
function onComponentDoubleClick(name: string) {
|
||||||
const d = parsed.value.doc
|
const d = parsed.value.doc
|
||||||
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
|
if (d) openEvent(name, controlDef(typeOf(d, name) ?? '').defaultEvent)
|
||||||
@@ -475,7 +501,8 @@ const noop = () => {}
|
|||||||
@dragstart="onToolDragStart($event, def)"
|
@dragstart="onToolDragStart($event, def)"
|
||||||
@dblclick="onToolDoubleClick(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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -495,9 +522,7 @@ const noop = () => {}
|
|||||||
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
|
{{ value === 'fit' ? `${label}(${Math.round(zoom * 100)}%)` : label }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">
|
<button class="view-code" title="设计器根据窗体上的控件自动生成的代码(Designer.cs)" @click="emit('openDesignerFile')">查看生成的代码</button>
|
||||||
查看生成的代码
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
|
<div class="window" :class="{ selected: selected === '' }" :style="zoom !== 1 ? { zoom } : undefined">
|
||||||
<div class="titlebar" @pointerdown="selected = ''">
|
<div class="titlebar" @pointerdown="selected = ''">
|
||||||
@@ -543,7 +568,7 @@ const noop = () => {}
|
|||||||
:class="{ active: selected === c.name }"
|
:class="{ active: selected === c.name }"
|
||||||
@click="selected = c.name"
|
@click="selected = c.name"
|
||||||
@dblclick="onComponentDoubleClick(c.name)"
|
@dblclick="onComponentDoubleClick(c.name)"
|
||||||
@keydown.delete="commit(removeControl(parsed.doc!, c.name)); selected = ''"
|
@keydown.delete="deleteComponent(c.name)"
|
||||||
>
|
>
|
||||||
⏱ {{ c.name }}
|
⏱ {{ c.name }}
|
||||||
</button>
|
</button>
|
||||||
@@ -694,7 +719,7 @@ const noop = () => {}
|
|||||||
height: 30px;
|
height: 30px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
background: #fff;
|
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;
|
font-size: 12px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -707,7 +732,7 @@ const noop = () => {}
|
|||||||
.client {
|
.client {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
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;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,14 +759,46 @@ const noop = () => {}
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle.nw { left: -4px; top: -4px; cursor: nwse-resize; }
|
.handle.nw {
|
||||||
.handle.n { left: calc(50% - 4px); top: -4px; cursor: ns-resize; }
|
left: -4px;
|
||||||
.handle.ne { right: -4px; top: -4px; cursor: nesw-resize; }
|
top: -4px;
|
||||||
.handle.e { right: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
cursor: nwse-resize;
|
||||||
.handle.se { right: -4px; bottom: -4px; cursor: nwse-resize; }
|
}
|
||||||
.handle.s { left: calc(50% - 4px); bottom: -4px; cursor: ns-resize; }
|
.handle.n {
|
||||||
.handle.sw { left: -4px; bottom: -4px; cursor: nesw-resize; }
|
left: calc(50% - 4px);
|
||||||
.handle.w { left: -4px; top: calc(50% - 4px); cursor: ew-resize; }
|
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 {
|
.form-resize {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function set(prop: PropDef, value: Value) {
|
|||||||
/** 图片属性:项目资源里的名字,没有设置时为空 */
|
/** 图片属性:项目资源里的名字,没有设置时为空 */
|
||||||
function imageOf(prop: PropDef) {
|
function imageOf(prop: PropDef) {
|
||||||
const text = getPropText(props.doc, props.selected, prop.name)
|
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) {
|
function setImage(prop: PropDef, choice: string, select: HTMLSelectElement) {
|
||||||
@@ -77,10 +77,14 @@ function reset(prop: PropDef) {
|
|||||||
const nameDraft = ref('')
|
const nameDraft = ref('')
|
||||||
const nameError = ref<string | null>(null)
|
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
|
nameDraft.value = props.selected || props.doc.formName
|
||||||
nameError.value = null
|
nameError.value = null
|
||||||
}, { immediate: true })
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
function commitName() {
|
function commitName() {
|
||||||
const name = nameDraft.value.trim()
|
const name = nameDraft.value.trim()
|
||||||
@@ -151,13 +155,24 @@ function setFont(prop: PropDef, change: Partial<{ family: string; size: number;
|
|||||||
set(prop, { ...base, ...change })
|
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)
|
const v = parseValue(item)
|
||||||
return v.kind === 'string' ? v.value : item
|
return v.kind === 'string' ? v.value : item
|
||||||
}).join('\n'))
|
})
|
||||||
|
.join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
function setItemsFromText(text: string) {
|
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) ?? ''
|
const handlerOf = (event: string) => getEventHandler(props.doc, props.selected, event) ?? ''
|
||||||
@@ -193,35 +208,80 @@ function renameHandler(event: string, value: string) {
|
|||||||
<div class="editor">
|
<div class="editor">
|
||||||
<code v-if="prop.type !== 'image' && prop.type !== 'chart' && raw(prop) !== undefined" class="raw" :title="raw(prop)">{{ raw(prop) }}</code>
|
<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)"
|
<textarea
|
||||||
@change="set(prop, { kind: 'string', value: ($event.target as HTMLTextAreaElement).value })" />
|
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)"
|
<input
|
||||||
@change="($event.target as HTMLInputElement).value ? set(prop, { kind: 'char', value: ($event.target as HTMLInputElement).value }) : reset(prop)" />
|
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))"
|
<select
|
||||||
@change="set(prop, { kind: 'bool', value: ($event.target as HTMLSelectElement).value === 'true' })">
|
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="true">是</option>
|
||||||
<option value="false">否</option>
|
<option value="false">否</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<input v-else-if="prop.type === 'int'" type="number" :value="num(prop)"
|
<input
|
||||||
@change="set(prop, { kind: 'int', value: Number(($event.target as HTMLInputElement).value) })" />
|
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">
|
<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 })" />
|
X
|
||||||
Y <input type="number" :value="point(prop).y" @change="set(prop, { kind: 'point', x: point(prop).x, y: Number(($event.target as HTMLInputElement).value) })" />
|
<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>
|
||||||
|
|
||||||
<span v-else-if="prop.type === 'size'" class="pair">
|
<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>
|
</span>
|
||||||
|
|
||||||
<select v-else-if="prop.type === 'enum'" :value="enumOf(prop)"
|
<select
|
||||||
@change="set(prop, { kind: 'enum', type: prop.enumType!, member: ($event.target as HTMLSelectElement).value })">
|
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>
|
<option v-for="option in prop.options" :key="option" :value="option">{{ prop.optionLabels?.[option] ?? option }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -232,39 +292,70 @@ function renameHandler(event: string, value: string) {
|
|||||||
<option v-for="(_, name) in NAMED_COLORS" :key="name" :value="name">{{ name }}</option>
|
<option v-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>
|
<option v-if="color(prop) && !color(prop)!.name" value="custom" disabled>{{ color(prop)!.css }}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="color" title="自定义颜色" :value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
|
<input
|
||||||
@change="setColor(prop, ($event.target as HTMLInputElement).value)" />
|
type="color"
|
||||||
|
title="自定义颜色"
|
||||||
|
:value="color(prop)?.css.startsWith('#') ? color(prop)!.css : '#000000'"
|
||||||
|
@change="setColor(prop, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span v-else-if="prop.type === 'font'" class="font">
|
<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 value="">(默认:宋体 9)</option>
|
||||||
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
|
<option v-for="family in FONT_FAMILIES" :key="family" :value="family">{{ family }}</option>
|
||||||
</select>
|
</select>
|
||||||
<template v-if="font(prop)">
|
<template v-if="font(prop)">
|
||||||
<input type="number" min="5" max="72" step="0.5" class="short" :value="font(prop)!.size"
|
<input
|
||||||
@change="setFont(prop, { size: Number(($event.target as HTMLInputElement).value) })" />
|
type="number"
|
||||||
<label class="check"><input type="checkbox" :checked="font(prop)!.bold" @change="setFont(prop, { bold: ($event.target as HTMLInputElement).checked })" />粗</label>
|
min="5"
|
||||||
<label class="check"><input type="checkbox" :checked="font(prop)!.italic" @change="setFont(prop, { italic: ($event.target as HTMLInputElement).checked })" />斜</label>
|
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>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span v-else-if="prop.type === 'chart'" class="chart-summary">
|
<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>
|
<button @click="chartOpen = true">编辑…</button>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<textarea v-else-if="prop.type === 'items'" rows="4" :value="itemsText" placeholder="每行一项"
|
<textarea
|
||||||
@change="setItemsFromText(($event.target as HTMLTextAreaElement).value)" />
|
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)"
|
<select
|
||||||
@change="setImage(prop, ($event.target as HTMLSelectElement).value, $event.target as HTMLSelectElement)">
|
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 value="">(无)</option>
|
||||||
<option v-for="name in resources?.names ?? []" :key="name" :value="name">Resources.{{ name }}</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 v-if="imageOf(prop) && !resources?.names.includes(imageOf(prop))" :value="imageOf(prop)">{{ imageOf(prop) }}(不在项目资源里)</option>
|
||||||
<option value="+">添加资源…</option>
|
<option value="+">添加资源…</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,7 +449,7 @@ function renameHandler(event: string, value: string) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor input:not([type="checkbox"]):not([type="color"]),
|
.editor input:not([type='checkbox']):not([type='color']),
|
||||||
.editor select,
|
.editor select,
|
||||||
.editor textarea {
|
.editor textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -394,7 +485,7 @@ function renameHandler(event: string, value: string) {
|
|||||||
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
|
background-image: linear-gradient(45deg, #ddd 25%, transparent 25%, transparent 75%, #ddd 75%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color input[type="color"] {
|
.color input[type='color'] {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
@@ -36,14 +36,19 @@ function listen(file: string) {
|
|||||||
|
|
||||||
function save() {
|
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)
|
emit('update:show', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NModal :show="show" preset="card" title="项目资源" style="width: 560px; max-width: calc(100vw - 32px)" @update:show="emit('update:show', $event)">
|
<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 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))">
|
<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 {
|
.name {
|
||||||
font-family: Consolas, "Courier New", monospace;
|
font-family: Consolas, 'Courier New', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+232
-60
@@ -75,9 +75,15 @@ const COMMON_PROPS: PropDef[] = [
|
|||||||
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
|
{ name: 'Visible', label: '可见', description: '是否可见', type: 'bool', default: bool(true), category: '行为' },
|
||||||
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
|
{ name: 'TabIndex', label: 'Tab 顺序', description: 'Tab 键顺序', type: 'int', category: '行为' },
|
||||||
{
|
{
|
||||||
name: 'Dock', label: '停靠', description: '贴在所在容器的哪一边(并随容器拉伸);填充是占满剩下的地方', type: 'enum', enumType: `${FORMS}.DockStyle`,
|
name: 'Dock',
|
||||||
options: ['None', 'Top', 'Bottom', 'Left', 'Right', 'Fill'], optionLabels: { None: '不停靠', Top: '顶部', Bottom: '底部', Left: '左边', Right: '右边', Fill: '填充' },
|
label: '停靠',
|
||||||
default: enumValue(`${FORMS}.DockStyle`, 'None'), category: '布局',
|
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[] = [
|
const BACKGROUND_PROPS: PropDef[] = [
|
||||||
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
{ name: 'BackgroundImage', label: '背景图片', description: '背景图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
||||||
{
|
{
|
||||||
name: 'BackgroundImageLayout', label: '背景排列', description: '背景图片怎样摆放', type: 'enum', enumType: `${FORMS}.ImageLayout`,
|
name: 'BackgroundImageLayout',
|
||||||
options: ['None', 'Tile', 'Center', 'Stretch', 'Zoom'], optionLabels: { None: '左上角', Tile: '平铺', Center: '居中', Stretch: '拉伸填满', Zoom: '按比例缩放' },
|
label: '背景排列',
|
||||||
default: enumValue(`${FORMS}.ImageLayout`, 'Tile'), category: '外观',
|
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
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const CONTROLS: ControlDef[] = [
|
export const CONTROLS: ControlDef[] = [
|
||||||
{
|
{
|
||||||
type: `${FORMS}.Button`,
|
type: `${FORMS}.Button`,
|
||||||
@@ -122,7 +133,11 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'Click',
|
defaultEvent: 'Click',
|
||||||
props: [...COMMON_PROPS],
|
props: [...COMMON_PROPS],
|
||||||
events: COMMON_EVENTS,
|
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`,
|
type: `${FORMS}.Label`,
|
||||||
@@ -130,12 +145,27 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
group: '常用控件',
|
group: '常用控件',
|
||||||
namePrefix: 'label',
|
namePrefix: 'label',
|
||||||
defaultEvent: 'Click',
|
defaultEvent: 'Click',
|
||||||
props: [...COMMON_PROPS, AUTO_SIZE, {
|
props: [
|
||||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
...COMMON_PROPS,
|
||||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
|
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,
|
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 }),
|
autoSize: (text) => ({ width: textWidth(text) + 5, height: 12 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -151,8 +181,15 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
|
{ name: 'PasswordChar', label: '密码字符', description: '输入时用这个字符代替显示的内容,如 *', type: 'char', category: '行为' },
|
||||||
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
|
{ name: 'UseSystemPasswordChar', label: '系统密码字符', description: '用系统密码字符显示', type: 'bool', default: bool(false), category: '行为' },
|
||||||
{
|
{
|
||||||
name: 'ScrollBars', label: '滚动条', description: '滚动条', type: 'enum', enumType: `${FORMS}.ScrollBars`,
|
name: 'ScrollBars',
|
||||||
options: ['None', 'Horizontal', 'Vertical', 'Both'], optionLabels: { None: '无', Horizontal: '水平', Vertical: '垂直', Both: '水平和垂直' }, default: enumValue(`${FORMS}.ScrollBars`, 'None'), category: '外观',
|
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,
|
events: COMMON_EVENTS,
|
||||||
@@ -168,12 +205,22 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
...COMMON_PROPS,
|
...COMMON_PROPS,
|
||||||
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
|
{ name: 'Items', label: '列表项', description: '下拉列表里的选项,每行一项', type: 'items', category: '数据' },
|
||||||
{
|
{
|
||||||
name: 'DropDownStyle', label: '下拉样式', description: '下拉样式', type: 'enum', enumType: `${FORMS}.ComboBoxStyle`,
|
name: 'DropDownStyle',
|
||||||
options: ['DropDown', 'DropDownList', 'Simple'], optionLabels: { DropDown: '可输入的下拉框', DropDownList: '只能选择的下拉列表', Simple: '始终展开' }, default: enumValue(`${FORMS}.ComboBoxStyle`, 'DropDown'), category: '外观',
|
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 }],
|
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`,
|
type: `${FORMS}.ListBox`,
|
||||||
@@ -185,15 +232,23 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||||
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
|
{ name: 'Items', label: '列表项', description: '列表里的项,每行一项', type: 'items', category: '数据' },
|
||||||
{
|
{
|
||||||
name: 'BorderStyle', label: '边框', description: '边框', type: 'enum', enumType: `${FORMS}.BorderStyle`,
|
name: 'BorderStyle',
|
||||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'), category: '外观',
|
label: '边框',
|
||||||
|
description: '边框',
|
||||||
|
type: 'enum',
|
||||||
|
enumType: `${FORMS}.BorderStyle`,
|
||||||
|
options: ['None', 'FixedSingle', 'Fixed3D'],
|
||||||
|
optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' },
|
||||||
|
default: enumValue(`${FORMS}.BorderStyle`, 'Fixed3D'),
|
||||||
|
category: '外观',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
events: [
|
events: [...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'), { name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT }],
|
||||||
...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
initial: () => [
|
||||||
{ name: 'SelectedIndexChanged', label: '选中项改变', ...EVENT },
|
['FormattingEnabled', bool(true)],
|
||||||
|
['ItemHeight', int(12)],
|
||||||
|
['Size', size(120, 88)],
|
||||||
],
|
],
|
||||||
initial: () => [['FormattingEnabled', bool(true)], ['ItemHeight', int(12)], ['Size', size(120, 88)]],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: `${FORMS}.PictureBox`,
|
type: `${FORMS}.PictureBox`,
|
||||||
@@ -206,18 +261,33 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
{ name: 'Image', label: '图片', description: '显示的图片:从项目资源里选(Properties.Resources)', type: 'image', category: '外观' },
|
||||||
...BACKGROUND_PROPS,
|
...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'],
|
options: ['Normal', 'StretchImage', 'AutoSize', 'CenterImage', 'Zoom'],
|
||||||
optionLabels: { 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`,
|
name: 'BorderStyle',
|
||||||
options: ['None', 'FixedSingle', 'Fixed3D'], optionLabels: { None: '无', FixedSingle: '单线', Fixed3D: '立体' }, default: enumValue(`${FORMS}.BorderStyle`, 'None'), category: '外观',
|
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'),
|
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`,
|
type: `${FORMS}.DataGridView`,
|
||||||
@@ -228,19 +298,43 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
props: [
|
props: [
|
||||||
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'),
|
...COMMON_PROPS.filter((p) => p.name !== 'Text' && p.name !== 'ForeColor'),
|
||||||
{ name: 'ReadOnly', label: '只读', description: '只读:不能在表格里修改数据', type: 'bool', default: bool(false), category: '行为' },
|
{ 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: 'AllowUserToDeleteRows', label: '允许删除行', description: '允许删除行', type: 'bool', default: bool(true), category: '行为' },
|
||||||
{ name: 'RowHeadersVisible', 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'],
|
options: ['CellSelect', 'FullRowSelect', 'FullColumnSelect', 'RowHeaderSelect', 'ColumnHeaderSelect'],
|
||||||
optionLabels: { CellSelect: '单元格', FullRowSelect: '整行', FullColumnSelect: '整列', RowHeaderSelect: '单元格(点行标题选整行)', ColumnHeaderSelect: '单元格(点列标题选整列)' },
|
optionLabels: {
|
||||||
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'), category: '行为',
|
CellSelect: '单元格',
|
||||||
|
FullRowSelect: '整行',
|
||||||
|
FullColumnSelect: '整列',
|
||||||
|
RowHeaderSelect: '单元格(点行标题选整行)',
|
||||||
|
ColumnHeaderSelect: '单元格(点列标题选整列)',
|
||||||
|
},
|
||||||
|
default: enumValue(`${FORMS}.DataGridViewSelectionMode`, 'RowHeaderSelect'),
|
||||||
|
category: '行为',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'AutoSizeColumnsMode', label: '列宽', description: '列宽怎样自动调整', type: 'enum', enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
|
name: 'AutoSizeColumnsMode',
|
||||||
options: ['None', 'Fill'], optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
|
label: '列宽',
|
||||||
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'), category: '布局',
|
description: '列宽怎样自动调整',
|
||||||
|
type: 'enum',
|
||||||
|
enumType: `${FORMS}.DataGridViewAutoSizeColumnsMode`,
|
||||||
|
options: ['None', 'Fill'],
|
||||||
|
optionLabels: { None: '固定宽度', Fill: '填满表格宽度' },
|
||||||
|
default: enumValue(`${FORMS}.DataGridViewAutoSizeColumnsMode`, 'None'),
|
||||||
|
category: '布局',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
events: [
|
events: [
|
||||||
@@ -251,7 +345,10 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
{ name: 'CellValueChanged', label: '单元格的值改变', delegate: `${FORMS}.DataGridViewCellEventHandler`, args: 'DataGridViewCellEventArgs' },
|
||||||
{ name: 'SelectionChanged', label: '选中改变', ...EVENT },
|
{ 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',
|
type: 'System.Windows.Forms.DataVisualization.Charting.Chart',
|
||||||
@@ -265,7 +362,10 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
|
{ name: 'Series', label: '系列和图例', description: '图表里画哪几条线(系列)、数据来自哪一列、图例怎样显示', type: 'chart', category: '数据' },
|
||||||
],
|
],
|
||||||
events: COMMON_EVENTS.filter((e) => e.name !== 'TextChanged'),
|
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`,
|
type: `${FORMS}.DateTimePicker`,
|
||||||
@@ -276,9 +376,15 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
props: [
|
props: [
|
||||||
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
...COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||||
{
|
{
|
||||||
name: 'Format', label: '格式', description: '日期和时间的显示格式', type: 'enum', enumType: `${FORMS}.DateTimePickerFormat`,
|
name: 'Format',
|
||||||
options: ['Long', 'Short', 'Time', 'Custom'], optionLabels: { Long: '长日期(2024年1月16日)', Short: '短日期(2024/1/16)', Time: '时间(9:05:03)', Custom: '自定义' },
|
label: '格式',
|
||||||
default: enumValue(`${FORMS}.DateTimePickerFormat`, 'Long'), category: '外观',
|
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: 'CustomFormat', label: '自定义格式', description: '格式为"自定义"时使用,如 yyyy-MM-dd HH:mm', type: 'string', category: '外观' },
|
||||||
{ name: 'ShowUpDown', label: '上下箭头', description: '用上下箭头调整,代替下拉日历', type: 'bool', default: bool(false), 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: 'LargeChange', label: '大步长', description: '点击轨道时每次改变多少', type: 'int', default: int(5), category: '行为' },
|
||||||
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
|
{ name: 'SmallChange', label: '小步长', description: '按方向键时每次改变多少', type: 'int', default: int(1), category: '行为' },
|
||||||
{
|
{
|
||||||
name: 'TickStyle', label: '刻度位置', description: '刻度画在哪一边', type: 'enum', enumType: `${FORMS}.TickStyle`,
|
name: 'TickStyle',
|
||||||
options: ['None', 'TopLeft', 'BottomRight', 'Both'], optionLabels: { None: '不画刻度', TopLeft: '上方(左边)', BottomRight: '下方(右边)', Both: '两边' },
|
label: '刻度位置',
|
||||||
default: enumValue(`${FORMS}.TickStyle`, 'BottomRight'), category: '外观',
|
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`,
|
name: 'Orientation',
|
||||||
options: ['Horizontal', 'Vertical'], optionLabels: { Horizontal: '横向', Vertical: '竖向' },
|
label: '方向',
|
||||||
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'), category: '外观',
|
description: '横向或竖向',
|
||||||
|
type: 'enum',
|
||||||
|
enumType: `${FORMS}.Orientation`,
|
||||||
|
options: ['Horizontal', 'Vertical'],
|
||||||
|
optionLabels: { Horizontal: '横向', Vertical: '竖向' },
|
||||||
|
default: enumValue(`${FORMS}.Orientation`, 'Horizontal'),
|
||||||
|
category: '外观',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
events: [
|
events: [
|
||||||
@@ -326,7 +444,12 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'CheckedChanged',
|
defaultEvent: 'CheckedChanged',
|
||||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '勾选', description: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
|
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '勾选', description: '是否勾选', type: 'bool', default: bool(false), category: '外观' }],
|
||||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '勾选状态改变', ...EVENT }],
|
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 }),
|
autoSize: (text) => ({ width: textWidth(text) + 24, height: 16 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -337,7 +460,12 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'CheckedChanged',
|
defaultEvent: 'CheckedChanged',
|
||||||
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '选中', description: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
|
props: [...COMMON_PROPS, AUTO_SIZE, { name: 'Checked', label: '选中', description: '是否选中', type: 'bool', default: bool(false), category: '外观' }],
|
||||||
events: [...COMMON_EVENTS, { name: 'CheckedChanged', label: '选中状态改变', ...EVENT }],
|
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 }),
|
autoSize: (text) => ({ width: textWidth(text) + 17, height: 16 }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -349,7 +477,11 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'Enter',
|
defaultEvent: 'Enter',
|
||||||
props: [...COMMON_PROPS],
|
props: [...COMMON_PROPS],
|
||||||
events: COMMON_EVENTS,
|
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`,
|
type: `${FORMS}.Panel`,
|
||||||
@@ -371,7 +503,14 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'Tick',
|
defaultEvent: 'Tick',
|
||||||
props: [
|
props: [
|
||||||
{ name: 'Interval', label: '间隔', description: '每隔多少毫秒触发一次"计时到"事件', type: 'int', default: int(100), category: '行为' },
|
{ 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 }],
|
events: [{ name: 'Tick', label: '计时到', ...EVENT }],
|
||||||
initial: () => [],
|
initial: () => [],
|
||||||
@@ -384,7 +523,10 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'Enter',
|
defaultEvent: 'Enter',
|
||||||
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
|
props: COMMON_PROPS.filter((p) => ['Location', 'Size', 'Enabled', 'Visible', 'TabIndex'].includes(p.name)),
|
||||||
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
|
events: [{ name: 'Enter', label: '获得焦点', ...EVENT }],
|
||||||
initial: () => [['Enabled', bool(true)], ['Size', size(245, 240)]],
|
initial: () => [
|
||||||
|
['Enabled', bool(true)],
|
||||||
|
['Size', size(245, 240)],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'WinFormsDeviceControlLibrary.LED',
|
type: 'WinFormsDeviceControlLibrary.LED',
|
||||||
@@ -404,7 +546,10 @@ export const CONTROLS: ControlDef[] = [
|
|||||||
defaultEvent: 'Load',
|
defaultEvent: 'Load',
|
||||||
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
props: COMMON_PROPS.filter((p) => p.name !== 'Text'),
|
||||||
events: [{ name: 'Load', label: '加载', ...EVENT }, ...COMMON_EVENTS.filter((e) => e.name !== 'TextChanged')],
|
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',
|
type: 'WinFormsDeviceControlLibrary.Alarm',
|
||||||
@@ -433,16 +578,40 @@ export const FORM_DEF: ControlDef = {
|
|||||||
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
|
{ name: 'Font', label: '字体', description: '字体', type: 'font', category: '外观' },
|
||||||
{ name: 'ClientSize', label: '窗口大小', description: '窗口内部的宽度和高度(不含标题栏和边框)', type: 'size', 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'],
|
options: ['None', 'FixedSingle', 'Fixed3D', 'FixedDialog', 'Sizable', 'FixedToolWindow', 'SizableToolWindow'],
|
||||||
optionLabels: { None: '无边框', FixedSingle: '固定单线', Fixed3D: '固定立体', FixedDialog: '对话框', Sizable: '可调整大小', FixedToolWindow: '固定工具窗口', SizableToolWindow: '可调整工具窗口' },
|
optionLabels: {
|
||||||
default: enumValue(`${FORMS}.FormBorderStyle`, 'Sizable'), category: '外观',
|
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'],
|
options: ['Manual', 'CenterScreen', 'WindowsDefaultLocation', 'WindowsDefaultBounds', 'CenterParent'],
|
||||||
optionLabels: { Manual: '手动指定', CenterScreen: '屏幕中央', WindowsDefaultLocation: '系统默认位置', WindowsDefaultBounds: '系统默认位置和大小', CenterParent: '父窗体中央' },
|
optionLabels: {
|
||||||
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'), category: '布局',
|
Manual: '手动指定',
|
||||||
|
CenterScreen: '屏幕中央',
|
||||||
|
WindowsDefaultLocation: '系统默认位置',
|
||||||
|
WindowsDefaultBounds: '系统默认位置和大小',
|
||||||
|
CenterParent: '父窗体中央',
|
||||||
|
},
|
||||||
|
default: enumValue(`${FORMS}.FormStartPosition`, 'WindowsDefaultLocation'),
|
||||||
|
category: '布局',
|
||||||
},
|
},
|
||||||
...BACKGROUND_PROPS,
|
...BACKGROUND_PROPS,
|
||||||
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
|
{ name: 'MaximizeBox', label: '最大化按钮', description: '显示最大化按钮', type: 'bool', default: bool(true), category: '外观' },
|
||||||
@@ -468,7 +637,10 @@ export function controlDef(type: string): ControlDef {
|
|||||||
type,
|
type,
|
||||||
label: type.split('.').at(-1)!,
|
label: type.split('.').at(-1)!,
|
||||||
group: '常用控件',
|
group: '常用控件',
|
||||||
namePrefix: type.split('.').at(-1)!.replace(/^./, (c) => c.toLowerCase()),
|
namePrefix: type
|
||||||
|
.split('.')
|
||||||
|
.at(-1)!
|
||||||
|
.replace(/^./, (c) => c.toLowerCase()),
|
||||||
defaultEvent: 'Click',
|
defaultEvent: 'Click',
|
||||||
props: COMMON_PROPS,
|
props: COMMON_PROPS,
|
||||||
events: COMMON_EVENTS,
|
events: COMMON_EVENTS,
|
||||||
|
|||||||
@@ -42,8 +42,26 @@ describe('图表的系列和图例', () => {
|
|||||||
areas: ['ChartArea1'],
|
areas: ['ChartArea1'],
|
||||||
legends: ['MaxLegend', 'AVGLegend'],
|
legends: ['MaxLegend', 'AVGLegend'],
|
||||||
series: [
|
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 })
|
const { doc, name } = addControl(parseDesigner(EMPTY_FORM), def, '', { x: 10, y: 20 })
|
||||||
expect(name).toBe('chart1')
|
expect(name).toBe('chart1')
|
||||||
const out = generateDesigner(doc)
|
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.Legend legend1 = new System.Windows.Forms.DataVisualization.Charting.Legend();
|
||||||
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
|
System.Windows.Forms.DataVisualization.Charting.Series series1 = new System.Windows.Forms.DataVisualization.Charting.Series();
|
||||||
this.chart1 = new System.Windows.Forms.DataVisualization.Charting.Chart();`)
|
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 = {
|
export const DEFAULT_CHART: ChartModel = {
|
||||||
areas: ['ChartArea1'],
|
areas: ['ChartArea1'],
|
||||||
legends: ['Legend1'],
|
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 {
|
interface ChartObject {
|
||||||
|
|||||||
@@ -9,13 +9,7 @@ test('插入事件处理方法:放在类的最后,与上一个方法空一
|
|||||||
const code = await Bun.file(FORM1).text()
|
const code = await Bun.file(FORM1).text()
|
||||||
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
|
const { code: next, line } = ensureHandler(code, 'Form1', 'Form1_Load', 'EventArgs')
|
||||||
const lines = next.split('\n')
|
const lines = next.split('\n')
|
||||||
expect(lines.slice(line - 4, line + 1)).toEqual([
|
expect(lines.slice(line - 4, line + 1)).toEqual(['', ' private void Form1_Load(object sender, EventArgs e)', ' {', '', ' }'])
|
||||||
'',
|
|
||||||
' private void Form1_Load(object sender, EventArgs e)',
|
|
||||||
' {',
|
|
||||||
'',
|
|
||||||
' }',
|
|
||||||
])
|
|
||||||
// 方法的 "}" 之后就是类的 "}"
|
// 方法的 "}" 之后就是类的 "}"
|
||||||
expect(lines[line + 1]).toBe(' }')
|
expect(lines[line + 1]).toBe(' }')
|
||||||
})
|
})
|
||||||
@@ -30,7 +24,9 @@ test('方法已经存在:不重复插入,只返回它的位置', async () =>
|
|||||||
test('类里还没有成员时不留多余空行', () => {
|
test('类里还没有成员时不留多余空行', () => {
|
||||||
const code = 'namespace A\n{\n public partial class F : Form\n {\n }\n}\n'
|
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')
|
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)
|
expect(line).toBe(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -20,13 +20,7 @@ export function ensureHandler(code: string, className: string, handler: string,
|
|||||||
// 类里已有成员时,与上一个成员之间空一行
|
// 类里已有成员时,与上一个成员之间空一行
|
||||||
const before = code.slice(0, lineStart)
|
const before = code.slice(0, lineStart)
|
||||||
const hasMembers = !/\{\s*$/.test(before)
|
const hasMembers = !/\{\s*$/.test(before)
|
||||||
const method = [
|
const method = [...(hasMembers ? [''] : []), `${indent}private void ${handler}(object sender, ${args} e)`, `${indent}{`, '', `${indent}}`].join(eol)
|
||||||
...(hasMembers ? [''] : []),
|
|
||||||
`${indent}private void ${handler}(object sender, ${args} e)`,
|
|
||||||
`${indent}{`,
|
|
||||||
'',
|
|
||||||
`${indent}}`,
|
|
||||||
].join(eol)
|
|
||||||
const next = `${before}${method}${eol}${code.slice(lineStart)}`
|
const next = `${before}${method}${eol}${code.slice(lineStart)}`
|
||||||
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
|
return { code: next, line: lineOf(next, before.length) + (hasMembers ? 3 : 2) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ async function designerFiles(dir: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
|
describe('读入 Designer.cs 再原样写出,内容逐字节不变', async () => {
|
||||||
const files = [
|
const files = [...(await designerFiles(resolve(ROOT, 'samples'))), ...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : [])]
|
||||||
...(await designerFiles(resolve(ROOT, 'samples'))),
|
|
||||||
...(existsSync(REFERENCE_DIR) ? await designerFiles(REFERENCE_DIR) : []),
|
|
||||||
]
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
test(file.replace(`${ROOT}/`, ''), async () => {
|
test(file.replace(`${ROOT}/`, ''), async () => {
|
||||||
const text = await Bun.file(file).text()
|
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 block of doc.blocks) {
|
||||||
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
|
for (const stmt of block.stmts) if (stmt.kind === 'add') parents.set(stmt.child, stmt.target)
|
||||||
}
|
}
|
||||||
return doc.prologue.flatMap((stmt) =>
|
return doc.prologue.flatMap((stmt) => (stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : []))
|
||||||
stmt.kind === 'create' ? [{ name: stmt.name, type: stmt.type, parent: parents.get(stmt.name) ?? null }] : [],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
|
/** 某个容器里的子控件,按 Controls.Add 的顺序(第一个在最上层) */
|
||||||
@@ -133,7 +131,9 @@ export function setEvent(doc: DesignerDocument, target: string, event: string, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KEYWORDS = new Set(
|
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,否则返回原因 */
|
/** 检查控件名是否可用;可用返回 null,否则返回原因 */
|
||||||
@@ -206,11 +206,7 @@ export function removeControl(doc: DesignerDocument, name: string): DesignerDocu
|
|||||||
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
|
// 图表的图表区、图例、系列是 InitializeComponent 开头的局部变量,一起删掉
|
||||||
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
|
for (const n of doomed) if (typeOf(next, n) === CHART_TYPE) next.prologue = removeChartDeclarations(next, n).prologue
|
||||||
const keep = (s: Stmt) =>
|
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.prologue = next.prologue.filter(keep)
|
||||||
next.epilogue = next.epilogue.filter(keep)
|
next.epilogue = next.epilogue.filter(keep)
|
||||||
next.blocks = next.blocks.filter((b) => !doomed.has(b.name))
|
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
|
return s.name === from ? { ...s, name: to, text: undefined } : s
|
||||||
case 'prop':
|
case 'prop':
|
||||||
if (s.target !== from) return s
|
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 'event':
|
||||||
case 'items':
|
case 'items':
|
||||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
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':
|
case 'layout':
|
||||||
return s.target === from ? { ...s, target: to, text: undefined } : s
|
return s.target === from ? { ...s, target: to, text: undefined } : s
|
||||||
case 'raw':
|
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)
|
next.prologue = next.prologue.map(fix)
|
||||||
@@ -274,7 +274,12 @@ export function moveControl(doc: DesignerDocument, name: string, parent: string,
|
|||||||
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
|
* 子控件里有 AutoSize 的,末尾再加 PerformLayout()。已有的语句不调整顺序,只增删。
|
||||||
*/
|
*/
|
||||||
export function syncLayout(doc: DesignerDocument) {
|
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) {
|
for (const container of containers) {
|
||||||
const children = childrenOf(doc, container)
|
const children = childrenOf(doc, container)
|
||||||
const hasChildren = children.length > 0 || container === ''
|
const hasChildren = children.length > 0 || container === ''
|
||||||
@@ -331,9 +336,7 @@ function ensureLayout(doc: DesignerDocument, target: string, op: 'SuspendLayout'
|
|||||||
} else {
|
} else {
|
||||||
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
|
// 容器:ResumeLayout(false) + PerformLayout() 成对,排在 EndInit 和窗体的 ResumeLayout 之前
|
||||||
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
|
const resume = list.findIndex((s) => s.kind === 'layout' && s.target === target && s.op === 'ResumeLayout')
|
||||||
const at = op === 'PerformLayout' && resume >= 0
|
const at = op === 'PerformLayout' && resume >= 0 ? resume + 1 : list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
|
||||||
? resume + 1
|
|
||||||
: list.findIndex((s) => s.kind === 'layout' && (s.op === 'EndInit' || s.target === ''))
|
|
||||||
list.splice(at < 0 ? list.length : at, 0, stmt)
|
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) {
|
function paddingOf(doc: DesignerDocument, name: string) {
|
||||||
const m = /Padding\((\d+)(?:,\s*(\d+),\s*(\d+),\s*(\d+))?\)/.exec(getPropText(doc, name, 'Padding') ?? '')
|
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 }
|
if (!m) return { left: 0, top: 0, right: 0, bottom: 0 }
|
||||||
return m[2] === undefined
|
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] }
|
||||||
? { 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)
|
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 })
|
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)
|
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) => {
|
const layout = (container: string) => {
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ function propsOf(doc: DesignerDocument, target: string): Record<string, unknown>
|
|||||||
|
|
||||||
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
|
/** 教材控件在 Designer.cs 里不写、但运行时构造函数会设置的外观 */
|
||||||
const DEVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
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.Lamp': { BackColor: '#ffffff' },
|
||||||
'WinFormsDeviceControlLibrary.Alarm': { 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) }
|
const props: Record<string, unknown> = { ...DEVICE_DEFAULTS[type], ...propsOf(doc, name) }
|
||||||
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
|
// 设计时即使 Visible = false 也显示出来(和 VS 一样),方便选中和编辑
|
||||||
props.Visible = true
|
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)
|
const v = parseValue(item)
|
||||||
return v.kind === 'string' ? v.value : 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))
|
if (type === CHART_TYPE) props.Chart = previewChart(readChart(doc, name))
|
||||||
// 和 VS 一样显示今天的日期
|
// 和 VS 一样显示今天的日期
|
||||||
if (type === 'System.Windows.Forms.DateTimePicker') props.Text = previewDate(String(props.Format ?? 'Long'))
|
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, '')
|
const formProps = propsOf(doc, '')
|
||||||
return {
|
return {
|
||||||
@@ -91,7 +104,9 @@ export function previewTree(doc: DesignerDocument): ControlNode {
|
|||||||
type: 'Form',
|
type: 'Form',
|
||||||
name: doc.formName,
|
name: doc.formName,
|
||||||
props: { ClientSize: { width: 300, height: 300 }, ...formProps, Visible: true },
|
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 { generateDesigner, parseDesigner } from './document'
|
||||||
import { addControl } from './edit'
|
import { addControl } from './edit'
|
||||||
import { controlDef } from './catalog'
|
import { controlDef } from './catalog'
|
||||||
import { assignmentTemplate, classPathError, formNameTaken, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from './templates'
|
import {
|
||||||
|
assignmentTemplate,
|
||||||
|
classPathError,
|
||||||
|
formNameTaken,
|
||||||
|
newClassFile,
|
||||||
|
newFormFiles,
|
||||||
|
nextFormName,
|
||||||
|
projectNamespace,
|
||||||
|
renameForm,
|
||||||
|
startupForm,
|
||||||
|
} from './templates'
|
||||||
|
|
||||||
const files = [
|
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' },
|
{ 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('新建类:可以带文件夹,文件夹也是命名空间的一段;名字不能重复', () => {
|
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({
|
expect(newClassFile('智慧校园', 'SQLHelpers/SQLHelper')).toEqual({
|
||||||
path: 'SQLHelpers/SQLHelper.cs',
|
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(newClassFile('智慧校园', 'RecordClass.cs').path).toBe('RecordClass.cs')
|
||||||
expect(classPathError(files, 'SQLHelpers/SQLBLL')).toBeNull()
|
expect(classPathError(files, 'SQLHelpers/SQLBLL')).toBeNull()
|
||||||
@@ -84,7 +102,10 @@ test('新建类:可以带文件夹,文件夹也是命名空间的一段;
|
|||||||
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
|
test('窗体改名:类名、构造函数、Program.cs 和两个文件名一起改,方法名和字符串不动', async () => {
|
||||||
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
|
const blank = assignmentTemplate(await sample('p8-t1-主界面'), 'blank')
|
||||||
const code = blank.find((f) => f.path === 'Form1.cs')!
|
const code = blank.find((f) => f.path === 'Form1.cs')!
|
||||||
code.content = code.content.replace('InitializeComponent();', 'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();')
|
code.content = code.content.replace(
|
||||||
|
'InitializeComponent();',
|
||||||
|
'InitializeComponent();\n MessageBox.Show("Form1"); // Form1\n }\n\n private void Form1_Load(object sender, EventArgs e)\n {\n Form1 other = new Form1();',
|
||||||
|
)
|
||||||
const files = renameForm(blank, 'Form1.Designer.cs', 'Main')
|
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.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())')
|
expect(files.find((f) => f.path === 'Program.cs')!.content).toContain('Application.Run(new Main())')
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export function nextFormName(files: SourceFile[]): string {
|
|||||||
|
|
||||||
/** 窗体名是否已被使用(同名文件或同名类) */
|
/** 窗体名是否已被使用(同名文件或同名类) */
|
||||||
export function formNameTaken(files: SourceFile[], name: string): boolean {
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.r, value.g, value.b].map(byteArg).join(', ')})`
|
||||||
: `System.Drawing.Color.FromArgb(${[value.a, 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': {
|
case 'font': {
|
||||||
const styles = [
|
const styles = [value.bold && 'Bold', value.italic && 'Italic', value.underline && 'Underline', value.strikeout && 'Strikeout'].filter(
|
||||||
value.bold && 'Bold',
|
Boolean,
|
||||||
value.italic && 'Italic',
|
) as string[]
|
||||||
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'
|
const style = styles.length ? styles.map((s) => `System.Drawing.FontStyle.${s}`).join(' | ') : 'System.Drawing.FontStyle.Regular'
|
||||||
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
|
// 与中文 Windows 上的 VS 一致:带 GB2312 字符集 134
|
||||||
return `new System.Drawing.Font("${escapeString(value.family)}", ${value.size}F, ${style}, System.Drawing.GraphicsUnit.Point, ((byte)(${value.charset ?? 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,
|
startColumn: d.column,
|
||||||
endLineNumber: d.line,
|
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)
|
monaco.editor.setModelMarkers(model, 'csharp-compiler', markers)
|
||||||
}
|
}
|
||||||
@@ -134,11 +134,14 @@ onMounted(() => {
|
|||||||
applyMarkers()
|
applyMarkers()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.files, () => {
|
watch(
|
||||||
|
() => props.files,
|
||||||
|
() => {
|
||||||
syncModels()
|
syncModels()
|
||||||
showActive()
|
showActive()
|
||||||
applyMarkers()
|
applyMarkers()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
watch(() => props.activeFile, showActive)
|
watch(() => props.activeFile, showActive)
|
||||||
watch(
|
watch(
|
||||||
() => props.diagnostics,
|
() => props.diagnostics,
|
||||||
@@ -158,9 +161,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="code-editor">
|
<div class="code-editor">
|
||||||
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">
|
<div v-if="activeFile?.endsWith('.Designer.cs')" class="designer-note">此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。</div>
|
||||||
此文件由窗体设计器生成,一般不要手动修改;控件的属性和事件请在设计器里设置。
|
|
||||||
</div>
|
|
||||||
<div ref="container" class="monaco-host" />
|
<div ref="container" class="monaco-host" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -84,8 +84,7 @@ export function registerLanguageFeatures() {
|
|||||||
if (!result) return { suggestions: [] }
|
if (!result) return { suggestions: [] }
|
||||||
const range = toRange(result.range)
|
const range = toRange(result.range)
|
||||||
return {
|
return {
|
||||||
suggestions: result.items.map(
|
suggestions: result.items.map((item, index): Item => ({
|
||||||
(item, index): Item => ({
|
|
||||||
index,
|
index,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
insertText: item.insert,
|
insertText: item.insert,
|
||||||
@@ -94,8 +93,7 @@ export function registerLanguageFeatures() {
|
|||||||
kind: KINDS[item.kind] ?? Kind.Text,
|
kind: KINDS[item.kind] ?? Kind.Text,
|
||||||
detail: item.detail || undefined,
|
detail: item.detail || undefined,
|
||||||
range,
|
range,
|
||||||
}),
|
})),
|
||||||
),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// 选中某一项时再取它的签名和说明
|
// 选中某一项时再取它的签名和说明
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
|
|||||||
<div class="grade">
|
<div class="grade">
|
||||||
<div v-if="submission.status === 'pending'" class="pending">
|
<div v-if="submission.status === 'pending'" class="pending">
|
||||||
<NSpin size="small" />
|
<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>
|
||||||
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
|
<div v-else-if="submission.status === 'error'" class="error">评分时服务器出错了,请重新提交;还是不行请告诉老师。</div>
|
||||||
<template v-else-if="submission.result">
|
<template v-else-if="submission.result">
|
||||||
@@ -47,7 +49,9 @@ const manualScore = computed(() => props.review?.scores.reduce<number>((a, s) =>
|
|||||||
</div>
|
</div>
|
||||||
<ul class="items">
|
<ul class="items">
|
||||||
<li v-for="(m, i) in manual" :key="i">
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="review.comment" class="comment">老师评语:{{ review.comment }}</div>
|
<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 {
|
.manual span + span::before {
|
||||||
content: "、";
|
content: '、';
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -85,8 +85,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
|
|||||||
<template v-if="titles.length">
|
<template v-if="titles.length">
|
||||||
<h4 class="section">评价项</h4>
|
<h4 class="section">评价项</h4>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果,
|
下面这些功能项由平台自动检查。点"自己检查",平台会运行你现在的程序,逐项操作、核对结果, 告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
|
||||||
告诉你哪几项还没做好。<b>不计分、不留记录</b>,可以随时检查。
|
|
||||||
</p>
|
</p>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
|
<NButton type="primary" :loading="running" :disabled="running || titles.length === 0" @click="start">自己检查</NButton>
|
||||||
@@ -101,11 +100,7 @@ const formatTime = (ms: number) => new Date(ms).toLocaleTimeString('zh-CN', { ho
|
|||||||
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
|
<div v-if="current?.result && !current.result.compiled" class="error">编译没有通过,先改正编译错误</div>
|
||||||
|
|
||||||
<ul class="items">
|
<ul class="items">
|
||||||
<li
|
<li v-for="(title, i) in titles" :key="i" :class="current?.result && !running ? (current.result.results[i]?.passed ? 'passed' : 'failed') : ''">
|
||||||
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>
|
<span class="mark">{{ current?.result && !running ? (current.result.results[i]?.passed ? '✓' : '✗') : '·' }}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ onMounted(async () => {
|
|||||||
loaded.value = true
|
loaded.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTime = (ms: number) =>
|
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
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)
|
const total = (a: Assignment) => a.scoring.auto.reduce((x, y) => x + y, 0)
|
||||||
|
|
||||||
function status(row: Row) {
|
function status(row: Row) {
|
||||||
const s = row.latest
|
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 === 'pending') return h(NTag, { type: 'warning', size: 'small' }, () => '评分中')
|
||||||
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
|
if (s.status === 'error') return h(NTag, { type: 'error', size: 'small' }, () => '评分出错')
|
||||||
if (row.manualScore !== null) {
|
if (row.manualScore !== null) {
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ import { currentUser } from '../auth'
|
|||||||
<h2>学生</h2>
|
<h2>学生</h2>
|
||||||
|
|
||||||
<h3>1. 登录</h3>
|
<h3>1. 登录</h3>
|
||||||
<p>
|
<p>用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。</p>
|
||||||
用老师发给你的账号(学号)和密码登录。请用新版的 Chrome 或 Edge 浏览器;用 360、QQ 浏览器时,点地址栏右边的闪电图标切换到<b>极速模式</b>。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>2. 自由练习和做作业</h3>
|
<h3>2. 自由练习和做作业</h3>
|
||||||
<ul>
|
<ul>
|
||||||
@@ -66,10 +64,11 @@ import { currentUser } from '../auth'
|
|||||||
|
|
||||||
<h3>6. 自己检查、提交</h3>
|
<h3>6. 自己检查、提交</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li><b>任务</b>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。</li>
|
|
||||||
<li>
|
<li>
|
||||||
做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。
|
<b>任务</b
|
||||||
|
>面板下面是<b>评价项</b>:这个任务要自动检查哪些功能;点<b>自己检查</b>,平台按评分规则试运行你的程序,告诉你哪项没通过、为什么。不计分,可以检查很多次。
|
||||||
</li>
|
</li>
|
||||||
|
<li>做作业时点<b>提交</b>,平台自动评分,几十秒内出结果(全班同时提交时要排队)。截止前可以改了再交,以最后一次为准。</li>
|
||||||
<li>点工具栏上的分数,看每一项的得分;老师评阅后,这里能看到老师的打分和评语。</li>
|
<li>点工具栏上的分数,看每一项的得分;老师评阅后,这里能看到老师的打分和评语。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -111,9 +110,7 @@ import { currentUser } from '../auth'
|
|||||||
|
|
||||||
<h3>4. 上课前后</h3>
|
<h3>4. 上课前后</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。</li>
|
||||||
第一次用之前,让学生提前打开一次网站:实验页第一次打开要下载约 1.5 MB,之后浏览器会缓存;全班同时第一次打开时会慢一些。
|
|
||||||
</li>
|
|
||||||
<li>上课时间不要更新平台(推送代码):更新会重启平台,正在运行的学生程序会结束(页面会提示并自动重连)。</li>
|
<li>上课时间不要更新平台(推送代码):更新会重启平台,正在运行的学生程序会结束(页面会提示并自动重连)。</li>
|
||||||
<li>全班同时"自己检查"或提交时要排队,项目 8 这种大任务最后一个人大约等 1.5 分钟。</li>
|
<li>全班同时"自己检查"或提交时要排队,项目 8 这种大任务最后一个人大约等 1.5 分钟。</li>
|
||||||
<li>重要节点前后备份数据:在服务器上执行 <code>deploy/backup.sh</code>(说明见 deploy/README.md)。</li>
|
<li>重要节点前后备份数据:在服务器上执行 <code>deploy/backup.sh</code>(说明见 deploy/README.md)。</li>
|
||||||
@@ -126,9 +123,7 @@ import { currentUser } from '../auth'
|
|||||||
<dt>窗体上的按钮点了没反应</dt>
|
<dt>窗体上的按钮点了没反应</dt>
|
||||||
<dd>看运行区有没有显示"与服务器的连接断开了"。断开后程序已经结束,等它自动连上,再按 F5 重新运行。</dd>
|
<dd>看运行区有没有显示"与服务器的连接断开了"。断开后程序已经结束,等它自动连上,再按 F5 重新运行。</dd>
|
||||||
<dt>代码改乱了,想重来</dt>
|
<dt>代码改乱了,想重来</dt>
|
||||||
<dd>
|
<dd>工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。</dd>
|
||||||
工具栏的<b>恢复原始代码</b>(做作业时是<b>恢复到作业起点</b>)把代码换回起点;已经提交的不受影响。
|
|
||||||
</dd>
|
|
||||||
<dt>登录时提示密码输错太多次</dt>
|
<dt>登录时提示密码输错太多次</dt>
|
||||||
<dd>同一个账号连续输错 10 次要等 10 分钟,或者请老师在名单里查看、重置密码。</dd>
|
<dd>同一个账号连续输错 10 次要等 10 分钟,或者请老师在名单里查看、重置密码。</dd>
|
||||||
<dt>网页打不开、一片空白</dt>
|
<dt>网页打不开、一片空白</dt>
|
||||||
|
|||||||
@@ -34,13 +34,7 @@ async function submit() {
|
|||||||
<h1>物联网编程仿真平台</h1>
|
<h1>物联网编程仿真平台</h1>
|
||||||
<p>用老师发给你的账号和密码登录</p>
|
<p>用老师发给你的账号和密码登录</p>
|
||||||
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
|
<NInput v-model:value="username" placeholder="账号" autofocus :input-props="{ autocomplete: 'username' }" />
|
||||||
<NInput
|
<NInput v-model:value="password" type="password" show-password-on="click" placeholder="密码" :input-props="{ autocomplete: 'current-password' }" />
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="密码"
|
|
||||||
:input-props="{ autocomplete: 'current-password' }"
|
|
||||||
/>
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
|
<NButton type="primary" attr-type="submit" block :loading="busy" :disabled="!username || !password">登录</NButton>
|
||||||
<RouterLink to="/help" target="_blank" class="help">第一次使用?看使用说明</RouterLink>
|
<RouterLink to="/help" target="_blank" class="help">第一次使用?看使用说明</RouterLink>
|
||||||
|
|||||||
@@ -237,8 +237,7 @@ watch(selectedId, (_, old) => {
|
|||||||
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
|
if (old !== undefined && dirty.value) message.warning('上一个学生的评分还没保存')
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatTime = (ms: number) =>
|
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
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 } {
|
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success' | 'info'; text: string } {
|
||||||
if (!row.status) return { type: 'default', text: '未提交' }
|
if (!row.status) return { type: 'default', text: '未提交' }
|
||||||
@@ -262,7 +261,15 @@ function exportCsv() {
|
|||||||
const a = assignment.value
|
const a = assignment.value
|
||||||
if (!a) return
|
if (!a) return
|
||||||
downloadCsv(`${a.title}-成绩.csv`, [
|
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) => [
|
...rows.value.map((r) => [
|
||||||
`\t${r.username}`,
|
`\t${r.username}`,
|
||||||
r.name,
|
r.name,
|
||||||
@@ -375,7 +382,8 @@ async function signOut() {
|
|||||||
<template v-else-if="detail.aiReview.status === 'done'">
|
<template v-else-if="detail.aiReview.status === 'done'">
|
||||||
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
|
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
|
||||||
<span class="score-title">{{ m.title }}</span>
|
<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 class="ai-reason">{{ detail.aiReview.scores[i]?.reason }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
|
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
|
||||||
@@ -401,9 +409,7 @@ async function signOut() {
|
|||||||
</div>
|
</div>
|
||||||
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
|
<NInput v-model:value="comment" type="textarea" :autosize="{ minRows: 2, maxRows: 6 }" placeholder="评语(学生能看到)" />
|
||||||
<div class="save-row">
|
<div class="save-row">
|
||||||
<span class="muted">
|
<span class="muted"> 总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }} </span>
|
||||||
总分 {{ (detail.submission.autoScore ?? 0) + draftManual }} / {{ autoTotal + manualTotal }}
|
|
||||||
</span>
|
|
||||||
<span class="spacer" />
|
<span class="spacer" />
|
||||||
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
|
<NButton size="small" :loading="saving" @click="save()">保存</NButton>
|
||||||
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
|
<NButton size="small" type="primary" :loading="saving" @click="save(true)">保存并看下一个</NButton>
|
||||||
|
|||||||
@@ -53,9 +53,7 @@ async function loadClasses() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadStudents() {
|
async function loadStudents() {
|
||||||
students.value = selectedId.value
|
students.value = selectedId.value ? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? []) : []
|
||||||
? ((await attempt(() => api<Student[]>('GET', `/api/teacher/classes/${selectedId.value}/students`))) ?? [])
|
|
||||||
: []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectClass(id: number) {
|
async function selectClass(id: number) {
|
||||||
@@ -173,18 +171,14 @@ async function confirmImport() {
|
|||||||
else
|
else
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: '部分学生没有导入',
|
title: '部分学生没有导入',
|
||||||
content: () =>
|
content: () => h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
||||||
h('div', [h('p', summary), ...result.errors.map((e) => h('div', `${rows[e.row - 1]?.username ?? ''}:${e.message}`))]),
|
|
||||||
positiveText: '知道了',
|
positiveText: '知道了',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportRoster() {
|
function exportRoster() {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
downloadCsv(`${selected.value.name}-名单.csv`, [
|
downloadCsv(`${selected.value.name}-名单.csv`, [['学号', '姓名', '密码'], ...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`])])
|
||||||
['学号', '姓名', '密码'],
|
|
||||||
...students.value.map((s) => [`\t${s.username}`, s.name, `\t${s.password}`]),
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadTemplate() {
|
function downloadTemplate() {
|
||||||
@@ -226,13 +220,7 @@ async function signOut() {
|
|||||||
</form>
|
</form>
|
||||||
</NPopover>
|
</NPopover>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button v-for="c in classes" :key="c.id" class="class-item" :class="{ active: c.id === selectedId }" @click="selectClass(c.id)">
|
||||||
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>{{ c.name }}</span>
|
||||||
<span class="count">{{ c.studentCount }} 人</span>
|
<span class="count">{{ c.studentCount }} 人</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -267,7 +255,8 @@ async function signOut() {
|
|||||||
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFileChosen" />
|
||||||
</div>
|
</div>
|
||||||
<p class="tip">
|
<p class="tip">
|
||||||
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6 位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
名单是 Excel 另存的 CSV,列为"学号、姓名、密码"。密码可以留空,平台会生成 6
|
||||||
|
位数字密码;导入后点"导出名单"发给学生。学号已存在时更新姓名和班级,密码留空则不变。
|
||||||
</p>
|
</p>
|
||||||
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
<NDataTable :columns="columns" :data="students" :row-key="(s: Student) => s.id" size="small" :bordered="false" />
|
||||||
</NTabPane>
|
</NTabPane>
|
||||||
@@ -292,14 +281,19 @@ async function signOut() {
|
|||||||
@update:show="(v: boolean) => !v && (preview = null)"
|
@update:show="(v: boolean) => !v && (preview = null)"
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length">,
|
共 {{ preview?.length }} 人<template v-if="preview && previewValid.length < preview.length"
|
||||||
<span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
>, <span class="problem">{{ preview.length - previewValid.length }} 行有问题,不会导入</span></template
|
||||||
>。
|
>。
|
||||||
</p>
|
</p>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>学号</th><th>姓名</th><th>密码</th><th /></tr>
|
<tr>
|
||||||
|
<th>学号</th>
|
||||||
|
<th>姓名</th>
|
||||||
|
<th>密码</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
|
<tr v-for="(r, i) in preview" :key="i" :class="{ bad: r.problem }">
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ import FormDesigner from '../designer/FormDesigner.vue'
|
|||||||
import { createDesignerHistory } from '../designer/history'
|
import { createDesignerHistory } from '../designer/history'
|
||||||
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
|
import { gallery, loadGallery, projectResources, resourcePrefix, resourcesFile, RESOURCES_FILE } from '../designer/resources'
|
||||||
import ResourcesDialog from '../designer/ResourcesDialog.vue'
|
import ResourcesDialog from '../designer/ResourcesDialog.vue'
|
||||||
import { classPathError, formNameTaken, isFormDesigner, newClassFile, newFormFiles, nextFormName, projectNamespace, renameForm, startupForm } from '../designer/templates'
|
import {
|
||||||
|
classPathError,
|
||||||
|
formNameTaken,
|
||||||
|
isFormDesigner,
|
||||||
|
newClassFile,
|
||||||
|
newFormFiles,
|
||||||
|
nextFormName,
|
||||||
|
projectNamespace,
|
||||||
|
renameForm,
|
||||||
|
startupForm,
|
||||||
|
} from '../designer/templates'
|
||||||
import CodeEditor from '../editor/CodeEditor.vue'
|
import CodeEditor from '../editor/CodeEditor.vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { currentUser, logout } from '../auth'
|
import { currentUser, logout } from '../auth'
|
||||||
@@ -36,11 +46,13 @@ const DESIGN = '设计:'
|
|||||||
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
|
/** 每个 X.Designer.cs 对应一个设计器标签页,和它的代码文件 X.cs */
|
||||||
const forms = computed(() => {
|
const forms = computed(() => {
|
||||||
const startup = startupForm(files.value)
|
const startup = startupForm(files.value)
|
||||||
return files.value
|
return (
|
||||||
|
files.value
|
||||||
.filter((f) => isFormDesigner(f.path))
|
.filter((f) => isFormDesigner(f.path))
|
||||||
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
.map((f) => ({ tab: DESIGN + f.path, designer: f.path, code: f.path.replace(/\.Designer\.cs$/, '.cs') }))
|
||||||
// 启动窗体排在最前
|
// 启动窗体排在最前
|
||||||
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
.sort((a, b) => Number(b.code === `${startup}.cs`) - Number(a.code === `${startup}.cs`))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
/**
|
/**
|
||||||
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
|
* 标签页按窗体分组:每个窗体依次是 [设计]、代码;其他文件(Program.cs 等)放最后。
|
||||||
@@ -79,7 +91,9 @@ const message = useMessage()
|
|||||||
const workKey = ref<string>()
|
const workKey = ref<string>()
|
||||||
|
|
||||||
const STATUS_TEXT = { idle: '未运行', compiling: '编译中…', running: '运行中', stopped: '已停止' } as const
|
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')
|
const running = computed(() => state.status === 'compiling' || state.status === 'running')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +179,6 @@ const taskPanel = computed(() => {
|
|||||||
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
|
return { task: sampleTask.value, titles: sampleChecks.value, target: `sample:${id}`, url: `/api/samples/${encodeURIComponent(id)}/selfcheck` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// ---------- 作业 ----------
|
// ---------- 作业 ----------
|
||||||
const assignment = ref<Assignment | null>(null)
|
const assignment = ref<Assignment | null>(null)
|
||||||
const submission = ref<Submission | null>(null)
|
const submission = ref<Submission | null>(null)
|
||||||
@@ -265,8 +278,7 @@ function pollSubmission() {
|
|||||||
}
|
}
|
||||||
onBeforeUnmount(() => clearTimeout(pollTimer))
|
onBeforeUnmount(() => clearTimeout(pollTimer))
|
||||||
|
|
||||||
const formatTime = (ms: number) =>
|
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
||||||
|
|
||||||
// ---------- 自动保存到服务器 ----------
|
// ---------- 自动保存到服务器 ----------
|
||||||
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
/** idle:还没改过;pending:有改动等待保存;saving:正在保存;saved:已保存;error:保存失败(下次改动时重试) */
|
||||||
@@ -510,13 +522,7 @@ function run() {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<strong>物联网编程仿真平台</strong>
|
<strong>物联网编程仿真平台</strong>
|
||||||
<NSelect
|
<NSelect class="sample-select" :value="sampleId" :options="samples" placeholder="选择示例工程" @update:value="loadSample" />
|
||||||
class="sample-select"
|
|
||||||
:value="sampleId"
|
|
||||||
:options="samples"
|
|
||||||
placeholder="选择示例工程"
|
|
||||||
@update:value="loadSample"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
<NButton type="primary" :disabled="running" title="F5" @click="run">▶ 运行</NButton>
|
||||||
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
<NButton :disabled="!running" title="Shift+F5" @click="session.stop">■ 停止</NButton>
|
||||||
@@ -546,8 +552,7 @@ function run() {
|
|||||||
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
<span v-if="!viewing" class="save-state" :class="saveState">{{ SAVE_TEXT[saveState] }}</span>
|
||||||
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
<NTag :type="statusType">{{ STATUS_TEXT[state.status] }}</NTag>
|
||||||
<span v-if="!assignmentId && !viewing && currentUser?.role === 'teacher'" class="metrics">
|
<span v-if="!assignmentId && !viewing && currentUser?.role === 'teacher'" class="metrics">
|
||||||
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返
|
编译 {{ state.metrics.compileMs }} ms · 启动 {{ state.metrics.startMs }} ms · 最近一次操作往返 {{ state.metrics.lastRoundTripMs }} ms
|
||||||
{{ state.metrics.lastRoundTripMs }} ms
|
|
||||||
</span>
|
</span>
|
||||||
<span class="spacer" />
|
<span class="spacer" />
|
||||||
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
<RouterLink v-if="!assignmentId && currentUser?.role === 'student'" to="/assignments" class="nav-link">我的作业</RouterLink>
|
||||||
@@ -616,7 +621,12 @@ function run() {
|
|||||||
</template>
|
</template>
|
||||||
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
|
将删除 {{ deletableClass }},代码里用到它的地方需要自己修改。确定删除吗?
|
||||||
</NPopconfirm>
|
</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})` : '' }}
|
项目资源{{ resourceNames.length ? `(${resourceNames.length})` : '' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
<NPopconfirm v-if="deletableForm" positive-text="删除" negative-text="取消" @positive-click="deleteForm">
|
||||||
@@ -655,11 +665,7 @@ function run() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button
|
<button class="run-toggle" :title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'" @click="toggleRunArea()">
|
||||||
class="run-toggle"
|
|
||||||
:title="runCollapsed ? '展开运行区' : '收起运行区(左边的代码和设计器占满宽度,运行时自动展开)'"
|
|
||||||
@click="toggleRunArea()"
|
|
||||||
>
|
|
||||||
{{ runCollapsed ? '◂' : '▸' }}
|
{{ runCollapsed ? '◂' : '▸' }}
|
||||||
</button>
|
</button>
|
||||||
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
|
<RunArea v-show="!runCollapsed" :session="session" @diagnostic="showDiagnostic" />
|
||||||
|
|||||||
+3
-13
@@ -59,10 +59,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
<template>
|
<template>
|
||||||
<section class="run-area" :class="{ maximized }">
|
<section class="run-area" :class="{ maximized }">
|
||||||
<div ref="desktop" class="desktop">
|
<div ref="desktop" class="desktop">
|
||||||
<div
|
<div class="desktop-canvas" :style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined">
|
||||||
class="desktop-canvas"
|
|
||||||
:style="scale < 1 ? { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%` } : undefined"
|
|
||||||
>
|
|
||||||
<FormWindow
|
<FormWindow
|
||||||
v-for="(form, index) in state.forms"
|
v-for="(form, index) in state.forms"
|
||||||
:key="form.root.id"
|
:key="form.root.id"
|
||||||
@@ -73,21 +70,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
:act="session.act"
|
:act="session.act"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<MessageBoxDialog
|
<MessageBoxDialog v-for="box in state.messageBoxes" :key="box.requestId" :box="box" @reply="session.replyMessageBox(box.requestId, $event)" />
|
||||||
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-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 v-else-if="state.connection === 'restored' && state.forms.length === 0" class="desktop-hint lost">
|
||||||
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
|
连接已恢复。刚才的程序随连接断开已经结束,请重新运行
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
<div v-else-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||||
<div class="desktop-tools">
|
<div class="desktop-tools">
|
||||||
<span v-if="scale < 1" class="desktop-scale" title="窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚">
|
<span v-if="scale < 1" class="desktop-scale" title='窗体比运行区大,按比例缩小显示;点"放大"可以看得更清楚'> 缩放 {{ Math.round(scale * 100) }}% </span>
|
||||||
缩放 {{ Math.round(scale * 100) }}%
|
|
||||||
</span>
|
|
||||||
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
|
<button type="button" :title="maximized ? '还原(Esc)' : '运行区占满整个网页'" @click="maximized = !maximized">
|
||||||
{{ maximized ? '还原' : '放大' }}
|
{{ maximized ? '还原' : '放大' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
import { reactive, shallowReactive } from 'vue'
|
import { reactive, shallowReactive } from 'vue'
|
||||||
import { validAccessToken } from '../tokens'
|
import { validAccessToken } from '../tokens'
|
||||||
import type {
|
import type { BoardState, ClientMessage, ControlNode, Diagnostic, ServerMessage, SourceFile, UserAction } from '@iot/shared'
|
||||||
BoardState,
|
|
||||||
ClientMessage,
|
|
||||||
ControlNode,
|
|
||||||
Diagnostic,
|
|
||||||
ServerMessage,
|
|
||||||
SourceFile,
|
|
||||||
UserAction,
|
|
||||||
} from '@iot/shared'
|
|
||||||
|
|
||||||
export interface OpenForm {
|
export interface OpenForm {
|
||||||
root: ControlNode
|
root: ControlNode
|
||||||
|
|||||||
@@ -151,20 +151,18 @@ const cameraTitle = computed(() =>
|
|||||||
|
|
||||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||||
<div class="ceiling">
|
<div class="ceiling">
|
||||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
||||||
红外对射
|
|
||||||
</button>
|
|
||||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
||||||
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</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="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
|
||||||
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
|
<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>
|
</div>
|
||||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
||||||
红外对射
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED 显示屏 -->
|
<!-- LED 显示屏 -->
|
||||||
@@ -227,7 +225,14 @@ const cameraTitle = computed(() =>
|
|||||||
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
|
<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 }">
|
<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 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>
|
</svg>
|
||||||
<div class="card-title">广播</div>
|
<div class="card-title">广播</div>
|
||||||
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
||||||
@@ -278,22 +283,16 @@ const cameraTitle = computed(() =>
|
|||||||
<div class="module">
|
<div class="module">
|
||||||
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
||||||
<div class="io-row">
|
<div class="io-row">
|
||||||
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
|
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }"> <i />DI{{ i }} {{ input.label }} </span>
|
||||||
<i />DI{{ i }} {{ input.label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="io-row">
|
<div class="io-row">
|
||||||
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
|
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }"> <i />DO{{ i }} {{ label }} </span>
|
||||||
<i />DO{{ i }} {{ label }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="module ports">
|
<div class="module ports">
|
||||||
<div class="module-title">串口服务器</div>
|
<div class="module-title">串口服务器</div>
|
||||||
<div class="port-list">
|
<div class="port-list"><span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span></div>
|
||||||
<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>
|
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,7 +497,7 @@ button {
|
|||||||
white-space: pre;
|
white-space: pre;
|
||||||
color: #ff2a2a;
|
color: #ff2a2a;
|
||||||
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
|
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;
|
font-size: 30px;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
animation: led-scroll 12s linear infinite;
|
animation: led-scroll 12s linear infinite;
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ function review(row: Row) {
|
|||||||
void router.push(`/teacher/assignments/${row.id}`)
|
void router.push(`/teacher/assignments/${row.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (ms: number) =>
|
const formatTime = (ms: number) => new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
||||||
|
|
||||||
const columns: DataTableColumns<Row> = [
|
const columns: DataTableColumns<Row> = [
|
||||||
{ title: '作业', key: 'title' },
|
{ title: '作业', key: 'title' },
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ function cell(row: Row, assignmentId: number) {
|
|||||||
const c = row.scores[assignmentId]
|
const c = row.scores[assignmentId]
|
||||||
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
|
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
|
||||||
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
|
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
|
||||||
const text =
|
const text = c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
|
||||||
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)
|
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,46 @@
|
|||||||
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
|
// 图表(WinForms 的 Chart,教材项目 8 的 7 天温度折线图):运行器发来各个系列的点,用 Chart.js 画。
|
||||||
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
|
// 图表类型按 WinForms 的 SeriesChartType 对应;配色是 WinForms 默认的 BrightPastel;图例默认在右边;不要动画。
|
||||||
import {
|
import {
|
||||||
ArcElement, BarController, BarElement, CategoryScale, Chart, DoughnutController, Filler, Legend, LinearScale, LineController,
|
ArcElement,
|
||||||
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip, type ChartConfiguration,
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart,
|
||||||
|
DoughnutController,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PieController,
|
||||||
|
PointElement,
|
||||||
|
RadarController,
|
||||||
|
RadialLinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
type ChartConfiguration,
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue'
|
||||||
import type { ControlNode } from '@iot/shared'
|
import type { ControlNode } from '@iot/shared'
|
||||||
|
|
||||||
Chart.register(ArcElement, BarController, BarElement, CategoryScale, DoughnutController, Filler, Legend, LinearScale, LineController,
|
Chart.register(
|
||||||
LineElement, PieController, PointElement, RadarController, RadialLinearScale, Title, Tooltip)
|
ArcElement,
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
DoughnutController,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PieController,
|
||||||
|
PointElement,
|
||||||
|
RadarController,
|
||||||
|
RadialLinearScale,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
interface ChartSeries {
|
interface ChartSeries {
|
||||||
name: string
|
name: string
|
||||||
@@ -33,7 +65,23 @@ interface ChartData {
|
|||||||
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
|
const props = defineProps<{ node: ControlNode; style: CSSProperties }>()
|
||||||
|
|
||||||
/** WinForms 图表默认的 BrightPastel 配色 */
|
/** 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 data = computed(() => props.node.props.Chart as ChartData | undefined)
|
||||||
const canvas = ref<HTMLCanvasElement>()
|
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 } },
|
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 },
|
title: { display: d.titles.length > 0, text: d.titles },
|
||||||
},
|
},
|
||||||
scales: pie || chartKind === 'radar'
|
scales:
|
||||||
|
pie || chartKind === 'radar'
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
x: { stacked, grid: { color: '#d9d9d9' } },
|
x: { stacked, grid: { color: '#d9d9d9' } },
|
||||||
|
|||||||
@@ -95,13 +95,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
<button
|
<button v-if="node.type === 'Button'" class="wf-control wf-button" :style="style" :disabled="!enabled" @click="act({ action: 'click' })">
|
||||||
v-if="node.type === 'Button'"
|
|
||||||
class="wf-control wf-button"
|
|
||||||
:style="style"
|
|
||||||
:disabled="!enabled"
|
|
||||||
@click="act({ action: 'click' })"
|
|
||||||
>
|
|
||||||
{{ displayText(p.Text) }}
|
{{ displayText(p.Text) }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -230,11 +224,7 @@ const itemHeight = computed(() => `${(p.value.ItemHeight as number | undefined)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 教材控件库:LED 显示屏 -->
|
<!-- 教材控件库:LED 显示屏 -->
|
||||||
<LedControl
|
<LedControl v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'" :node="node" :style="style" />
|
||||||
v-else-if="node.type === 'WinFormsDeviceControlLibrary.LED'"
|
|
||||||
:node="node"
|
|
||||||
:style="style"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 其他容器(Panel、UserControl 等):按普通容器渲染 -->
|
<!-- 其他容器(Panel、UserControl 等):按普通容器渲染 -->
|
||||||
<div v-else class="wf-control wf-panel" :style="style" :title="node.type">
|
<div v-else class="wf-control wf-panel" :style="style" :title="node.type">
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ function click(row: number, column: number) {
|
|||||||
if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' })
|
if (col?.check && !col.readOnly && p.value.ReadOnly !== true) props.act({ action: 'cellEdit', row, column, value: cell === true ? 'False' : 'True' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function doubleClick(row: number, column: number) {
|
||||||
|
props.act({ action: 'cellDoubleClick', row, column })
|
||||||
|
startEdit(row, column)
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit(row: number, column: number, initial?: string) {
|
function startEdit(row: number, column: number, initial?: string) {
|
||||||
const col = columns.value.find((c) => c.index === column)
|
const col = columns.value.find((c) => c.index === column)
|
||||||
if (!props.enabled || !col || col.readOnly || col.check || p.value.ReadOnly === true) return
|
if (!props.enabled || !col || col.readOnly || col.check || p.value.ReadOnly === true) return
|
||||||
@@ -137,12 +142,7 @@ function columnStyle(column: Column): CSSProperties {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr v-for="(row, r) in rows.items" :key="r" :class="{ 'wf-dgv-selected': selectedRows.has(r) }" :style="{ height: `${rows.height}px` }">
|
||||||
v-for="(row, r) in rows.items"
|
|
||||||
:key="r"
|
|
||||||
:class="{ 'wf-dgv-selected': selectedRows.has(r) }"
|
|
||||||
:style="{ height: `${rows.height}px` }"
|
|
||||||
>
|
|
||||||
<th v-if="rowHeaders" class="wf-dgv-rowheader" @click="click(r, -1)">
|
<th v-if="rowHeaders" class="wf-dgv-rowheader" @click="click(r, -1)">
|
||||||
{{ current?.[0] === r ? '▶' : row.isNew ? '*' : '' }}
|
{{ current?.[0] === r ? '▶' : row.isNew ? '*' : '' }}
|
||||||
</th>
|
</th>
|
||||||
@@ -152,7 +152,7 @@ function columnStyle(column: Column): CSSProperties {
|
|||||||
:class="{ 'wf-dgv-current': isCurrent(r, column.index) }"
|
:class="{ 'wf-dgv-current': isCurrent(r, column.index) }"
|
||||||
:style="columnStyle(column)"
|
:style="columnStyle(column)"
|
||||||
@click="click(r, column.index)"
|
@click="click(r, column.index)"
|
||||||
@dblclick="act({ action: 'cellDoubleClick', row: r, column: column.index }); startEdit(r, column.index)"
|
@dblclick="doubleClick(r, column.index)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-if="editing && editing.row === r && editing.column === column.index"
|
v-if="editing && editing.row === r && editing.column === column.index"
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ function pick(event: Event) {
|
|||||||
if (!picked) return
|
if (!picked) return
|
||||||
const [date, time] = value.value.split('T')
|
const [date, time] = value.value.split('T')
|
||||||
const withSeconds = (t: string) => (t.length === 5 ? `${t}:00` : t)
|
const withSeconds = (t: string) => (t.length === 5 ? `${t}:00` : t)
|
||||||
const next =
|
const next = inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
|
||||||
inputType.value === 'date' ? `${picked}T${time}` : inputType.value === 'time' ? `${date}T${withSeconds(picked)}` : withSeconds(picked)
|
|
||||||
props.act({ action: 'pick', value: next })
|
props.act({ action: 'pick', value: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const p = computed(() => props.form.props)
|
const p = computed(() => props.form.props)
|
||||||
const runImages = inject(RUN_IMAGES, new Map<string, string>())
|
const runImages = inject(RUN_IMAGES, new Map<string, string>())
|
||||||
provide(AMBIENT_BACK_COLOR, computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR))
|
provide(
|
||||||
|
AMBIENT_BACK_COLOR,
|
||||||
|
computed(() => (p.value.BackColor as string | null) ?? DEFAULT_BACK_COLOR),
|
||||||
|
)
|
||||||
const borderless = computed(() => p.value.FormBorderStyle === 'None')
|
const borderless = computed(() => p.value.FormBorderStyle === 'None')
|
||||||
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
|
// WinForms 中先加入 Controls 的控件在最上层,所以倒序渲染
|
||||||
const childrenTopLast = computed(() => [...props.form.children].reverse())
|
const childrenTopLast = computed(() => [...props.form.children].reverse())
|
||||||
|
|||||||
@@ -8,13 +8,39 @@ const emit = defineEmits<{ reply: [result: string] }>()
|
|||||||
|
|
||||||
const BUTTONS: Record<string, [string, string][]> = {
|
const BUTTONS: Record<string, [string, string][]> = {
|
||||||
OK: [['OK', '确定']],
|
OK: [['OK', '确定']],
|
||||||
OKCancel: [['OK', '确定'], ['Cancel', '取消']],
|
OKCancel: [
|
||||||
YesNo: [['Yes', '是(Y)'], ['No', '否(N)']],
|
['OK', '确定'],
|
||||||
YesNoCancel: [['Yes', '是(Y)'], ['No', '否(N)'], ['Cancel', '取消']],
|
['Cancel', '取消'],
|
||||||
RetryCancel: [['Retry', '重试(R)'], ['Cancel', '取消']],
|
],
|
||||||
AbortRetryIgnore: [['Abort', '中止(A)'], ['Retry', '重试(R)'], ['Ignore', '忽略(I)']],
|
YesNo: [
|
||||||
|
['Yes', '是(Y)'],
|
||||||
|
['No', '否(N)'],
|
||||||
|
],
|
||||||
|
YesNoCancel: [
|
||||||
|
['Yes', '是(Y)'],
|
||||||
|
['No', '否(N)'],
|
||||||
|
['Cancel', '取消'],
|
||||||
|
],
|
||||||
|
RetryCancel: [
|
||||||
|
['Retry', '重试(R)'],
|
||||||
|
['Cancel', '取消'],
|
||||||
|
],
|
||||||
|
AbortRetryIgnore: [
|
||||||
|
['Abort', '中止(A)'],
|
||||||
|
['Retry', '重试(R)'],
|
||||||
|
['Ignore', '忽略(I)'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const ICONS: Record<string, string> = {
|
||||||
|
Error: '⛔',
|
||||||
|
Hand: '⛔',
|
||||||
|
Stop: '⛔',
|
||||||
|
Warning: '⚠️',
|
||||||
|
Exclamation: '⚠️',
|
||||||
|
Information: 'ℹ️',
|
||||||
|
Asterisk: 'ℹ️',
|
||||||
|
Question: '❓',
|
||||||
}
|
}
|
||||||
const ICONS: Record<string, string> = { Error: '⛔', Hand: '⛔', Stop: '⛔', Warning: '⚠️', Exclamation: '⚠️', Information: 'ℹ️', Asterisk: 'ℹ️', Question: '❓' }
|
|
||||||
|
|
||||||
const buttons = computed(() => BUTTONS[props.box.buttons] ?? BUTTONS.OK)
|
const buttons = computed(() => BUTTONS[props.box.buttons] ?? BUTTONS.OK)
|
||||||
const icon = computed(() => ICONS[props.box.icon])
|
const icon = computed(() => ICONS[props.box.icon])
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const vertical = computed(() => p.value.Orientation === 'Vertical')
|
|||||||
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
|
const tickStyle = computed(() => String(p.value.TickStyle ?? 'BottomRight'))
|
||||||
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
|
const size = computed(() => p.value.Size as { width: number; height: number } | undefined)
|
||||||
/** 轨道方向上的长度 */
|
/** 轨道方向上的长度 */
|
||||||
const length = computed(() => (vertical.value ? size.value?.height ?? 104 : size.value?.width ?? 104))
|
const length = computed(() => (vertical.value ? (size.value?.height ?? 104) : (size.value?.width ?? 104)))
|
||||||
|
|
||||||
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
|
/** 值 → 沿轨道的位置(像素);竖向时最小值在下面 */
|
||||||
function position(v: number) {
|
function position(v: number) {
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ export function imageSources(value: unknown, images: ReadonlyMap<string, string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
|
/** <img> 的 srcset:有两倍图时浏览器按屏幕分辨率选 */
|
||||||
export const srcset = (sources: { src: string; hidpi?: string } | undefined) =>
|
export const srcset = (sources: { src: string; hidpi?: string } | undefined) => (sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined)
|
||||||
sources?.hidpi ? `${sources.src} 1x, ${sources.hidpi} 2x` : undefined
|
|
||||||
|
|
||||||
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
|
/** 背景图片的 CSS:BackgroundImageLayout 为 Tile(默认,平铺)、None(左上角一张)、Center、Stretch、Zoom */
|
||||||
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
|
export function backgroundCss(value: unknown, layout: unknown, images: ReadonlyMap<string, string>): CSSProperties {
|
||||||
|
|||||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user