42 Commits

Author SHA1 Message Date
alivender
cbf85165b7 Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-17 14:55:41 +08:00
alivender
fdfc5729ec feat&fix: 完善JPEGClient逻辑,前端新增编码器组件 2025-08-17 14:55:38 +08:00
8e69c96891 fix: 调整进度条的步幅 2025-08-17 14:23:35 +08:00
caa26c729e fix: 前端修复拨码开关第一个无法开关的问题;后端修复进度条停止在3%的问题 2025-08-17 13:46:14 +08:00
55edfd771e fix: 修复进度条的问题 2025-08-17 13:33:11 +08:00
97b86acfa8 fix: 修复拨码开关数字孪生无法正常工作的问题 2025-08-17 12:29:46 +08:00
b6720d867d feat: 实现拨动开关的数字孪生 2025-08-16 16:01:10 +08:00
a2ac1bcb3b fix: 修复比特流下载失败的问题 2025-08-16 15:55:14 +08:00
e61cf96c07 refactor: 使用更简洁的方式进行认证 2025-08-16 14:53:28 +08:00
c974de593a fix: 尝试去修复后端无法停止扫描数码管的问题 2025-08-16 14:21:26 +08:00
9bd3fb29e3 fix: 前后端修复七段数码管无法正常工作的问题 2025-08-16 13:05:01 +08:00
0a1e0982c2 feat: 前端七段数码管添加数字孪生功能 2025-08-16 11:56:27 +08:00
3644c75304 feat: 完成jpeg读取后端 2025-08-15 21:04:50 +08:00
774c9575d4 fix: 修复后端数码管无法正常读取/关闭的问题 2025-08-15 15:25:37 +08:00
a00cc84e48 fix: 修复数据库与SignalR无法连接的问题 2025-08-15 13:02:56 +08:00
6fa7fffa7f fix: 修复网络配置失败的问题 2025-08-14 20:31:43 +08:00
56eeb5dce3 feat: 完成数码管websocket通信 2025-08-14 20:25:32 +08:00
7bfc362b1f feat: 完成七段数码管后端 2025-08-14 15:21:18 +08:00
alivender
0e07a5996a feat: 合并冲突 2025-08-14 15:08:41 +08:00
alivender
4b2afe13db Merge branch 'master' of ssh://git.swordlost.top:222/SikongJueluo/FPGA_WebLab 2025-08-14 15:04:57 +08:00
9af4546a11 fix: 修复调整resource manager接口导致无法过编译的问题 2025-08-14 14:22:06 +08:00
66bc5882af feat: 完成jpeg后端 2025-08-14 14:19:46 +08:00
e5dac3e731 feat: 完成提交作业的后端 2025-08-14 11:38:09 +08:00
24622d30cf feat: 使首页的教程placehold支持中文,同时使markdown编辑器同app主题变化 2025-08-14 11:37:30 +08:00
c4b3a09198 feat: 添加Markdown编辑器 2025-08-13 19:27:09 +08:00
7a59c29e06 feat: 实现可编辑已有的实验 2025-08-13 16:11:06 +08:00
76342553ad feat: 认证管理实时获取token 2025-08-13 14:36:01 +08:00
efcdee2109 chore: 移除无用的库 2025-08-13 14:34:50 +08:00
37156c937a fix: 修复signalR无法认证的问题 2025-08-13 14:32:41 +08:00
6e84953740 refactor: 重新调整exam页面 2025-08-11 17:01:24 +08:00
b09961473e fix: 修复主题无法保存的问题 2025-08-11 16:21:25 +08:00
ed9eacf33f fix: 修复数据库无法正常获取信息的问题 2025-08-11 13:34:21 +08:00
c1d641c20c refactor: 视频流前后端适配 2025-08-11 13:09:30 +08:00
b95a61c532 refactor: 重构数据库相关操作 2025-08-10 20:13:44 +08:00
079004c17d fix: 修复生成api时,缺失main.ts的问题 2025-08-10 20:13:12 +08:00
11ef4dfba6 refactor: 重构videostream; fix: 修复进度条guid无法生成的问题 2025-08-10 20:07:37 +08:00
bbde060d11 feat: 完成基本的Jpeg控制 2025-08-10 20:06:05 +08:00
0547bb5a02 refactor: video stream service; fix: progress tracker guid 2025-08-09 14:09:03 +08:00
771f5e8e9f feat: 完成基本的Jpeg控制 2025-08-08 18:38:16 +08:00
58378851bb fix: 修复progresstracker堆栈溢出的问题 2025-08-08 16:34:31 +08:00
ae50ba3b9f feat: 为Number添加更多处理方式 2025-08-08 16:33:48 +08:00
d2508f6484 feat: 修改视频流后端服务,使其适配jpeg格式 2025-08-07 15:16:18 +08:00
92 changed files with 12565 additions and 8475 deletions

2
.gitignore vendored
View File

@@ -29,7 +29,7 @@ DebuggerCmd.md
*.ntvs*
*.njsproj
*.sw?
prompt.md
*.tsbuildinfo
# Generated Files

13
TODO.md
View File

@@ -1,13 +0,0 @@
# TODO
1. 后端HTTP视频流
640*480, RGB565
0x0000_0000 + 25800
2. 信号发生器界面导入.dat文件
3. 示波器后端交互、前端界面
4. 逻辑分析仪后端交互、前端界面
5. 前端重构
6. 数据库 —— 用户登录、板卡资源分配、板卡IP地址分配

View File

@@ -9,7 +9,10 @@
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
pkgs = import nixpkgs {
inherit system;
config.permittedInsecurePackages = ["dotnet-sdk-6.0.428"];
config.permittedInsecurePackages = [
"dotnet-sdk-6.0.428"
"beekeeper-studio-5.2.9"
];
};
});
in
@@ -21,7 +24,7 @@
nodejs
sqlite
sqls
sql-studio
beekeeper-studio
zlib
bash
# Backend

800
package-lock.json generated
View File

@@ -18,12 +18,12 @@
"axios": "^1.11.0",
"echarts": "^5.6.0",
"highlight.js": "^11.11.1",
"konva": "^9.3.20",
"lodash": "^4.17.21",
"log-symbols": "^7.0.0",
"lucide-vue-next": "^0.525.0",
"marked": "^12.0.0",
"mathjs": "^14.4.0",
"md-editor-v3": "^5.8.4",
"pinia": "^3.0.1",
"reka-ui": "^2.3.1",
"ts-log": "^2.2.7",
@@ -549,6 +549,390 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.18.6",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz",
"integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-angular": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz",
"integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.3"
}
},
"node_modules/@codemirror/lang-cpp": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz",
"integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/cpp": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-java": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz",
"integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/java": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-less": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz",
"integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-liquid": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.2.3.tgz",
"integrity": "sha512-yeN+nMSrf/lNii3FJxVVEGQwFG0/2eDyH6gNOj+TGCa0hlNO4bhQnoO5ISnd7JOG+7zTEcI/GOoyraisFVY7jQ==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.3.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz",
"integrity": "sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-php": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz",
"integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/php": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sass": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz",
"integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-css": "^6.2.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/sass": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.9.1.tgz",
"integrity": "sha512-ecSk3gm/mlINcURMcvkCZmXgdzPSq8r/yfCtTB4vgqGGIbBC2IJIAy7GqYTy5pgBEooTVmHP2GZK6Z7h63CDGg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-vue": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz",
"integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-javascript": "^6.1.2",
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.1"
}
},
"node_modules/@codemirror/lang-wast": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz",
"integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-xml": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz",
"integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz",
"integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/language-data": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.1.tgz",
"integrity": "sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w==",
"license": "MIT",
"dependencies": {
"@codemirror/lang-angular": "^0.1.0",
"@codemirror/lang-cpp": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-go": "^6.0.0",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/lang-java": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-less": "^6.0.0",
"@codemirror/lang-liquid": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-php": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/lang-rust": "^6.0.0",
"@codemirror/lang-sass": "^6.0.0",
"@codemirror/lang-sql": "^6.0.0",
"@codemirror/lang-vue": "^0.1.1",
"@codemirror/lang-wast": "^6.0.0",
"@codemirror/lang-xml": "^6.0.0",
"@codemirror/lang-yaml": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/legacy-modes": "^6.4.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz",
"integrity": "sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.8.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz",
"integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -1130,6 +1514,189 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
"license": "MIT"
},
"node_modules/@lezer/cpp": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.3.tgz",
"integrity": "sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/css": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/java": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.1.tgz",
"integrity": "sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
"integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/php": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.4.tgz",
"integrity": "sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.1.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/sass": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz",
"integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/xml": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz",
"integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@microsoft/signalr": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz",
@@ -1889,12 +2456,34 @@
"@types/sizzle": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.16",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
@@ -1926,6 +2515,18 @@
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vavt/copy2clipboard": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@vavt/copy2clipboard/-/copy2clipboard-1.0.3.tgz",
"integrity": "sha512-HtG48r2FBYp9eRvGB3QGmtRBH1zzRRAVvFbGgFstOwz4/DDaNiX0uZc3YVKPydqgOav26pibr9MtoCaWxn7aeA==",
"license": "MIT"
},
"node_modules/@vavt/util": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vavt/util/-/util-2.1.0.tgz",
"integrity": "sha512-YIfAvArSFVXmWvoF+DEGD0FhkhVNcCtVWWkfYtj76eSrwHh/wuEEFhiEubg1XLNM3tChO8FH8xJCT/hnizjgFQ==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz",
@@ -2408,6 +3009,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -2644,6 +3251,21 @@
"fsevents": "~2.3.2"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2656,6 +3278,12 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/complex.js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
@@ -2705,6 +3333,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2743,6 +3377,12 @@
"node": ">= 8"
}
},
"node_modules/cssfilter": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz",
"integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3738,7 +4378,8 @@
"url": "https://github.com/sponsors/lavrton"
}
],
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lightningcss": {
"version": "1.29.2",
@@ -3979,6 +4620,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/local-pkg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.1.tgz",
@@ -4054,6 +4704,47 @@
"dev": true,
"license": "ISC"
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it-image-figures": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/markdown-it-image-figures/-/markdown-it-image-figures-2.1.1.tgz",
"integrity": "sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"markdown-it": "*"
}
},
"node_modules/markdown-it-sub": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-sub/-/markdown-it-sub-2.0.0.tgz",
"integrity": "sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==",
"license": "MIT"
},
"node_modules/markdown-it-sup": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/markdown-it-sup/-/markdown-it-sup-2.0.0.tgz",
"integrity": "sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==",
"license": "MIT"
},
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
@@ -4111,6 +4802,68 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/md-editor-v3": {
"version": "5.8.4",
"resolved": "https://registry.npmjs.org/md-editor-v3/-/md-editor-v3-5.8.4.tgz",
"integrity": "sha512-z7OOvr+Zt86kf0v46L47OHENNzdYeG8tVnfBSQdei7efVs4MWtWJk4ofv1KGutsNUA9q12h9aDZzjELeS+qCog==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-markdown": "^6.3.0",
"@codemirror/language": "^6.11.0",
"@codemirror/language-data": "^6.5.1",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.8",
"@lezer/highlight": "^1.2.1",
"@types/markdown-it": "^14.0.1",
"@vavt/copy2clipboard": "^1.0.1",
"@vavt/util": "^2.1.0",
"codemirror": "^6.0.1",
"lru-cache": "^11.0.1",
"lucide-vue-next": "^0.453.0",
"markdown-it": "^14.0.0",
"markdown-it-image-figures": "^2.1.1",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"medium-zoom": "^1.1.0",
"xss": "^1.0.15"
},
"peerDependencies": {
"vue": "^3.5.3"
}
},
"node_modules/md-editor-v3/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/md-editor-v3/node_modules/lucide-vue-next": {
"version": "0.453.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.453.0.tgz",
"integrity": "sha512-5zmv83vxAs9SVoe22veDBi8Dw0Fh2F+oTngWgKnKOkrZVbZjceXLQ3tescV2boB0zlaf9R2Sd9RuUP2766xvsQ==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/medium-zoom": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/medium-zoom/-/medium-zoom-1.1.0.tgz",
"integrity": "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ==",
"license": "MIT"
},
"node_modules/memorystream": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz",
@@ -4595,6 +5348,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/quansync": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
@@ -4895,6 +5657,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
"license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
@@ -5105,6 +5873,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
@@ -5560,6 +6334,12 @@
"typescript": ">=5.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -5630,6 +6410,22 @@
}
}
},
"node_modules/xss": {
"version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
"integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==",
"license": "MIT",
"dependencies": {
"commander": "^2.20.3",
"cssfilter": "0.0.10"
},
"bin": {
"xss": "bin/xss"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -22,12 +22,12 @@
"axios": "^1.11.0",
"echarts": "^5.6.0",
"highlight.js": "^11.11.1",
"konva": "^9.3.20",
"lodash": "^4.17.21",
"log-symbols": "^7.0.0",
"lucide-vue-next": "^0.525.0",
"marked": "^12.0.0",
"mathjs": "^14.4.0",
"md-editor-v3": "^5.8.4",
"pinia": "^3.0.1",
"reka-ui": "^2.3.1",
"ts-log": "^2.2.7",

View File

@@ -304,7 +304,7 @@ async function generateSignalRClient(): Promise<void> {
console.log("Generating SignalR TypeScript client...");
try {
const { stdout, stderr } = await execAsync(
"dotnet tsrts --project ./server/server.csproj --output ./src/",
"dotnet tsrts --project ./server/server.csproj --output ./src/utils/signalR",
);
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);

View File

@@ -35,57 +35,133 @@ public class NumberTest
}
/// <summary>
/// 测试 BytesToUInt64 的正常与异常情况
/// 测试 BytesToUInt64 的正常与异常情况,覆盖不同参数组合
/// </summary>
[Fact]
public void Test_BytesToUInt64()
{
// 正常大端
// 正常大端isLowNumHigh=false
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 };
var result = Number.BytesToUInt64((byte[])bytes.Clone(), false);
var result = Number.BytesToUInt64((byte[])bytes.Clone());
Assert.True(result.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result.Value);
// 正常小端
// 正常小端isLowNumHigh=true
var bytes2 = new byte[] { 0x01, 0xEF, 0xCD, 0xAB, 0x78, 0x56, 0x34, 0x12 };
var result2 = Number.BytesToUInt64((byte[])bytes2.Clone(), true);
Assert.True(result2.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result2.Value);
// 异常:长度超限
var result3 = Number.BytesToUInt64(new byte[9], false);
Assert.False(result3.IsSuccessful);
// 长度不足8字节numLength=4大端
var bytes3 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result3 = Number.BytesToUInt64((byte[])bytes3.Clone(), 0, 4, false);
Assert.True(result3.IsSuccessful);
Assert.Equal(0x1234567800000000UL, result3.Value);
// 异常:不足8字节
var result4 = Number.BytesToUInt64(new byte[] { 0x01, 0x02 }, false);
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt64 需要8字节
// 长度不足8字节numLength=4小端
var bytes4 = new byte[] { 0x78, 0x56, 0x34, 0x12 };
var result4 = Number.BytesToUInt64((byte[])bytes4.Clone(), 0, 4, true);
Assert.True(result4.IsSuccessful);
Assert.Equal(0x12345678UL, result4.Value);
// numLength=0
var bytes5 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result5 = Number.BytesToUInt64((byte[])bytes5.Clone(), 0, 0, false);
Assert.True(result5.IsSuccessful);
Assert.Equal(0UL, result5.Value);
// offset测试
var bytes6 = new byte[] { 0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 };
var result6 = Number.BytesToUInt64(bytes6, 2, 8, false);
Assert.True(result6.IsSuccessful);
Assert.Equal(0x12345678ABCDEF01UL, result6.Value);
// numLength超限>8应返回异常
var bytes7 = new byte[9];
var result7 = Number.BytesToUInt64(bytes7, 0, 9, false);
Assert.False(result7.IsSuccessful);
// offset+numLength超限
var bytes8 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result8 = Number.BytesToUInt64(bytes8, 2, 4, false);
Assert.True(result8.IsSuccessful);
Assert.Equal(0x0304000000000000UL, result8.Value);
// bytes长度不足offset+numLength
var bytes9 = new byte[] { 0x01, 0x02 };
var result9 = Number.BytesToUInt64(bytes9, 1, 2, true);
Assert.True(result9.IsSuccessful);
Assert.Equal(0x02UL, result9.Value);
// 空数组
var result10 = Number.BytesToUInt64(new byte[0], 0, 0, false);
Assert.True(result10.IsSuccessful);
Assert.Equal(0UL, result10.Value);
}
/// <summary>
/// 测试 BytesToUInt32 的正常与异常情况
/// 测试 BytesToUInt32 的正常与异常情况,覆盖不同参数组合
/// </summary>
[Fact]
public void Test_BytesToUInt32()
{
// 正常大端
// 正常大端isLowNumHigh=false
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result = Number.BytesToUInt32((byte[])bytes.Clone(), false);
var result = Number.BytesToUInt32((byte[])bytes.Clone());
Assert.True(result.IsSuccessful);
Assert.Equal(0x12345678U, result.Value);
// 正常小端
// 正常小端isLowNumHigh=true
var bytes2 = new byte[] { 0x78, 0x56, 0x34, 0x12 };
var result2 = Number.BytesToUInt32((byte[])bytes2.Clone(), true);
Assert.True(result2.IsSuccessful);
Assert.Equal(0x12345678U, result2.Value);
// 异常:长度超限
var result3 = Number.BytesToUInt32(new byte[5], false);
Assert.False(result3.IsSuccessful);
// 长度不足4字节numLength=2大端
var bytes3 = new byte[] { 0x12, 0x34 };
var result3 = Number.BytesToUInt32((byte[])bytes3.Clone(), 0, 2, false);
Assert.True(result3.IsSuccessful);
Assert.Equal(0x12340000U, result3.Value);
// 异常:不足4字节
var result4 = Number.BytesToUInt32(new byte[] { 0x01, 0x02 }, false);
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt32 需要4字节
// 长度不足4字节numLength=2小端
var bytes4 = new byte[] { 0x34, 0x12 };
var result4 = Number.BytesToUInt32((byte[])bytes4.Clone(), 0, 2, true);
Assert.True(result4.IsSuccessful);
Assert.Equal(0x1234U, result4.Value);
// numLength=0
var bytes5 = new byte[] { 0x12, 0x34, 0x56, 0x78 };
var result5 = Number.BytesToUInt32((byte[])bytes5.Clone(), 0, 0, false);
Assert.True(result5.IsSuccessful);
Assert.Equal(0U, result5.Value);
// offset测试
var bytes6 = new byte[] { 0x00, 0x00, 0x12, 0x34, 0x56, 0x78 };
var result6 = Number.BytesToUInt32(bytes6, 2, 4, false);
Assert.True(result6.IsSuccessful);
Assert.Equal(0x12345678U, result6.Value);
// numLength超限>4应返回异常
var bytes7 = new byte[5];
var result7 = Number.BytesToUInt32(bytes7, 0, 5, false);
Assert.False(result7.IsSuccessful);
// offset+numLength超限
var bytes8 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var result8 = Number.BytesToUInt32(bytes8, 2, 2, false);
Assert.True(result8.IsSuccessful);
Assert.Equal(0x03040000U, result8.Value);
// bytes长度不足offset+numLength
var bytes9 = new byte[] { 0x01, 0x02 };
var result9 = Number.BytesToUInt32(bytes9, 1, 1, true);
Assert.True(result9.IsSuccessful);
Assert.Equal(0x02U, result9.Value);
// 空数组
var result10 = Number.BytesToUInt32(new byte[0], 0, 0, false);
Assert.True(result10.IsSuccessful);
Assert.Equal(0U, result10.Value);
}
/// <summary>

View File

@@ -6,7 +6,7 @@ using server.Services;
public class ProgressTrackerTest
{
[Fact]
public async Task Test_ProgressReporter_Basic()
public void Test_ProgressReporter_Basic()
{
int reportedValue = -1;
var reporter = new ProgressReporter(async v => { reportedValue = v; await Task.CompletedTask; }, 0, 100, 10);

3
server/.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Generate
obj
bin
bitstream
bsdl
data

View File

@@ -62,8 +62,40 @@ try
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes("my secret key 1234567890my secret key 1234567890")),
};
options.Authority = $"http://{Global.localhost}:5000";
options.Authority = $"http://{Global.LocalHost}:5000";
options.RequireHttpsMetadata = false;
// We have to hook the OnMessageReceived event in order to
// allow the JWT authentication handler to read the access
// token from the query string when a WebSocket or
// Server-Sent Events request comes in.
// Sending the access token in the query string is required when using WebSockets or ServerSentEvents
// due to a limitation in Browser APIs. We restrict it to only calls to the
// SignalR hub in this code.
// See https://docs.microsoft.com/aspnet/core/signalr/security#access-token-logging
// for more information about security considerations when using
// the query string to transmit the access token.
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
// If the request is for our hub...
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && (
path.StartsWithSegments("/hubs/JtagHub") ||
path.StartsWithSegments("/hubs/ProgressHub") ||
path.StartsWithSegments("/hubs/DigitalTubesHub")
))
{
// Read the token out of the query string
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// Add JWT Token Authorization Policy
builder.Services.AddAuthorization(options =>
@@ -71,7 +103,7 @@ try
options.AddPolicy("Admin", policy =>
{
policy.RequireClaim(ClaimTypes.Role, new string[] {
Database.User.UserPermission.Admin.ToString(),
Database.UserPermission.Admin.ToString(),
});
});
});
@@ -141,7 +173,6 @@ try
options.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer"));
});
// 添加 HTTP 视频流服务
builder.Services.AddSingleton<HttpVideoStreamService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpVideoStreamService>());
@@ -149,8 +180,7 @@ try
builder.Services.AddHostedService(provider => provider.GetRequiredService<HttpHdmiVideoStreamService>());
// 添加进度跟踪服务
builder.Services.AddSingleton<ProgressTrackerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<ProgressTrackerService>());
builder.Services.AddSingleton<ProgressTracker>();
// Application Settings
var app = builder.Build();
@@ -209,7 +239,7 @@ try
settings.PostProcess = (document, httpRequest) =>
{
document.Servers.Clear();
document.Servers.Add(new NSwag.OpenApiServer { Url = $"http://{Global.localhost}:5000" });
document.Servers.Add(new NSwag.OpenApiServer { Url = $"http://{Global.LocalHost}:5000" });
};
});
app.UseSwaggerUi();
@@ -221,18 +251,21 @@ try
// Router
app.MapControllers();
app.MapHub<server.Hubs.JtagHub>("hubs/JtagHub");
app.MapHub<server.Hubs.ProgressHub>("hubs/ProgressHub");
app.MapHub<server.Hubs.JtagHub>("/hubs/JtagHub");
app.MapHub<server.Hubs.ProgressHub>("/hubs/ProgressHub");
app.MapHub<server.Hubs.DigitalTubesHub>("/hubs/DigitalTubesHub");
// Setup Program
MsgBus.Init();
var progressTracker = app.Services.GetRequiredService<ProgressTracker>();
MsgBus.SetProgressTracker(progressTracker);
// Generate API Client
app.MapGet("GetAPIClientCode", async (HttpContext context) =>
{
try
{
var document = await OpenApiDocument.FromUrlAsync($"http://{Global.localhost}:5000/swagger/v1/swagger.json");
var document = await OpenApiDocument.FromUrlAsync($"http://{Global.LocalHost}:5000/swagger/v1/swagger.json");
var settings = new TypeScriptClientGeneratorSettings
{

View File

@@ -11,6 +11,7 @@
<SpaRoot>../</SpaRoot>
<SpaProxyServerUrl>http://localhost:5173</SpaProxyServerUrl>
<SpaProxyLaunchCommand>npm run dev</SpaProxyLaunchCommand>
<NoWarn>CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -30,7 +31,7 @@
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.4.0" />
<PackageReference Include="OpenCvSharp4" Version="4.11.0.20250507" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
<PackageReference Include="Tapper.Analyzer" Version="1.13.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -4,7 +4,8 @@ using System.Net.Sockets;
public static class Global
{
public static readonly string localhost = "127.0.0.1";
public static readonly string LocalHost = "127.0.0.1";
public static readonly string DataPath = Path.Combine(Environment.CurrentDirectory, "data");
public static string GetLocalIPAddress()
{

View File

@@ -72,7 +72,7 @@ public class Image
var b8 = (byte)((b5 * 255) / 31); // 5位扩展到8位
// 存储到 RGB24 数组
var rgb24Index = (i%2 == 0)?((i+1) * 3):((i-1) * 3);
var rgb24Index = (i % 2 == 0) ? ((i + 1) * 3) : ((i - 1) * 3);
rgb24Data[rgb24Index] = r8; // R
rgb24Data[rgb24Index + 1] = g8; // G
rgb24Data[rgb24Index + 2] = b8; // B
@@ -255,13 +255,222 @@ public class Image
return Encoding.ASCII.GetBytes("\r\n");
}
/// <summary>
/// 将原始 JPEG 数据补全 JPEG 头部,生成完整的 JPEG 图片
/// </summary>
/// <param name="jpegData">原始 JPEG 扫描数据(不含头尾)</param>
/// <param name="width">图像宽度</param>
/// <param name="height">图像高度</param>
/// <param name="quantizationTable">量化表数组Y0-Y63, Cb0-Cb63, Cr0-Cr63共192个值</param>
/// <returns>完整的 JPEG 图片数据</returns>
public static Result<byte[]> CompleteJpegData(byte[] jpegData, int width, int height, uint[] quantizationTable)
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
if (width <= 0 || height <= 0)
return new(new ArgumentException("Width and height must be positive"));
if (quantizationTable == null || quantizationTable.Length != 192)
return new(new ArgumentException("Quantization table must contain exactly 192 values (64 Y + 64 Cb + 64 Cr)"));
try
{
var jpegBytes = new List<byte>();
// SOI (Start of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD8 });
// APP0 段
jpegBytes.AddRange(new byte[] {
0xFF, 0xE0, // APP0 marker
0x00, 0x10, // Length (16 bytes)
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, // Version 1.1
0x00, // Units: 0 = no units
0x00, 0x01, // X density (1)
0x00, 0x01, // Y density (1)
0x00, // Thumbnail width
0x00 // Thumbnail height
});
// DQT (Define Quantization Table) - Y table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x00); // Table ID (0 = Y table)
// 添加Y量化表 (quantizationTable[0-63])
for (int i = 0; i < 64; i++)
{
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// DQT (Define Quantization Table) - CbCr table
jpegBytes.AddRange(new byte[] { 0xFF, 0xDB }); // DQT marker
jpegBytes.AddRange(new byte[] { 0x00, 0x43 }); // Length (67 bytes)
jpegBytes.Add(0x01); // Table ID (1 = CbCr table)
// 添加Cb量化表 (quantizationTable[64-127])但这里使用Cr表的数据作为CbCr共用
for (int i = 128; i < 192; i++) // 使用Cr量化表 (quantizationTable[128-191])
{
jpegBytes.Add((byte)Math.Min(255, quantizationTable[i]));
}
// SOF0 (Start of Frame)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC0, // SOF0 marker
0x00, 0x11, // Length (17 bytes)
0x08, // Precision (8 bits)
(byte)((height >> 8) & 0xFF), (byte)(height & 0xFF), // Height
(byte)((width >> 8) & 0xFF), (byte)(width & 0xFF), // Width
0x03, // Number of components
0x01, 0x11, 0x00, // Y component
0x02, 0x11, 0x01, // Cb component
0x03, 0x11, 0x01 // Cr component
});
// DHT (Define Huffman Table) - DC Y table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x00, // Table class and ID (DC table 0)
// DC Y Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// DHT (Define Huffman Table) - AC Y table (简化版)
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x10 // Table class and ID (AC table 0)
});
// AC Y Huffman table数据
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
// DHT (Define Huffman Table) - DC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0x1F, // Length
0x01, // Table class and ID (DC table 1)
// DC CbCr Huffman table
0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
});
// DHT (Define Huffman Table) - AC CbCr table
jpegBytes.AddRange(new byte[] {
0xFF, 0xC4, // DHT marker
0x00, 0xB5, // Length
0x11 // Table class and ID (AC table 1)
});
// AC CbCr Huffman table数据与AC Y table相同
jpegBytes.AddRange(new byte[] {
0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7D,
0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07,
0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0,
0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28,
0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69,
0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7,
0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5,
0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8,
0xF9, 0xFA
});
// SOS (Start of Scan)
jpegBytes.AddRange(new byte[] {
0xFF, 0xDA, // SOS marker
0x00, 0x0C, // Length (12 bytes)
0x03, // Number of components
0x01, 0x00, // Y component, DC/AC table
0x02, 0x11, // Cb component, DC/AC table
0x03, 0x11, // Cr component, DC/AC table
0x00, 0x3F, 0x00 // Start of spectral, End of spectral, Ah/Al
});
// 添加原始 JPEG 扫描数据
jpegBytes.AddRange(jpegData);
// EOI (End of Image)
jpegBytes.AddRange(new byte[] { 0xFF, 0xD9 });
return jpegBytes.ToArray();
}
catch (Exception ex)
{
return new(ex);
}
}
/// <summary>
/// 从 JPEG 数据生成 MJPEG 帧数据
/// </summary>
/// <param name="jpegData">完整的 JPEG 数据</param>
/// <param name="boundary">边界字符串(默认为"--boundary"</param>
/// <returns>MJPEG 帧数据</returns>
public static Result<(byte[] header, byte[] footer, byte[] data)> CreateMjpegFrameFromJpeg(
byte[] jpegData, string boundary = "--boundary")
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
// 验证是否为有效的 JPEG 数据
if (jpegData.Length < 4 || jpegData[0] != 0xFF || jpegData[1] != 0xD8)
{
return new(new ArgumentException("Invalid JPEG data: missing JPEG header"));
}
try
{
var header = CreateMjpegFrameHeader(jpegData.Length, boundary);
var footer = CreateMjpegFrameFooter();
var totalLength = header.Length + jpegData.Length + footer.Length;
var frameData = new byte[totalLength];
var offset = 0;
Array.Copy(header, 0, frameData, offset, header.Length);
offset += header.Length;
Array.Copy(jpegData, 0, frameData, offset, jpegData.Length);
offset += jpegData.Length;
Array.Copy(footer, 0, frameData, offset, footer.Length);
return (header, footer, frameData);
}
catch (Exception ex)
{
return new(ex);
}
}
/// <summary>
/// 创建完整的 MJPEG 帧数据
/// </summary>
/// <param name="jpegData">JPEG数据</param>
/// <param name="boundary">边界字符串(默认为"--boundary"</param>
/// <returns>完整的MJPEG帧数据</returns>
public static Result<byte[]> CreateMjpegFrame(byte[] jpegData, string boundary = "--boundary")
public static Result<(byte[] header, byte[] footer, byte[] data)> CreateMjpegFrame(
byte[] jpegData, string boundary = "--boundary")
{
if (jpegData == null)
return new(new ArgumentNullException(nameof(jpegData)));
@@ -283,7 +492,7 @@ public class Image
Array.Copy(footer, 0, frameData, offset, footer.Length);
return frameData;
return (header, footer, frameData);
}
catch (Exception ex)
{

View File

@@ -78,6 +78,56 @@ public class Number
return arr;
}
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <param name="offset">字节数组偏移量</param>
/// <param name="numLength">字节数组长度</param>
/// <param name="isLowNumHigh">是否高位在数组索引低</param>
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes, int offset = 0, int numLength = 8, bool isLowNumHigh = false)
{
if (bytes.Length < offset)
{
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
if (numLength > 8)
{
return new(new ArgumentException($"The Length of bytes is greater than 8"));
}
if (numLength <= 0) return 0;
try
{
byte[] numBytes = new byte[8]; // 8字节
int copyLen = Math.Min(numLength, bytes.Length - offset);
if (isLowNumHigh)
{
// 小端:拷贝到低位
Buffer.BlockCopy(bytes, offset, numBytes, 0, copyLen);
}
else
{
// 大端:拷贝到高位
byte[] temp = new byte[copyLen];
Buffer.BlockCopy(bytes, offset, temp, 0, copyLen);
Array.Reverse(temp);
Buffer.BlockCopy(temp, 0, numBytes, 8 - copyLen, copyLen);
}
UInt64 num = BitConverter.ToUInt64(numBytes, 0);
return num;
}
catch (Exception error)
{
return new(error);
}
}
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
@@ -86,25 +136,78 @@ public class Number
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes, bool isLowNumHigh = false)
{
if (bytes.Length > 8)
{
return new(new ArgumentException(
"Unsigned long number can't over 8 bytes(64 bits).",
nameof(bytes)
));
return BytesToUInt64(bytes, 0, 8, isLowNumHigh);
}
UInt64 num = 0;
int len = bytes.Length;
/// <summary>
/// 二进制字节数组转成64bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <returns>整数</returns>
public static Result<UInt64> BytesToUInt64(byte[] bytes)
{
return BytesToUInt64(bytes, 0, 8, false);
}
/// <summary>
/// 二进制字节数组转成32bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <param name="offset">字节数组偏移量</param>
/// <param name="numLength">整形所占字节数组长度</param>
/// <param name="isLowNumHigh">是否高位在数组索引低</param>
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes, int offset = 0, int numLength = 4, bool isLowNumHigh = false)
{
if (bytes.Length < offset)
{
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
if (numLength > 4)
{
return new(new ArgumentException($"The Length of bytes is greater than 4"));
}
if (bytes.Length < offset)
{
return new(new ArgumentException($"The Length of bytes is less than offset"));
}
if (numLength > 4)
{
return new(new ArgumentException($"The Length of bytes is greater than 4"));
}
if (numLength <= 0) return 0;
try
{
if (!isLowNumHigh)
{
Array.Reverse(bytes);
}
num = BitConverter.ToUInt64(bytes, 0);
byte[] numBytes = new byte[4]; // 4字节
int copyLen = Math.Min(numLength, bytes.Length - offset);
if (copyLen < 0) copyLen = 0;
if (isLowNumHigh)
{
// 小端:拷贝到低位
if (copyLen > 0)
{
Buffer.BlockCopy(bytes, offset, numBytes, 0, copyLen);
}
}
else
{
// 大端:拷贝到高位
if (copyLen > 0)
{
byte[] temp = new byte[copyLen];
Buffer.BlockCopy(bytes, offset, temp, 0, copyLen);
Array.Reverse(temp);
Buffer.BlockCopy(temp, 0, numBytes, 4 - copyLen, copyLen);
}
}
UInt32 num = BitConverter.ToUInt32(numBytes, 0);
return num;
}
catch (Exception error)
@@ -121,32 +224,17 @@ public class Number
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes, bool isLowNumHigh = false)
{
if (bytes.Length > 4)
{
return new(new ArgumentException(
"Unsigned long number can't over 4 bytes(32 bits).",
nameof(bytes)
));
return BytesToUInt32(bytes, 0, 4, isLowNumHigh);
}
UInt32 num = 0;
int len = bytes.Length;
try
/// <summary>
/// 二进制字节数组转成32bits整数
/// </summary>
/// <param name="bytes">二进制字节数组</param>
/// <returns>整数</returns>
public static Result<UInt32> BytesToUInt32(byte[] bytes)
{
if (!isLowNumHigh)
{
Array.Reverse(bytes);
}
num = BitConverter.ToUInt32(bytes, 0);
return num;
}
catch (Exception error)
{
return new(error);
}
return BytesToUInt32(bytes, 0, 4, false);
}
/// <summary>

View File

@@ -17,40 +17,12 @@ namespace server.Controllers;
public class DataController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager = new();
// 固定的实验板IP,端口,MAC地址
private const string BOARD_IP = "169.254.109.0";
/// <summary>
/// [TODO:description]
/// </summary>
public class UserInfo
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 用户的名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
public required string EMail { get; set; }
/// <summary>
/// 用户关联的板卡ID
/// </summary>
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
public DateTime? BoardExpireTime { get; set; }
}
/// <summary>
/// 获取本机IP地址优先选择与实验板同网段的IP
/// </summary>
@@ -112,8 +84,7 @@ public class DataController : ControllerBase
public IActionResult Login(string name, string password)
{
// 验证用户密码
using var db = new Database.AppDataConnection();
var ret = db.CheckUserPassword(name, password);
var ret = _userManager.CheckUserPassword(name, password);
if (!ret.IsSuccessful) return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
if (!ret.Value.HasValue) return BadRequest("用户名或密码错误");
var user = ret.Value.Value;
@@ -188,8 +159,7 @@ public class DataController : ControllerBase
return Unauthorized("未找到用户名信息");
// Get User Info
using var db = new Database.AppDataConnection();
var ret = db.GetUserByName(userName);
var ret = _userManager.GetUserByName(userName);
if (!ret.IsSuccessful)
return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
@@ -236,8 +206,7 @@ public class DataController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var ret = db.AddUser(name, email, password);
var ret = _userManager.AddUser(name, email, password);
return Ok(ret);
}
catch (Exception ex)
@@ -265,15 +234,14 @@ public class DataController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("未找到用户名信息");
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return BadRequest("用户不存在");
var user = userRet.Value.Value;
var expireTime = DateTime.UtcNow.AddHours(durationHours);
var boardOpt = db.GetAvailableBoard(user.ID, expireTime);
var boardOpt = _userManager.GetAvailableBoard(user.ID, expireTime);
if (!boardOpt.HasValue)
return NotFound("没有可用的实验板");
@@ -309,13 +277,12 @@ public class DataController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("未找到用户名信息");
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return BadRequest("用户不存在");
var user = userRet.Value.Value;
var result = db.UnbindUserFromBoard(user.ID);
var result = _userManager.UnbindUserFromBoard(user.ID);
return Ok(result > 0);
}
catch (Exception ex)
@@ -338,8 +305,7 @@ public class DataController : ControllerBase
{
try
{
using var db = new Database.AppDataConnection();
var ret = db.GetBoardByID(id);
var ret = _userManager.GetBoardByID(id);
if (!ret.IsSuccessful)
return StatusCode(StatusCodes.Status500InternalServerError, "数据库操作失败");
if (!ret.Value.HasValue)
@@ -375,8 +341,7 @@ public class DataController : ControllerBase
return BadRequest("板子名称不能为空");
try
{
using var db = new Database.AppDataConnection();
var ret = db.AddBoard(name);
var ret = _userManager.AddBoard(name);
return Ok(ret);
}
catch (Exception ex)
@@ -402,8 +367,7 @@ public class DataController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var ret = db.DeleteBoardByID(id);
var ret = _userManager.DeleteBoardByID(id);
return Ok(ret);
}
catch (Exception ex)
@@ -425,8 +389,7 @@ public class DataController : ControllerBase
{
try
{
using var db = new Database.AppDataConnection();
var boards = db.GetAllBoard();
var boards = _userManager.GetAllBoard();
return Ok(boards);
}
catch (Exception ex)
@@ -453,8 +416,7 @@ public class DataController : ControllerBase
return BadRequest("新名称不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.UpdateBoardName(boardId, newName);
var result = _userManager.UpdateBoardName(boardId, newName);
return Ok(result);
}
catch (Exception ex)
@@ -473,14 +435,13 @@ public class DataController : ControllerBase
[ProducesResponseType(typeof(int), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult UpdateBoardStatus(Guid boardId, Database.Board.BoardStatus newStatus)
public IActionResult UpdateBoardStatus(Guid boardId, Database.BoardStatus newStatus)
{
if (boardId == Guid.Empty)
return BadRequest("板子Guid不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.UpdateBoardStatus(boardId, newStatus);
var result = _userManager.UpdateBoardStatus(boardId, newStatus);
return Ok(result);
}
catch (Exception ex)
@@ -489,4 +450,54 @@ public class DataController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "更新失败,请稍后重试");
}
}
[HttpPost("AddEmptyBoard")]
[EnableCors("Development")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult AddEmptyBoard()
{
try
{
var boardId = _userManager.AddBoard("Test");
var result = _userManager.UpdateBoardStatus(boardId, Database.BoardStatus.Available);
return Ok();
}
catch (Exception ex)
{
logger.Error(ex, "新增板子时发生异常");
return StatusCode(StatusCodes.Status500InternalServerError, "新增失败,请稍后重试");
}
}
/// <summary>
/// [TODO:description]
/// </summary>
public class UserInfo
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 用户的名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
public required string EMail { get; set; }
/// <summary>
/// 用户关联的板卡ID
/// </summary>
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
public DateTime? BoardExpireTime { get; set; }
}
}

View File

@@ -15,78 +15,7 @@ public class DebuggerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 表示单个信号通道的配置信息
/// </summary>
public class ChannelConfig
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道显示颜色(如前端波形显示用)
/// </summary>
required public string color;
/// <summary>
/// 通道信号线宽度(位数)
/// </summary>
required public UInt32 wireWidth;
/// <summary>
/// 信号线在父端口中的起始索引bit
/// </summary>
required public UInt32 wireStartIndex;
/// <summary>
/// 父端口编号
/// </summary>
required public UInt32 parentPort;
/// <summary>
/// 捕获模式(如上升沿、下降沿等)
/// </summary>
required public CaptureMode mode;
}
/// <summary>
/// 调试器整体配置信息
/// </summary>
public class DebuggerConfig
{
/// <summary>
/// 时钟频率
/// </summary>
required public UInt32 clkFreq;
/// <summary>
/// 总端口数量
/// </summary>
required public UInt32 totalPortNum;
/// <summary>
/// 捕获深度(采样点数)
/// </summary>
required public UInt32 captureDepth;
/// <summary>
/// 触发器数量
/// </summary>
required public UInt32 triggerNum;
/// <summary>
/// 所有信号通道的配置信息
/// </summary>
required public ChannelConfig[] channelConfigs;
}
/// <summary>
/// 单个通道的捕获数据
/// </summary>
public class ChannelCaptureData
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道捕获到的数据Base64编码的UInt32数组
/// </summary>
required public string data;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取当前用户绑定的调试器实例
@@ -99,8 +28,7 @@ public class DebuggerController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -108,7 +36,7 @@ public class DebuggerController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
@@ -464,4 +392,77 @@ public class DebuggerController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <summary>
/// 表示单个信号通道的配置信息
/// </summary>
public class ChannelConfig
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道显示颜色(如前端波形显示用)
/// </summary>
required public string color;
/// <summary>
/// 通道信号线宽度(位数)
/// </summary>
required public UInt32 wireWidth;
/// <summary>
/// 信号线在父端口中的起始索引bit
/// </summary>
required public UInt32 wireStartIndex;
/// <summary>
/// 父端口编号
/// </summary>
required public UInt32 parentPort;
/// <summary>
/// 捕获模式(如上升沿、下降沿等)
/// </summary>
required public CaptureMode mode;
}
/// <summary>
/// 调试器整体配置信息
/// </summary>
public class DebuggerConfig
{
/// <summary>
/// 时钟频率
/// </summary>
required public UInt32 clkFreq;
/// <summary>
/// 总端口数量
/// </summary>
required public UInt32 totalPortNum;
/// <summary>
/// 捕获深度(采样点数)
/// </summary>
required public UInt32 captureDepth;
/// <summary>
/// 触发器数量
/// </summary>
required public UInt32 triggerNum;
/// <summary>
/// 所有信号通道的配置信息
/// </summary>
required public ChannelConfig[] channelConfigs;
}
/// <summary>
/// 单个通道的捕获数据
/// </summary>
public class ChannelCaptureData
{
/// <summary>
/// 通道名称
/// </summary>
required public string name;
/// <summary>
/// 通道捕获到的数据Base64编码的UInt32数组
/// </summary>
required public string data;
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using DotNext;
using Database;
namespace server.Controllers;
@@ -14,128 +15,9 @@ public class ExamController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 实验信息类
/// </summary>
public class ExamInfo
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public required string Description { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}
/// <summary>
/// 实验简要信息类(用于列表显示)
/// </summary>
public class ExamSummary
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}
/// <summary>
/// 创建实验请求类
/// </summary>
public class CreateExamRequest
{
/// <summary>
/// 实验ID
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public required string Description { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}
private readonly ExamManager _examManager = new();
private readonly ResourceManager _resourceManager = new();
private readonly UserManager _userManager = new();
/// <summary>
/// 获取所有实验列表
@@ -144,29 +26,19 @@ public class ExamController : ControllerBase
[Authorize]
[HttpGet("list")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamSummary[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ExamInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetExamList()
{
try
{
using var db = new Database.AppDataConnection();
var exams = db.GetAllExams();
var exams = _examManager.GetAllExams();
var examSummaries = exams.Select(exam => new ExamSummary
{
ID = exam.ID,
Name = exam.Name,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
}).ToArray();
var examInfos = exams.Select(exam => new ExamInfo(exam)).ToArray();
logger.Info($"成功获取实验列表,共 {examSummaries.Length} 个实验");
return Ok(examSummaries);
logger.Info($"成功获取实验列表,共 {examInfos.Length} 个实验");
return Ok(examInfos);
}
catch (Exception ex)
{
@@ -195,8 +67,7 @@ public class ExamController : ControllerBase
try
{
using var db = new Database.AppDataConnection();
var result = db.GetExamByID(examId);
var result = _examManager.GetExamByID(examId);
if (!result.IsSuccessful)
{
@@ -211,17 +82,7 @@ public class ExamController : ControllerBase
}
var exam = result.Value.Value;
var examInfo = new ExamInfo
{
ID = exam.ID,
Name = exam.Name,
Description = exam.Description,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
};
var examInfo = new ExamInfo(exam);
logger.Info($"成功获取实验信息: {examId}");
return Ok(examInfo);
@@ -239,7 +100,7 @@ public class ExamController : ControllerBase
/// <param name="request">创建实验请求</param>
/// <returns>创建结果</returns>
[Authorize("Admin")]
[HttpPost]
[HttpPost("create")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
@@ -247,15 +108,14 @@ public class ExamController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult CreateExam([FromBody] CreateExamRequest request)
public IActionResult CreateExam([FromBody] ExamDto request)
{
if (string.IsNullOrWhiteSpace(request.ID) || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Description))
return BadRequest("实验ID、名称和描述不能为空");
try
{
using var db = new Database.AppDataConnection();
var result = db.CreateExam(request.ID, request.Name, request.Description, request.Tags, request.Difficulty, request.IsVisibleToUsers);
var result = _examManager.CreateExam(request.ID, request.Name, request.Description, request.Tags, request.Difficulty, request.IsVisibleToUsers);
if (!result.IsSuccessful)
{
@@ -267,17 +127,7 @@ public class ExamController : ControllerBase
}
var exam = result.Value;
var examInfo = new ExamInfo
{
ID = exam.ID,
Name = exam.Name,
Description = exam.Description,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
};
var examInfo = new ExamInfo(exam);
logger.Info($"成功创建实验: {request.ID}");
return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo);
@@ -288,4 +138,381 @@ public class ExamController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}");
}
}
/// <summary>
/// 更新实验信息
/// </summary>
/// <param name="request">更新实验请求</param>
/// <returns>更新结果</returns>
[Authorize("Admin")]
[HttpPost("update")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult UpdateExam([FromBody] ExamDto request)
{
var examId = request.ID;
try
{
// 首先检查实验是否存在
var existingExamResult = _examManager.GetExamByID(examId);
if (!existingExamResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {existingExamResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {existingExamResult.Error.Message}");
}
if (!existingExamResult.Value.HasValue)
{
logger.Warn($"要更新的实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 执行更新
var updateResult = _examManager.UpdateExam(
examId,
request.Name,
request.Description,
request.Tags,
request.Difficulty,
request.IsVisibleToUsers
);
if (!updateResult.IsSuccessful)
{
logger.Error($"更新实验时出错: {updateResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {updateResult.Error.Message}");
}
// 获取更新后的实验信息并返回
var updatedExamResult = _examManager.GetExamByID(examId);
if (!updatedExamResult.IsSuccessful || !updatedExamResult.Value.HasValue)
{
logger.Error($"获取更新后的实验信息失败: {examId}");
return StatusCode(StatusCodes.Status500InternalServerError, "更新成功但获取更新后信息失败");
}
var updatedExam = updatedExamResult.Value.Value;
var examInfo = new ExamInfo(updatedExam);
logger.Info($"成功更新实验: {examId},更新记录数: {updateResult.Value}");
return Ok(examInfo);
}
catch (Exception ex)
{
logger.Error($"更新实验 {examId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {ex.Message}");
}
}
/// <summary>
/// 提交作业
/// </summary>
/// <param name="examId">实验ID</param>
/// <param name="file">提交的文件</param>
/// <returns>提交结果</returns>
[Authorize]
[HttpPost("commit/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Commit(string examId, IFormFile file)
{
if (string.IsNullOrWhiteSpace(examId))
return BadRequest("实验ID不能为空");
if (file == null || file.Length == 0)
return BadRequest("文件不能为空");
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查实验是否存在
var examResult = _examManager.GetExamByID(examId);
if (!examResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {examResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {examResult.Error.Message}");
}
if (!examResult.Value.HasValue)
{
logger.Warn($"实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 读取文件内容
byte[] fileData;
using (var memoryStream = new MemoryStream())
{
await file.CopyToAsync(memoryStream);
fileData = memoryStream.ToArray();
}
// 提交作业
var commitResult = _resourceManager.AddResource(
user.ID, ResourceTypes.Compression, ResourcePurpose.Homework,
file.FileName, fileData, examId);
if (!commitResult.IsSuccessful)
{
logger.Error($"提交作业时出错: {commitResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"提交作业失败: {commitResult.Error.Message}");
}
var commit = new ResourceInfo(commitResult.Value);
logger.Info($"用户 {userName} 成功提交实验 {examId} 的作业Commit ID: {commit.ID}");
return CreatedAtAction(nameof(GetCommitsByExamId), new { examId = examId }, commit);
}
catch (Exception ex)
{
logger.Error($"提交实验 {examId} 作业时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"提交作业失败: {ex.Message}");
}
}
/// <summary>
/// 获取用户在指定实验中的提交记录
/// </summary>
/// <param name="examId">实验ID</param>
/// <returns>提交记录列表</returns>
[Authorize]
[HttpGet("commits/{examId}")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetCommitsByExamId(string examId)
{
if (string.IsNullOrWhiteSpace(examId))
return BadRequest("实验ID不能为空");
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查实验是否存在
var examResult = _examManager.GetExamByID(examId);
if (!examResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {examResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {examResult.Error.Message}");
}
if (!examResult.Value.HasValue)
{
logger.Warn($"实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 获取用户的提交记录
var commitsResult = _resourceManager.GetResourceListByType(
ResourceTypes.Compression, ResourcePurpose.Homework, examId);
if (!commitsResult.IsSuccessful)
{
logger.Error($"获取提交记录时出错: {commitsResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {commitsResult.Error.Message}");
}
var commits = commitsResult.Value.Select(x => new ResourceInfo(x)).ToArray();
logger.Info($"成功获取用户 {userName} 在实验 {examId} 中的提交记录,共 {commits.Length} 条");
return Ok(commits);
}
catch (Exception ex)
{
logger.Error($"获取实验 {examId} 提交记录时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {ex.Message}");
}
}
/// <summary>
/// 删除提交记录
/// </summary>
/// <param name="commitId">提交记录ID</param>
/// <returns>删除结果</returns>
[Authorize]
[HttpDelete("commit/{commitId}")]
[EnableCors("Users")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteCommit(Guid commitId)
{
try
{
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 检查是否是管理员
var isAdmin = user.Permission == UserPermission.Admin;
// 如果不是管理员,检查提交记录是否属于当前用户
if (!isAdmin)
{
var commitResult = _resourceManager.GetResourceById(commitId);
if (!commitResult.HasValue)
{
logger.Warn($"提交记录不存在: {commitId}");
return NotFound($"提交记录 {commitId} 不存在");
}
var commit = commitResult.Value;
if (commit.UserID != user.ID)
{
logger.Warn($"用户 {userName} 尝试删除不属于自己的提交记录: {commitId}");
return Forbid("您只能删除自己的提交记录");
}
}
// 执行删除
var deleteResult = _resourceManager.DeleteResource(commitId);
if (!deleteResult)
{
logger.Warn($"提交记录不存在: {commitId}");
return NotFound($"提交记录 {commitId} 不存在");
}
logger.Info($"用户 {userName} 成功删除提交记录: {commitId}");
return Ok($"提交记录 {commitId} 已成功删除");
}
catch (Exception ex)
{
logger.Error($"删除提交记录 {commitId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"删除提交记录失败: {ex.Message}");
}
}
}
/// <summary>
/// 实验信息
/// </summary>
public class ExamInfo
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public string Description { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
public ExamInfo(Exam exam)
{
ID = exam.ID;
Name = exam.Name;
Description = exam.Description;
CreatedTime = exam.CreatedTime;
UpdatedTime = exam.UpdatedTime;
Tags = exam.GetTagsList();
Difficulty = exam.Difficulty;
IsVisibleToUsers = exam.IsVisibleToUsers;
}
}
/// <summary>
/// 统一的实验数据传输对象
/// </summary>
public class ExamDto
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
public required string Description { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}

View File

@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using server.Services;
using Database;
namespace server.Controllers;
@@ -12,9 +11,11 @@ namespace server.Controllers;
[EnableCors("Users")]
public class HdmiVideoStreamController : ControllerBase
{
private readonly HttpHdmiVideoStreamService _videoStreamService;
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly HttpHdmiVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager = new();
public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService)
{
_videoStreamService = videoStreamService;
@@ -40,11 +41,7 @@ public class HdmiVideoStreamController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("User name not found in claims.");
var db = new AppDataConnection();
if (db == null)
return NotFound("Database connection failed.");
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return NotFound("User not found.");
@@ -53,7 +50,7 @@ public class HdmiVideoStreamController : ControllerBase
if (boardId == Guid.Empty)
return NotFound("No board bound to this user.");
var boardRet = db.GetBoardByID(boardId);
var boardRet = _userManager.GetBoardByID(boardId);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return NotFound("Board not found.");
@@ -70,11 +67,7 @@ public class HdmiVideoStreamController : ControllerBase
if (string.IsNullOrEmpty(userName))
return Unauthorized("User name not found in claims.");
var db = new AppDataConnection();
if (db == null)
return NotFound("Database connection failed.");
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return NotFound("User not found.");

View File

@@ -16,15 +16,12 @@ public class JtagController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTrackerService _tracker;
private readonly ProgressTracker _tracker = MsgBus.ProgressTracker;
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
private const string BITSTREAM_PATH = "bitstream/Jtag";
public JtagController(ProgressTrackerService tracker)
{
_tracker = tracker;
}
/// <summary>
/// 控制器首页信息
/// </summary>
@@ -127,6 +124,7 @@ public class JtagController : ControllerBase
/// <param name="address">JTAG 设备地址</param>
/// <param name="port">JTAG 设备端口</param>
/// <param name="bitstreamId">比特流ID</param>
/// <param name="cancelToken">取消令牌</param>
/// <returns>进度跟踪TaskID</returns>
[HttpPost("DownloadBitstream")]
[EnableCors("Users")]
@@ -134,7 +132,7 @@ public class JtagController : ControllerBase
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async ValueTask<IResult> DownloadBitstream(string address, int port, int bitstreamId, CancellationToken cancelToken)
public IResult DownloadBitstream(string address, int port, Guid bitstreamId, CancellationToken cancelToken)
{
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}");
@@ -149,35 +147,33 @@ public class JtagController : ControllerBase
}
// 从数据库获取用户信息
using var db = new Database.AppDataConnection();
var userResult = db.GetUserByName(username);
var userResult = _userManager.GetUserByName(username);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
{
logger.Error($"User {username} not found in database");
return TypedResults.BadRequest("用户不存在");
}
var user = userResult.Value.Value;
// 从数据库获取比特流
var bitstreamResult = db.GetResourceById(bitstreamId);
var user = userResult.Value.Value;
var resourceRet = _resourceManager.GetResourceById(bitstreamId);
if (!bitstreamResult.IsSuccessful)
{
logger.Error($"User {username} failed to get bitstream from database: {bitstreamResult.Error}");
return TypedResults.InternalServerError($"数据库查询失败: {bitstreamResult.Error?.Message}");
}
if (!bitstreamResult.Value.HasValue)
if (!resourceRet.HasValue)
{
logger.Warn($"User {username} attempted to download non-existent bitstream ID: {bitstreamId}");
return TypedResults.BadRequest("比特流不存在");
}
var bitstream = bitstreamResult.Value.Value;
// 处理比特流数据
var fileBytes = bitstream.Data;
var resource = resourceRet.Value;
var bitstreamRet = _resourceManager.ReadBytesFromPath(resource.Path);
if (!bitstreamRet.IsSuccessful)
{
logger.Error($"User {username} failed to read bitstream file: {bitstreamRet.Error}");
return TypedResults.InternalServerError($"比特流读取失败: {bitstreamRet.Error?.Message}");
}
var fileBytes = bitstreamRet.Value;
if (fileBytes == null || fileBytes.Length == 0)
{
logger.Warn($"User {username} found empty bitstream data for ID: {bitstreamId}");
@@ -187,8 +183,8 @@ public class JtagController : ControllerBase
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
// 定义进度跟踪
var (taskId, progress) = _tracker.CreateTask(cancelToken);
progress.Report(10);
var taskId = _tracker.CreateTask(10000);
_tracker.AdvanceProgress(taskId, 10);
_ = Task.Run(async () =>
{
@@ -209,7 +205,8 @@ public class JtagController : ControllerBase
if (!retBuffer.IsSuccessful)
{
logger.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
progress.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
_tracker.FailProgress(taskId,
$"User {username} failed to reverse bytes: {retBuffer.Error}");
return;
}
revBuffer = retBuffer.Value;
@@ -227,21 +224,22 @@ public class JtagController : ControllerBase
var processedBytes = outputStream.ToArray();
logger.Info($"User {username} processed {totalBytesProcessed} bytes for device {address}");
progress.Report(20);
_tracker.AdvanceProgress(taskId, 20);
// 下载比特流
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
var ret = await jtagCtrl.DownloadBitstream(processedBytes);
var ret = await jtagCtrl.DownloadBitstream(processedBytes, taskId);
if (ret.IsSuccessful)
{
logger.Info($"User {username} successfully downloaded bitstream '{bitstream.ResourceName}' to device {address}");
progress.Finish();
logger.Info($"User {username} successfully downloaded bitstream '{resource.ResourceName}' to device {address}");
_tracker.CompleteProgress(taskId);
}
else
{
logger.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
progress.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
_tracker.FailProgress(taskId,
$"User {username} failed to download bitstream to device {address}: {ret.Error}");
}
}
});

View File

@@ -15,57 +15,7 @@ public class LogicAnalyzerController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 信号触发配置
/// </summary>
public class SignalTriggerConfig
{
/// <summary>
/// 信号索引 (0-7)
/// </summary>
public int SignalIndex { get; set; }
/// <summary>
/// 操作符
/// </summary>
public SignalOperator Operator { get; set; }
/// <summary>
/// 信号值
/// </summary>
public SignalValue Value { get; set; }
}
/// <summary>
/// 捕获配置
/// </summary>
public class CaptureConfig
{
/// <summary>
/// 全局触发模式
/// </summary>
public GlobalCaptureMode GlobalMode { get; set; }
/// <summary>
/// 捕获深度
/// </summary>
public int CaptureLength { get; set; } = 2048 * 32;
/// <summary>
/// 预采样深度
/// </summary>
public int PreCaptureLength { get; set; } = 2048;
/// <summary>
/// 有效通道
/// </summary>
public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT;
/// <summary>
/// 时钟分频系数
/// </summary>
public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1;
/// <summary>
/// 信号触发配置列表
/// </summary>
public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>();
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取逻辑分析仪实例
@@ -78,8 +28,7 @@ public class LogicAnalyzerController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -87,7 +36,7 @@ public class LogicAnalyzerController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
@@ -422,4 +371,57 @@ public class LogicAnalyzerController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <summary>
/// 信号触发配置
/// </summary>
public class SignalTriggerConfig
{
/// <summary>
/// 信号索引 (0-7)
/// </summary>
public int SignalIndex { get; set; }
/// <summary>
/// 操作符
/// </summary>
public SignalOperator Operator { get; set; }
/// <summary>
/// 信号值
/// </summary>
public SignalValue Value { get; set; }
}
/// <summary>
/// 捕获配置
/// </summary>
public class CaptureConfig
{
/// <summary>
/// 全局触发模式
/// </summary>
public GlobalCaptureMode GlobalMode { get; set; }
/// <summary>
/// 捕获深度
/// </summary>
public int CaptureLength { get; set; } = 2048 * 32;
/// <summary>
/// 预采样深度
/// </summary>
public int PreCaptureLength { get; set; } = 2048;
/// <summary>
/// 有效通道
/// </summary>
public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT;
/// <summary>
/// 时钟分频系数
/// </summary>
public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1;
/// <summary>
/// 信号触发配置列表
/// </summary>
public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty<SignalTriggerConfig>();
}
}

View File

@@ -21,8 +21,8 @@ public class NetConfigController : ControllerBase
private const int BOARD_PORT = 1234;
// 本机网络信息
private readonly IPAddress _localIP;
private readonly byte[] _localMAC;
private readonly IPAddress _localIP = IPAddress.Any;
private readonly byte[] _localMAC = new byte[6];
private readonly string _localIPString;
private readonly string _localMACString;
private readonly string _localInterface;

View File

@@ -15,72 +15,7 @@ public class OscilloscopeApiController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 示波器完整配置
/// </summary>
public class OscilloscopeFullConfig
{
/// <summary>
/// 是否启动捕获
/// </summary>
public bool CaptureEnabled { get; set; }
/// <summary>
/// 触发电平0-255
/// </summary>
public byte TriggerLevel { get; set; }
/// <summary>
/// 触发边沿true为上升沿false为下降沿
/// </summary>
public bool TriggerRisingEdge { get; set; }
/// <summary>
/// 水平偏移量0-1023
/// </summary>
public ushort HorizontalShift { get; set; }
/// <summary>
/// 抽样率0-1023
/// </summary>
public ushort DecimationRate { get; set; }
/// <summary>
/// 是否自动刷新RAM
/// </summary>
public bool AutoRefreshRAM { get; set; } = true;
}
/// <summary>
/// 示波器状态和数据
/// </summary>
public class OscilloscopeDataResponse
{
/// <summary>
/// AD采样频率
/// </summary>
public uint ADFrequency { get; set; }
/// <summary>
/// AD采样幅度
/// </summary>
public byte ADVpp { get; set; }
/// <summary>
/// AD采样最大值
/// </summary>
public byte ADMax { get; set; }
/// <summary>
/// AD采样最小值
/// </summary>
public byte ADMin { get; set; }
/// <summary>
/// 波形数据Base64编码
/// </summary>
public string WaveformData { get; set; } = string.Empty;
}
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取示波器实例
@@ -93,8 +28,7 @@ public class OscilloscopeApiController : ControllerBase
if (string.IsNullOrEmpty(userName))
return null;
using var db = new Database.AppDataConnection();
var userRet = db.GetUserByName(userName);
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
@@ -102,7 +36,7 @@ public class OscilloscopeApiController : ControllerBase
if (user.BoardID == Guid.Empty)
return null;
var boardRet = db.GetBoardByID(user.BoardID);
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
@@ -481,4 +415,72 @@ public class OscilloscopeApiController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试");
}
}
/// <summary>
/// 示波器完整配置
/// </summary>
public class OscilloscopeFullConfig
{
/// <summary>
/// 是否启动捕获
/// </summary>
public bool CaptureEnabled { get; set; }
/// <summary>
/// 触发电平0-255
/// </summary>
public byte TriggerLevel { get; set; }
/// <summary>
/// 触发边沿true为上升沿false为下降沿
/// </summary>
public bool TriggerRisingEdge { get; set; }
/// <summary>
/// 水平偏移量0-1023
/// </summary>
public ushort HorizontalShift { get; set; }
/// <summary>
/// 抽样率0-1023
/// </summary>
public ushort DecimationRate { get; set; }
/// <summary>
/// 是否自动刷新RAM
/// </summary>
public bool AutoRefreshRAM { get; set; } = true;
}
/// <summary>
/// 示波器状态和数据
/// </summary>
public class OscilloscopeDataResponse
{
/// <summary>
/// AD采样频率
/// </summary>
public uint ADFrequency { get; set; }
/// <summary>
/// AD采样幅度
/// </summary>
public byte ADVpp { get; set; }
/// <summary>
/// AD采样最大值
/// </summary>
public byte ADMax { get; set; }
/// <summary>
/// AD采样最小值
/// </summary>
public byte ADMin { get; set; }
/// <summary>
/// 波形数据Base64编码
/// </summary>
public string WaveformData { get; set; } = string.Empty;
}
}

View File

@@ -15,67 +15,8 @@ public class ResourceController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public int ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public required string Name { get; set; }
/// <summary>
/// 资源类型
/// </summary>
public required string Type { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required string Purpose { get; set; }
/// <summary>
/// 上传时间
/// </summary>
public DateTime UploadTime { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
/// <summary>
/// MIME类型
/// </summary>
public string? MimeType { get; set; }
}
/// <summary>
/// 添加资源请求类
/// </summary>
public class AddResourceRequest
{
/// <summary>
/// 资源类型
/// </summary>
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required string ResourcePurpose { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
}
private readonly UserManager _userManager = new();
private readonly ResourceManager _resourceManager = new();
/// <summary>
/// 添加资源(文件上传)
@@ -93,27 +34,25 @@ public class ResourceController : ControllerBase
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> AddResource([FromForm] AddResourceRequest request, IFormFile file)
{
if (string.IsNullOrWhiteSpace(request.ResourceType) || string.IsNullOrWhiteSpace(request.ResourcePurpose) || file == null)
if (string.IsNullOrWhiteSpace(request.ResourceType) || file == null)
return BadRequest("资源类型、资源用途和文件不能为空");
// 验证资源用途
if (request.ResourcePurpose != Resource.ResourcePurposes.Template && request.ResourcePurpose != Resource.ResourcePurposes.User)
if (request.ResourcePurpose != ResourcePurpose.Template && request.ResourcePurpose != ResourcePurpose.User)
return BadRequest($"无效的资源用途: {request.ResourcePurpose}");
// 模板资源需要管理员权限
if (request.ResourcePurpose == Resource.ResourcePurposes.Template && !User.IsInRole("Admin"))
if (request.ResourcePurpose == ResourcePurpose.Template && !User.IsInRole("Admin"))
return Forbid("只有管理员可以添加模板资源");
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
@@ -124,7 +63,9 @@ public class ResourceController : ControllerBase
await file.CopyToAsync(memoryStream);
var fileData = memoryStream.ToArray();
var result = db.AddResource(user.ID, request.ResourceType, request.ResourcePurpose, file.FileName, fileData, request.ExamID);
var result = _resourceManager.AddResource(
user.ID, request.ResourceType, request.ResourcePurpose,
file.FileName, fileData, request.ExamID);
if (!result.IsSuccessful)
{
@@ -135,20 +76,10 @@ public class ResourceController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {result.Error.Message}");
}
var resource = result.Value;
var resourceInfo = new ResourceInfo
{
ID = resource.ID,
Name = resource.ResourceName,
Type = resource.ResourceType,
Purpose = resource.ResourcePurpose,
UploadTime = resource.UploadTime,
ExamID = resource.ExamID,
MimeType = resource.MimeType
};
var resourceInfo = new ResourceInfo(result.Value);
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resource.ID }, resourceInfo);
logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} ID: {resourceInfo.ID}");
return CreatedAtAction(nameof(GetResourceById), new { resourceId = resourceInfo.ID }, resourceInfo);
}
catch (Exception ex)
{
@@ -170,43 +101,48 @@ public class ResourceController : ControllerBase
[ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceList([FromQuery] string? examId = null, [FromQuery] string? resourceType = null, [FromQuery] string? resourcePurpose = null)
public IActionResult GetResourceList(
[FromQuery] string? examId = null,
[FromQuery] string? resourceType = null,
[FromQuery] ResourcePurpose? resourcePurpose = null)
{
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户ID
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 普通用户只能查看自己的资源和模板资源
Guid? userId = null;
if (!User.IsInRole("Admin"))
Result<List<Resource>> result;
// 管理员
if (user.Permission == UserPermission.Admin)
{
// 如果指定了用户资源用途,则只查看自己的资源
if (resourcePurpose == Resource.ResourcePurposes.User)
{
userId = user.ID;
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose);
}
// 如果指定了模板资源用途则不限制用户ID
else if (resourcePurpose == Resource.ResourcePurposes.Template)
// 用户
else if (resourcePurpose == ResourcePurpose.User)
{
userId = null;
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose, user.ID);
}
// 如果没有指定用途,则查看自己的用户资源和所有模板资源
// 模板
else if (resourcePurpose == ResourcePurpose.Template)
{
result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose);
}
// 其他
else
{
// 这种情况下需要分别查询并合并结果
var userResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.User, user.ID);
var templateResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.Template, null);
var userResourcesResult = _resourceManager.GetFullResourceList(
examId, resourceType, ResourcePurpose.User, user.ID);
var templateResourcesResult = _resourceManager.GetFullResourceList(
examId, resourceType, ResourcePurpose.Template, null);
if (!userResourcesResult.IsSuccessful || !templateResourcesResult.IsSuccessful)
{
@@ -216,23 +152,11 @@ public class ResourceController : ControllerBase
var allResources = userResourcesResult.Value.Concat(templateResourcesResult.Value)
.OrderByDescending(r => r.UploadTime);
var mergedResourceInfos = allResources.Select(r => new ResourceInfo
{
ID = r.ID,
Name = r.ResourceName,
Type = r.ResourceType,
Purpose = r.ResourcePurpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
var mergedResourceInfos = allResources.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源");
return Ok(mergedResourceInfos);
}
}
var result = db.GetFullResourceList(examId, resourceType, resourcePurpose, userId);
if (!result.IsSuccessful)
{
@@ -240,16 +164,7 @@ public class ResourceController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {result.Error.Message}");
}
var resources = result.Value.Select(r => new ResourceInfo
{
ID = r.ID,
Name = r.ResourceName,
Type = r.ResourceType,
Purpose = r.ResourcePurpose,
UploadTime = r.UploadTime,
ExamID = r.ExamID,
MimeType = r.MimeType
}).ToArray();
var resources = result.Value.Select(r => new ResourceInfo(r)).ToArray();
logger.Info($"成功获取资源列表,共 {resources.Length} 个资源");
return Ok(resources);
@@ -272,28 +187,29 @@ public class ResourceController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetResourceById(int resourceId)
public IActionResult GetResourceById(Guid resourceId)
{
try
{
using var db = new Database.AppDataConnection();
var result = db.GetResourceById(resourceId);
var result = _resourceManager.GetResourceById(resourceId);
if (!result.IsSuccessful)
{
logger.Error($"获取资源时出错: {result.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {result.Error.Message}");
}
if (!result.Value.HasValue)
if (!result.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = result.Value.Value;
var resource = result.Value;
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
return File(resource.Data, resource.MimeType ?? "application/octet-stream", resource.ResourceName);
var dataRet = _resourceManager.ReadBytesFromPath(resource.Path);
if (!dataRet.IsSuccessful)
{
logger.Error($"读取资源数据时出错: {dataRet.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"读取资源数据失败: {dataRet.Error.Message}");
}
return File(dataRet.Value, resource.MimeType ?? "application/octet-stream", resource.ResourceName);
}
catch (Exception ex)
{
@@ -315,50 +231,43 @@ public class ResourceController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult DeleteResource(int resourceId)
public IActionResult DeleteResource(Guid resourceId)
{
try
{
using var db = new Database.AppDataConnection();
// 获取当前用户信息
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return Unauthorized("无法获取用户信息");
var userResult = db.GetUserByName(userName);
var userResult = _userManager.GetUserByName(userName);
if (!userResult.IsSuccessful || !userResult.Value.HasValue)
return Unauthorized("用户不存在");
var user = userResult.Value.Value;
// 先获取资源信息以验证权限
var resourceResult = db.GetResourceById(resourceId);
if (!resourceResult.IsSuccessful)
{
logger.Error($"获取资源时出错: {resourceResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {resourceResult.Error.Message}");
}
var resourceResult = _resourceManager.GetResourceById(resourceId);
if (!resourceResult.Value.HasValue)
if (!resourceResult.HasValue)
{
logger.Warn($"资源不存在: {resourceId}");
return NotFound($"资源 {resourceId} 不存在");
}
var resource = resourceResult.Value.Value;
var resource = resourceResult.Value;
// 权限检查:管理员可以删除所有资源,普通用户只能删除自己的用户资源
if (!User.IsInRole("Admin"))
{
if (resource.ResourcePurpose == Resource.ResourcePurposes.Template)
if (resource.Purpose == ResourcePurpose.Template)
return Forbid("普通用户不能删除模板资源");
if (resource.UserID != user.ID)
return Forbid("只能删除自己的资源");
}
var deleteResult = db.DeleteResource(resourceId);
var deleteResult = _resourceManager.DeleteResource(resourceId);
if (!deleteResult.IsSuccessful)
{
logger.Error($"删除资源时出错: {deleteResult.Error.Message}");
@@ -375,3 +284,76 @@ public class ResourceController : ControllerBase
}
}
}
/// <summary>
/// 资源信息类
/// </summary>
public class ResourceInfo
{
/// <summary>
/// 资源ID
/// </summary>
public Guid ID { get; set; }
/// <summary>
/// 资源名称
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 资源类型
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// 资源用途template/user
/// </summary>
public ResourcePurpose Purpose { get; set; }
/// <summary>
/// 上传时间
/// </summary>
public DateTime UploadTime { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
/// <summary>
/// MIME类型
/// </summary>
public string? MimeType { get; set; }
public ResourceInfo(Resource resource)
{
ID = resource.ID;
Name = resource.ResourceName;
Type = resource.ResourceType;
Purpose = resource.Purpose;
UploadTime = resource.UploadTime;
ExamID = resource.ExamID;
MimeType = resource.MimeType;
}
}
/// <summary>
/// 添加资源请求类
/// </summary>
public class AddResourceRequest
{
/// <summary>
/// 资源类型
/// </summary>
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template/user
/// </summary>
public required ResourcePurpose ResourcePurpose { get; set; }
/// <summary>
/// 所属实验ID可选
/// </summary>
public string? ExamID { get; set; }
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Cors;
using Peripherals.SwitchClient;
namespace server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class SwitchController : ControllerBase
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly Database.UserManager _userManager = new();
/// <summary>
/// 获取示波器实例
/// </summary>
private SwitchCtrl? GetSwitchCtrl()
{
var userName = User.Identity?.Name;
if (string.IsNullOrEmpty(userName))
return null;
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
return null;
var user = userRet.Value.Value;
if (user.BoardID == Guid.Empty)
return null;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
return null;
var board = boardRet.Value.Value;
return new SwitchCtrl(board.IpAddr, board.Port, 0);
}
/// <summary>
/// 启用或禁用 Switch 外设
/// </summary>
/// <param name="enable">是否启用</param>
/// <returns>操作结果</returns>
[HttpPost("enable")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetEnable([FromQuery] bool enable)
{
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
var result = await switchCtrl.SetEnable(enable);
if (!result.IsSuccessful)
{
logger.Error(result.Error, "SetEnable failed");
return StatusCode(500, result.Error);
}
return Ok(result.Value);
}
/// <summary>
/// 控制指定编号的 Switch 开关
/// </summary>
/// <param name="num">开关编号</param>
/// <param name="onOff">开/关</param>
/// <returns>操作结果</returns>
[HttpPost("switch")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ArgumentException), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetSwitchOnOff([FromQuery] int num, [FromQuery] bool onOff)
{
if (num <= 0 || num > 6)
return BadRequest(new ArgumentException($"Switch num should be 1~5, instead of {num}"));
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
var result = await switchCtrl.SetSwitchOnOff(num, onOff);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"SetSwitchOnOff({num}, {onOff}) failed");
return StatusCode(500, result.Error);
}
return Ok(result.Value);
}
/// <summary>
/// 控制 Switch 开关
/// </summary>
/// <param name="keyStatus">开关状态</param>
/// <returns>操作结果</returns>
[HttpPost("MultiSwitch")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ArgumentException), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> SetMultiSwitchsOnOff(bool[] keyStatus)
{
if (keyStatus.Length == 0 || keyStatus.Length > 6) return BadRequest(
new ArgumentException($"Switch num should be 1~5, instead of {keyStatus.Length}"));
var switchCtrl = GetSwitchCtrl();
if (switchCtrl == null)
return BadRequest("Can't get user or board info");
for (int i = 0; i < keyStatus.Length; i++)
{
var result = await switchCtrl.SetSwitchOnOff(i + 1, keyStatus[i]);
if (!result.IsSuccessful)
{
logger.Error(result.Error, $"SetSwitchOnOff({i}, {keyStatus[i]}) failed");
return StatusCode(500, result.Error);
}
if (!result.Value) return Ok(false);
}
return Ok(true);
}
}

View File

@@ -1,121 +1,81 @@
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using DotNext;
using server.Services;
/// <summary>
/// 视频流控制器,支持动态配置摄像头连接
/// </summary>
[ApiController]
[Authorize]
[EnableCors("Users")]
[Route("api/[controller]")]
public class VideoStreamController : ControllerBase
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly server.Services.HttpVideoStreamService _videoStreamService;
/// <summary>
/// 视频流信息结构体
/// </summary>
public class StreamInfoResult
private readonly HttpVideoStreamService _videoStreamService;
private readonly Database.UserManager _userManager = new();
public class AvailableResolutionsResponse
{
/// <summary>
/// TODO:
/// </summary>
public int FrameRate { get; set; }
/// <summary>
/// TODO:
/// </summary>
public int FrameWidth { get; set; }
/// <summary>
/// TODO:
/// </summary>
public int FrameHeight { get; set; }
/// <summary>
/// TODO:
/// </summary>
public string Format { get; set; } = "MJPEG";
/// <summary>
/// TODO:
/// </summary>
public string HtmlUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string MjpegUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string SnapshotUrl { get; set; } = "";
/// <summary>
/// TODO:
/// </summary>
public string UsbCameraUrl { get; set; } = "";
}
/// <summary>
/// 摄像头配置请求模型
/// </summary>
public class CameraConfigRequest
{
/// <summary>
/// 摄像头地址
/// </summary>
[Required]
[RegularExpression(@"^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ErrorMessage = "IP地址")]
public string Address { get; set; } = "";
/// <summary>
/// 摄像头端口
/// </summary>
[Required]
[Range(1, 65535, ErrorMessage = "端口必须在1-65535范围内")]
public int Port { get; set; }
}
/// <summary>
/// 分辨率配置请求模型
/// </summary>
public class ResolutionConfigRequest
{
/// <summary>
/// 宽度
/// </summary>
[Required]
[Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")]
public int Width { get; set; }
/// <summary>
/// 高度
/// </summary>
[Required]
[Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")]
public int Height { get; set; }
public string Name { get; set; } = string.Empty;
public string Value => $"{Width}x{Height}";
}
/// <summary>
/// 初始化HTTP视频流控制器
/// </summary>
/// <param name="videoStreamService">HTTP视频流服务</param>
public VideoStreamController(server.Services.HttpVideoStreamService videoStreamService)
public VideoStreamController(HttpVideoStreamService videoStreamService)
{
logger.Info("创建VideoStreamController命名空间{Namespace}", this.GetType().Namespace);
_videoStreamService = videoStreamService;
}
private Optional<string> TryGetBoardId()
{
var userName = User.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name not found in claims.");
return Optional<string>.None;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error("User not found.");
return Optional<string>.None;
}
var user = userRet.Value.Value;
var boardId = user.BoardID;
if (boardId == Guid.Empty)
{
logger.Error("No board bound to this user.");
return Optional<string>.None;
}
return boardId.ToString();
}
/// <summary>
/// 获取 HTTP 视频流服务状态
/// </summary>
/// <returns>服务状态信息</returns>
[HttpGet("Status")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[HttpGet("ServiceStatus")]
[ProducesResponseType(typeof(VideoStreamServiceStatus), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetStatus()
public IResult GetServiceStatus()
{
try
{
logger.Info("GetStatus方法被调用控制器{Controller}路径api/VideoStream/Status", this.GetType().Name);
// 使用HttpVideoStreamService提供的状态信息
var status = _videoStreamService.GetServiceStatus();
@@ -129,101 +89,17 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 获取 HTTP 视频流信息
/// </summary>
/// <returns>流信息</returns>
[HttpGet("StreamInfo")]
[EnableCors("Users")]
[ProducesResponseType(typeof(StreamInfoResult), StatusCodes.Status200OK)]
[HttpGet("MyEndpoint")]
[ProducesResponseType(typeof(VideoStreamEndpoint), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetStreamInfo()
public IResult MyEndpoint()
{
try
{
logger.Info("获取 HTTP 视频流信息");
var result = new StreamInfoResult
{
FrameRate = _videoStreamService.FrameRate,
FrameWidth = _videoStreamService.FrameWidth,
FrameHeight = _videoStreamService.FrameHeight,
Format = "MJPEG",
HtmlUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/video-feed.html",
MjpegUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/video-stream",
SnapshotUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/snapshot",
UsbCameraUrl = $"http://{Global.localhost}:{_videoStreamService.ServerPort}/usb-camera"
};
return TypedResults.Ok(result);
}
catch (Exception ex)
{
logger.Error(ex, "获取 HTTP 视频流信息失败");
return TypedResults.InternalServerError(ex.Message);
}
}
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var endpoint = _videoStreamService.GetVideoEndpoint(boardId);
/// <summary>
/// 配置摄像头连接参数
/// </summary>
/// <param name="config">摄像头配置</param>
/// <returns>配置结果</returns>
[HttpPost("ConfigureCamera")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> ConfigureCamera([FromBody] CameraConfigRequest config)
{
try
{
logger.Info("配置摄像头连接: {Address}:{Port}", config.Address, config.Port);
var success = await _videoStreamService.ConfigureCameraAsync(config.Address, config.Port);
if (success)
{
return TypedResults.Ok(new
{
success = true,
message = "摄像头配置成功",
cameraAddress = config.Address,
cameraPort = config.Port
});
}
else
{
return TypedResults.BadRequest(new
{
success = false,
message = "摄像头配置失败",
cameraAddress = config.Address,
cameraPort = config.Port
});
}
}
catch (Exception ex)
{
logger.Error(ex, "配置摄像头连接失败");
return TypedResults.InternalServerError(ex.Message);
}
}
/// <summary>
/// 获取当前摄像头配置
/// </summary>
/// <returns>摄像头配置信息</returns>
[HttpGet("CameraConfig")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public IResult GetCameraConfig()
{
try
{
logger.Info("获取摄像头配置");
var cameraStatus = _videoStreamService.GetCameraStatus();
return TypedResults.Ok(cameraStatus);
return TypedResults.Ok(endpoint);
}
catch (Exception ex)
{
@@ -232,60 +108,34 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 控制 HTTP 视频流服务开关
/// </summary>
/// <param name="enabled">是否启用服务</param>
/// <returns>操作结果</returns>
[HttpPost("SetEnabled")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> SetEnabled([FromQuery] bool enabled)
{
logger.Info("设置视频流服务开关: {Enabled}", enabled);
await _videoStreamService.SetEnable(enabled);
return TypedResults.Ok();
}
/// <summary>
/// 测试 HTTP 视频流连接
/// </summary>
/// <returns>连接测试结果</returns>
[HttpPost("TestConnection")]
[EnableCors("Users")]
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> TestConnection()
{
try
{
logger.Info("测试 HTTP 视频流连接");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var endpoint = _videoStreamService.GetVideoEndpoint(boardId);
// 尝试通过HTTP请求检查视频流服务是否可访问
bool isConnected = false;
using (var httpClient = new HttpClient())
{
httpClient.Timeout = TimeSpan.FromSeconds(2); // 设置较短的超时时间
var response = await httpClient.GetAsync($"http://{Global.localhost}:{_videoStreamService.ServerPort}/");
var response = await httpClient.GetAsync(endpoint.MjpegUrl);
// 只要能连接上就认为成功,不管返回状态
isConnected = response.IsSuccessStatusCode;
}
logger.Info("测试摄像头连接");
var ret = await _videoStreamService.TestCameraConnection(boardId);
var (isSuccess, message) = await _videoStreamService.TestCameraConnectionAsync();
return TypedResults.Ok(new
{
isConnected = isConnected,
success = isSuccess,
message = message,
cameraAddress = _videoStreamService.CameraAddress,
cameraPort = _videoStreamService.CameraPort,
timestamp = DateTime.Now
});
return TypedResults.Ok(ret);
}
catch (Exception ex)
{
@@ -295,6 +145,25 @@ public class VideoStreamController : ControllerBase
}
}
[HttpPost("SetVideoStreamEnable")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> SetVideoStreamEnable(bool enable)
{
try
{
var boardId = TryGetBoardId().OrThrow(() => new ArgumentException("Board ID is required"));
await _videoStreamService.SetVideoStreamEnableAsync(boardId.ToString(), enable);
return Ok($"HDMI transmission for board {boardId} disabled.");
}
catch (Exception ex)
{
logger.Error(ex, $"Failed to disable HDMI transmission for board");
return StatusCode(500, $"Error disabling HDMI transmission: {ex.Message}");
}
}
/// <summary>
/// 设置视频流分辨率
/// </summary>
@@ -309,16 +178,16 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info($"设置视频流分辨率为 {request.Width}x{request.Height}");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var (isSuccess, message) = await _videoStreamService.SetResolutionAsync(request.Width, request.Height);
var ret = await _videoStreamService.SetResolutionAsync(boardId, request.Width, request.Height);
if (isSuccess)
if (ret.IsSuccessful && ret.Value)
{
return TypedResults.Ok(new
{
success = true,
message = message,
message = $"成功设置分辨率为 {request.Width}x{request.Height}",
width = request.Width,
height = request.Height,
timestamp = DateTime.Now
@@ -329,7 +198,7 @@ public class VideoStreamController : ControllerBase
return TypedResults.BadRequest(new
{
success = false,
message = message,
message = ret.Error?.ToString() ?? "未知错误",
timestamp = DateTime.Now
});
}
@@ -341,71 +210,30 @@ public class VideoStreamController : ControllerBase
}
}
/// <summary>
/// 获取当前分辨率
/// </summary>
/// <returns>当前分辨率信息</returns>
[HttpGet("Resolution")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public IResult GetCurrentResolution()
{
try
{
logger.Info("获取当前视频流分辨率");
var (width, height) = _videoStreamService.GetCurrentResolution();
return TypedResults.Ok(new
{
width = width,
height = height,
resolution = $"{width}x{height}",
timestamp = DateTime.Now
});
}
catch (Exception ex)
{
logger.Error(ex, "获取当前分辨率失败");
return TypedResults.InternalServerError($"获取当前分辨率失败: {ex.Message}");
}
}
/// <summary>
/// 获取支持的分辨率列表
/// </summary>
/// <returns>支持的分辨率列表</returns>
[HttpGet("SupportedResolutions")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(AvailableResolutionsResponse[]), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public IResult GetSupportedResolutions()
{
try
// (640, 480, "640x480 (VGA)"),
// (960, 540, "960x540 (qHD)"),
// (1280, 720, "1280x720 (HD)"),
// (1280, 960, "1280x960 (SXGA)"),
// (1920, 1080, "1920x1080 (Full HD)")
return TypedResults.Ok(new AvailableResolutionsResponse[]
{
logger.Info("获取支持的分辨率列表");
var resolutions = _videoStreamService.GetSupportedResolutions();
return TypedResults.Ok(new
{
resolutions = resolutions.Select(r => new
{
width = r.Width,
height = r.Height,
name = r.Name,
value = $"{r.Width}x{r.Height}"
}),
timestamp = DateTime.Now
new AvailableResolutionsResponse { Width = 640, Height = 480, Name = "640x480(VGA)" },
new AvailableResolutionsResponse { Width = 960, Height = 480, Name = "960x480(qHD)" },
new AvailableResolutionsResponse { Width = 1280, Height = 720, Name = "1280x720(HD)" },
new AvailableResolutionsResponse { Width = 1280, Height = 960, Name = "1280x960(SXGA)" },
new AvailableResolutionsResponse { Width = 1920, Height = 1080, Name = "1920x1080(Full HD)" }
});
}
catch (Exception ex)
{
logger.Error(ex, "获取支持的分辨率列表失败");
return TypedResults.InternalServerError($"获取支持的分辨率列表失败: {ex.Message}");
}
}
/// <summary>
/// 初始化摄像头自动对焦功能
@@ -420,9 +248,9 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info("收到初始化自动对焦请求");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var result = await _videoStreamService.InitAutoFocusAsync();
var result = await _videoStreamService.InitAutoFocusAsync(boardId);
if (result)
{
@@ -465,9 +293,9 @@ public class VideoStreamController : ControllerBase
{
try
{
logger.Info("收到执行自动对焦请求");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
var result = await _videoStreamService.PerformAutoFocusAsync();
var result = await _videoStreamService.PerformAutoFocusAsync(boardId);
if (result)
{
@@ -498,59 +326,55 @@ public class VideoStreamController : ControllerBase
}
/// <summary>
/// 执行一次自动对焦 (GET方式)
/// 配置摄像头连接参数
/// </summary>
/// <returns>对焦结果</returns>
[HttpGet("Focus")]
/// <returns>配置结果</returns>
[HttpPost("ConfigureCamera")]
[EnableCors("Users")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)]
public async Task<IResult> Focus()
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
public async Task<IResult> ConfigureCamera()
{
try
{
logger.Info("收到执行一次对焦请求 (GET)");
var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found"));
// 检查摄像头是否已配置
if (!_videoStreamService.IsCameraConfigured())
{
logger.Warn("摄像头未配置,无法执行对焦");
return TypedResults.BadRequest(new
{
success = false,
message = "摄像头未配置,请先配置摄像头连接",
timestamp = DateTime.Now
});
}
var ret = await _videoStreamService.ConfigureCameraAsync(boardId);
var result = await _videoStreamService.PerformAutoFocusAsync();
if (result)
if (ret)
{
logger.Info("对焦执行成功");
return TypedResults.Ok(new
{
success = true,
message = "对焦执行成功",
timestamp = DateTime.Now
});
return TypedResults.Ok(new { Message = "配置成功" });
}
else
{
logger.Warn("对焦执行失败");
return TypedResults.BadRequest(new
{
success = false,
message = "对焦执行失败",
timestamp = DateTime.Now
});
return TypedResults.BadRequest(new { Message = "配置失败" });
}
}
catch (Exception ex)
{
logger.Error(ex, "执行对焦时发生异常");
return TypedResults.InternalServerError($"执行对焦失败: {ex.Message}");
logger.Error(ex, "配置摄像头连接失败");
return TypedResults.InternalServerError(ex.Message);
}
}
/// <summary>
/// 分辨率配置请求模型
/// </summary>
public class ResolutionConfigRequest
{
/// <summary>
/// 宽度
/// </summary>
[Required]
[Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")]
public int Width { get; set; }
/// <summary>
/// 高度
/// </summary>
[Required]
[Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")]
public int Height { get; set; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
namespace Database;
/// <summary>
/// 应用程序数据连接类,用于与数据库交互
/// </summary>
public class AppDataConnection : DataConnection
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
static readonly string DATABASE_FILEPATH = $"{Global.DataPath}/Database.sqlite";
static readonly LinqToDB.DataOptions options =
new LinqToDB.DataOptions().UseSQLite($"Data Source={DATABASE_FILEPATH}");
/// <summary>
/// 用户表
/// </summary>
public ITable<User> UserTable => this.GetTable<User>();
/// <summary>
/// FPGA 板子表
/// </summary>
public ITable<Board> BoardTable => this.GetTable<Board>();
/// <summary>
/// 实验表
/// </summary>
public ITable<Exam> ExamTable => this.GetTable<Exam>();
/// <summary>
/// 资源表(统一管理实验资源、用户比特流等)
/// </summary>
public ITable<Resource> ResourceTable => this.GetTable<Resource>();
/// <summary>
/// 初始化应用程序数据连接
/// </summary>
public AppDataConnection() : base(options)
{
var filePath = Path.GetDirectoryName(DATABASE_FILEPATH);
if (!string.IsNullOrEmpty(filePath) && !Directory.Exists(filePath))
{
Directory.CreateDirectory(filePath);
}
if (!Path.Exists(DATABASE_FILEPATH))
{
logger.Info($"数据库文件不存在,正在创建新数据库: {DATABASE_FILEPATH}");
LinqToDB.DataProvider.SQLite.SQLiteTools.CreateDatabase(DATABASE_FILEPATH);
this.CreateAllTables();
var user = new User()
{
Name = "Admin",
EMail = "selfconfusion@gmail.com",
Password = "12345678",
Permission = Database.UserPermission.Admin,
};
this.Insert(user);
logger.Info("默认管理员用户已创建");
}
else
{
logger.Info($"数据库连接已建立: {DATABASE_FILEPATH}");
}
}
/// <summary>
/// 创建所有数据库表
/// </summary>
public void CreateAllTables()
{
logger.Info("正在创建数据库表...");
this.CreateTable<User>();
this.CreateTable<Board>();
this.CreateTable<Exam>();
this.CreateTable<Resource>();
logger.Info("数据库表创建完成");
}
/// <summary>
/// 删除所有数据库表
/// </summary>
public void DropAllTables()
{
logger.Warn("正在删除所有数据库表...");
this.DropTable<User>();
this.DropTable<Board>();
this.DropTable<Exam>();
this.DropTable<Resource>();
logger.Warn("所有数据库表已删除");
}
}

View File

@@ -0,0 +1,149 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
namespace Database;
public class ExamManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private AppDataConnection _db = new();
/// <summary>
/// 创建新实验
/// </summary>
/// <param name="id">实验ID</param>
/// <param name="name">实验名称</param>
/// <param name="description">实验描述</param>
/// <param name="tags">实验标签</param>
/// <param name="difficulty">实验难度</param>
/// <param name="isVisibleToUsers">普通用户是否可见</param>
/// <returns>创建的实验</returns>
public Result<Exam> CreateExam(string id, string name, string description, string[]? tags = null, int difficulty = 1, bool isVisibleToUsers = true)
{
try
{
// 检查实验ID是否已存在
var existingExam = _db.ExamTable.Where(e => e.ID.ToString() == id).FirstOrDefault();
if (existingExam != null)
{
logger.Error($"实验ID已存在: {id}");
return new(new Exception($"实验ID已存在: {id}"));
}
var exam = new Exam
{
ID = id,
Name = name,
Description = description,
Difficulty = Math.Max(1, Math.Min(5, difficulty)),
IsVisibleToUsers = isVisibleToUsers,
CreatedTime = DateTime.Now,
UpdatedTime = DateTime.Now
};
if (tags != null)
{
exam.SetTagsList(tags);
}
_db.Insert(exam);
logger.Info($"新实验已创建: {id} ({name})");
return new(exam);
}
catch (Exception ex)
{
logger.Error($"创建实验时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 更新实验信息
/// </summary>
/// <param name="id">实验ID</param>
/// <param name="name">实验名称</param>
/// <param name="description">实验描述</param>
/// <param name="tags">实验标签</param>
/// <param name="difficulty">实验难度</param>
/// <param name="isVisibleToUsers">普通用户是否可见</param>
/// <returns>更新的记录数</returns>
public Result<int> UpdateExam(string id, string? name = null, string? description = null, string[]? tags = null, int? difficulty = null, bool? isVisibleToUsers = null)
{
try
{
int result = 0;
if (name != null)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Name, name).Update();
}
if (description != null)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Description, description).Update();
}
if (tags != null)
{
var tagsString = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Tags, tagsString).Update();
}
if (difficulty.HasValue)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.Difficulty, Math.Max(1, Math.Min(5, difficulty.Value))).Update();
}
if (isVisibleToUsers.HasValue)
{
result += _db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.IsVisibleToUsers, isVisibleToUsers.Value).Update();
}
// 更新时间
_db.ExamTable.Where(e => e.ID.ToString() == id).Set(e => e.UpdatedTime, DateTime.Now).Update();
logger.Info($"实验已更新: {id},更新记录数: {result}");
return new(result);
}
catch (Exception ex)
{
logger.Error($"更新实验时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取所有实验信息
/// </summary>
/// <returns>所有实验的数组</returns>
public Exam[] GetAllExams()
{
var exams = _db.ExamTable.OrderBy(e => e.ID).ToArray();
logger.Debug($"获取所有实验,共 {exams.Length} 个");
return exams;
}
/// <summary>
/// 根据实验ID获取实验信息
/// </summary>
/// <param name="examId">实验ID</param>
/// <returns>包含实验信息的结果,如果未找到则返回空</returns>
public Result<Optional<Exam>> GetExamByID(string examId)
{
var exams = _db.ExamTable.Where(exam => exam.ID.ToString() == examId).ToArray();
if (exams.Length > 1)
{
logger.Error($"数据库中存在多个相同ID的实验: {examId}");
return new(new Exception($"数据库中存在多个相同ID的实验: {examId}"));
}
if (exams.Length == 0)
{
logger.Info($"未找到ID对应的实验: {examId}");
return new(Optional<Exam>.None);
}
logger.Debug($"成功获取实验信息: {examId}");
return new(exams[0]);
}
}

View File

@@ -0,0 +1,348 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
using System.Security.Cryptography;
namespace Database;
public class ResourceManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db = new();
/// <summary>
/// 根据文件扩展名获取MIME类型
/// </summary>
/// <param name="extension">文件扩展名</param>
/// <param name="fileName">文件名(可选,用于特殊文件判断)</param>
/// <returns>MIME类型</returns>
private string GetMimeTypeFromExtension(string extension, string fileName = "")
{
// 特殊文件名处理
if (!string.IsNullOrEmpty(fileName) && fileName.Equals("diagram.json", StringComparison.OrdinalIgnoreCase))
{
return "application/json";
}
return extension.ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".svg" => "image/svg+xml",
".bit" => "application/octet-stream",
".sbit" => "application/octet-stream",
".bin" => "application/octet-stream",
".mcs" => "application/octet-stream",
".hex" => "text/plain",
".json" => "application/json",
".zip" => "application/zip",
".md" => "text/markdown",
_ => "application/octet-stream"
};
}
/// <summary>
/// 将二进制数据写入指定路径
/// </summary>
/// <param name="path">目标文件路径</param>
/// <param name="data">要写入的二进制数据</param>
/// <returns>写入是否成功</returns>
public Result<bool> WriteBytesToPath(string path, byte[] data)
{
try
{
var filePath = Path.Combine(Global.DataPath, path);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllBytes(filePath, data);
logger.Info($"成功写入文件: {filePath},大小: {data.Length} bytes");
return new(true);
}
catch (Exception ex)
{
logger.Error($"写入文件时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 从指定路径读取二进制数据
/// </summary>
/// <param name="path">要读取的文件路径</param>
/// <returns>读取到的二进制数据</returns>
public Result<byte[]> ReadBytesFromPath(string path)
{
try
{
var filePath = Path.Combine(Global.DataPath, path);
if (!File.Exists(filePath))
{
logger.Error($"文件不存在: {filePath}");
return new(new Exception($"文件不存在: {filePath}"));
}
var data = File.ReadAllBytes(filePath);
logger.Info($"成功读取文件: {filePath},大小: {data.Length} bytes");
return new(data);
}
catch (Exception ex)
{
logger.Error($"读取文件时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 添加资源
/// </summary>
/// <param name="userId">上传用户ID</param>
/// <param name="resourceType">资源类型</param>
/// <param name="resourcePurpose">资源用途template 或 user</param>
/// <param name="resourceName">资源名称</param>
/// <param name="data">资源二进制数据</param>
/// <param name="examId">所属实验ID可选</param>
/// <param name="mimeType">MIME类型可选将根据文件扩展名自动确定</param>
/// <returns>创建的资源</returns>
public Result<Resource> AddResource(
Guid userId, string resourceType, ResourcePurpose resourcePurpose,
string resourceName, byte[] data, string? examId = null, string? mimeType = null)
{
try
{
// 验证用户是否存在
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"用户不存在: {userId}");
return new(new Exception($"用户不存在: {userId}"));
}
// 如果指定了实验ID验证实验是否存在
if (!string.IsNullOrEmpty(examId))
{
var exam = _db.ExamTable.Where(e => e.ID.ToString() == examId).FirstOrDefault();
if (exam == null)
{
logger.Error($"实验不存在: {examId}");
return new(new Exception($"实验不存在: {examId}"));
}
}
// 验证资源用途
if (resourcePurpose != ResourcePurpose.Template &&
resourcePurpose != ResourcePurpose.User)
{
logger.Error($"无效的资源用途: {resourcePurpose}");
return new(new Exception($"无效的资源用途: {resourcePurpose}"));
}
// 如果未指定MIME类型根据文件扩展名自动确定
if (string.IsNullOrEmpty(mimeType))
{
var extension = Path.GetExtension(resourceName).ToLowerInvariant();
mimeType = GetMimeTypeFromExtension(extension, resourceName);
}
// 计算数据的SHA256
var sha256 = SHA256.HashData(data).ToString();
if (string.IsNullOrEmpty(sha256))
{
logger.Error($"SHA256计算失败");
return new(new Exception("SHA256计算失败"));
}
var duplicateResource = _db.ResourceTable.Where(r => r.SHA256 == sha256).FirstOrDefault();
if (duplicateResource != null && duplicateResource.ResourceName == resourceName)
{
logger.Info($"资源已存在: {resourceName}, ID: {duplicateResource.ID}, UserID: {duplicateResource.UserID}");
return duplicateResource;
}
var nowTime = DateTime.Now;
var resource = new Resource
{
UserID = userId,
ExamID = examId,
ResourceType = resourceType,
Purpose = resourcePurpose,
ResourceName = resourceName,
Path = duplicateResource == null ?
Path.Combine(resourceType, nowTime.ToString("yyyyMMddHH"), resourceName) :
duplicateResource.Path,
SHA256 = sha256,
MimeType = mimeType,
UploadTime = nowTime
};
var insertedId = _db.Insert(resource);
var writeRet = WriteBytesToPath(resource.Path, data);
if (writeRet.IsSuccessful && writeRet.Value)
{
logger.Info($"新资源已添加: {userId}/{resourceType}/{resourcePurpose}/{resourceName} ({data.Length} bytes)" +
(examId != null ? $" [实验: {examId}]" : "") + $" [ID: {resource.ID}]");
return new(resource);
}
else
{
_db.ResourceTable.Where(r => r.ID == resource.ID).Delete();
logger.Error($"写入资源文件时出错: {writeRet.Error}");
return new(new Exception(writeRet.Error?.ToString() ?? $"写入失败"));
}
}
catch (Exception ex)
{
logger.Error($"添加资源时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取资源信息列表返回ID和名称
/// <param name="resourceType">资源类型</param>
/// <param name="examId">实验ID可选</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// </summary>
/// <returns>资源信息列表</returns>
public Result<Resource[]> GetResourceListByType(
string resourceType,
ResourcePurpose? resourcePurpose = null,
string? examId = null,
Guid? userId = null)
{
try
{
var query = _db.ResourceTable.Where(r => r.ResourceType == resourceType);
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.Purpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.ToArray();
logger.Info($"获取资源列表: {resourceType}" +
(examId != null ? $"/{examId}" : "") +
($"/{resourcePurpose.ToString()}") +
(userId != null ? $"/{userId}" : "") +
$",共 {resources.Length} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 获取完整的资源列表
/// </summary>
/// <param name="examId">实验ID可选</param>
/// <param name="resourceType">资源类型(可选)</param>
/// <param name="resourcePurpose">资源用途(可选)</param>
/// <param name="userId">用户ID可选</param>
/// <returns>完整的资源对象列表</returns>
public Result<List<Resource>> GetFullResourceList(
string? examId = null,
string? resourceType = null,
ResourcePurpose? resourcePurpose = null,
Guid? userId = null)
{
try
{
var query = _db.ResourceTable.AsQueryable();
if (examId != null)
{
query = query.Where(r => r.ExamID == examId);
}
if (resourceType != null)
{
query = query.Where(r => r.ResourceType == resourceType);
}
if (resourcePurpose != null)
{
query = query.Where(r => r.Purpose == resourcePurpose);
}
if (userId != null)
{
query = query.Where(r => r.UserID == userId);
}
var resources = query.OrderByDescending(r => r.UploadTime).ToList();
logger.Info($"获取完整资源列表" +
(examId != null ? $" [实验: {examId}]" : "") +
(resourceType != null ? $" [类型: {resourceType}]" : "") +
($" [用途: {resourcePurpose.ToString()}]") +
(userId != null ? $" [用户: {userId}]" : "") +
$",共 {resources.Count} 个资源");
return new(resources);
}
catch (Exception ex)
{
logger.Error($"获取完整资源列表时出错: {ex.Message}");
return new(ex);
}
}
/// <summary>
/// 根据资源ID获取资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>资源数据</returns>
public Optional<Resource> GetResourceById(Guid resourceId)
{
var resource = _db.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
if (resource == null)
{
logger.Info($"未找到资源: {resourceId}");
return new(null);
}
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
return new(resource);
}
/// <summary>
/// 删除资源
/// </summary>
/// <param name="resourceId">资源ID</param>
/// <returns>删除的记录数</returns>
public Result<int> DeleteResource(Guid resourceId)
{
try
{
var result = _db.ResourceTable.Where(r => r.ID == resourceId).Delete();
logger.Info($"资源已删除: {resourceId},删除记录数: {result}");
return new(result);
}
catch (Exception ex)
{
logger.Error($"删除资源时出错: {ex.Message}");
return new(ex);
}
}
}

350
server/src/Database/Type.cs Normal file
View File

@@ -0,0 +1,350 @@
using DotNext;
using LinqToDB;
using LinqToDB.Mapping;
namespace Database;
/// <summary>
/// 用户权限枚举
/// </summary>
public enum UserPermission
{
/// <summary>
/// 管理员权限,可以管理用户和实验板
/// </summary>
Admin,
/// <summary>
/// 普通用户权限,只能使用实验板
/// </summary>
Normal,
}
/// <summary>
/// 用户类,表示用户信息
/// </summary>
public class User
{
/// <summary>
/// 用户的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// 用户的名称
/// </summary>
[NotNull]
public required string Name { get; set; }
/// <summary>
/// 用户的电子邮箱
/// </summary>
[NotNull]
public required string EMail { get; set; }
/// <summary>
/// 用户的密码(应该进行哈希处理)
/// </summary>
[NotNull]
public required string Password { get; set; }
/// <summary>
/// 用户权限等级
/// </summary>
[NotNull]
public required UserPermission Permission { get; set; }
/// <summary>
/// 绑定的实验板ID如果未绑定则为空
/// </summary>
[Nullable]
public Guid BoardID { get; set; }
/// <summary>
/// 用户绑定板子的过期时间
/// </summary>
[Nullable]
public DateTime? BoardExpireTime { get; set; }
}
/// <summary>
/// FPGA 板子状态枚举
/// </summary>
public enum BoardStatus
{
/// <summary>
/// 未启用状态,无法被使用
/// </summary>
Disabled,
/// <summary>
/// 繁忙状态,正在被用户使用
/// </summary>
Busy,
/// <summary>
/// 可用状态,可以被分配给用户
/// </summary>
Available,
}
/// <summary>
/// FPGA 板子类,表示板子信息
/// </summary>
public class Board
{
/// <summary>
/// FPGA 板子的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// FPGA 板子的名称
/// </summary>
[NotNull]
public required string BoardName { get; set; }
/// <summary>
/// FPGA 板子的IP地址
/// </summary>
[NotNull]
public required string IpAddr { get; set; }
/// <summary>
/// FPGA 板子的MAC地址
/// </summary>
[NotNull]
public required string MacAddr { get; set; }
/// <summary>
/// FPGA 板子的通信端口
/// </summary>
[NotNull]
public int Port { get; set; } = 1234;
/// <summary>
/// FPGA 板子的当前状态
/// </summary>
[NotNull]
public required BoardStatus Status { get; set; }
/// <summary>
/// 占用该板子的用户的唯一标识符
/// </summary>
[Nullable]
public Guid OccupiedUserID { get; set; }
/// <summary>
/// 占用该板子的用户的用户名
/// </summary>
[Nullable]
public string? OccupiedUserName { get; set; }
/// <summary>
/// FPGA 板子的固件版本号
/// </summary>
[NotNull]
public string FirmVersion { get; set; } = "1.0.0";
}
/// <summary>
/// 实验类,表示实验信息
/// </summary>
public class Exam
{
/// <summary>
/// 实验的唯一标识符
/// </summary>
[PrimaryKey]
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
[NotNull]
public required string Name { get; set; }
/// <summary>
/// 实验描述
/// </summary>
[NotNull]
public required string Description { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
[NotNull]
public DateTime CreatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验最后更新时间
/// </summary>
[NotNull]
public DateTime UpdatedTime { get; set; } = DateTime.Now;
/// <summary>
/// 实验标签(以逗号分隔的字符串)
/// </summary>
[NotNull]
public string Tags { get; set; } = "";
/// <summary>
/// 实验难度1-51为最简单
/// </summary>
[NotNull]
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
[NotNull]
public bool IsVisibleToUsers { get; set; } = true;
/// <summary>
/// 获取标签列表
/// </summary>
/// <returns>标签数组</returns>
public string[] GetTagsList()
{
if (string.IsNullOrWhiteSpace(Tags))
return Array.Empty<string>();
return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(tag => tag.Trim())
.Where(tag => !string.IsNullOrEmpty(tag))
.ToArray();
}
/// <summary>
/// 设置标签列表
/// </summary>
/// <param name="tags">标签数组</param>
public void SetTagsList(string[] tags)
{
Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
}
}
/// <summary>
/// 资源类型枚举
/// </summary>
public static class ResourceTypes
{
/// <summary>
/// 图片资源类型
/// </summary>
public const string Images = "images";
/// <summary>
/// Markdown文档资源类型
/// </summary>
public const string Markdown = "markdown";
/// <summary>
/// 比特流文件资源类型
/// </summary>
public const string Bitstream = "bitstream";
/// <summary>
/// 原理图资源类型
/// </summary>
public const string Diagram = "diagram";
/// <summary>
/// 项目文件资源类型
/// </summary>
public const string Project = "project";
/// <summary>
/// 压缩文件资源类型
/// </summary>
public const string Compression = "compression";
}
public enum ResourcePurpose : int
{
/// <summary>
/// 模板资源,通常由管理员上传,供用户参考
/// </summary>
Template,
/// <summary>
/// 用户上传的资源
/// </summary>
User,
/// <summary>
/// 用户提交的作业
/// </summary>
Homework
}
/// <summary>
/// 资源类,统一管理实验资源、用户比特流等各类资源
/// </summary>
public class Resource
{
/// <summary>
/// 资源的唯一标识符
/// </summary>
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
/// <summary>
/// 上传资源的用户ID
/// </summary>
[NotNull]
public required Guid UserID { get; set; }
/// <summary>
/// 所属实验ID可选如果不属于特定实验则为空
/// </summary>
[Nullable]
public string? ExamID { get; set; }
/// <summary>
/// 资源类型images, markdown, bitstream, diagram, project等
/// </summary>
[NotNull]
public required string ResourceType { get; set; }
/// <summary>
/// 资源用途template模板或 user用户上传
/// </summary>
[NotNull]
public required ResourcePurpose Purpose { get; set; }
/// <summary>
/// 资源名称(包含文件扩展名)
/// </summary>
[NotNull]
public required string ResourceName { get; set; }
/// <summary>
/// 资源路径(包含文件名和扩展名)
/// </summary>
[NotNull]
public required string Path { get; set; }
/// <summary>
/// 资源SHA256哈希值
/// </summary>
[NotNull]
public required string SHA256 { get; set; }
/// <summary>
/// 资源创建/上传时间
/// </summary>
[NotNull]
public DateTime UploadTime { get; set; } = DateTime.Now;
/// <summary>
/// 资源的MIME类型
/// </summary>
[NotNull]
public string MimeType { get; set; } = "application/octet-stream";
}

View File

@@ -0,0 +1,453 @@
using DotNext;
using LinqToDB;
using LinqToDB.Data;
namespace Database;
public class UserManager
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly AppDataConnection _db = new();
/// <summary>
/// 添加一个新的用户到数据库
/// </summary>
/// <param name="name">用户的名称</param>
/// <param name="email">用户的电子邮箱地址</param>
/// <param name="password">用户的密码</param>
/// <returns>插入的记录数</returns>
public int AddUser(string name, string email, string password)
{
var user = new User()
{
Name = name,
EMail = email,
Password = password,
Permission = UserPermission.Normal,
};
var result = _db.Insert(user);
logger.Info($"新用户已添加: {name} ({email})");
return result;
}
/// <summary>
/// 根据用户名获取用户信息
/// </summary>
/// <param name="name">用户名</param>
/// <returns>包含用户信息的结果,如果未找到或出错则返回相应状态</returns>
public Result<Optional<User>> GetUserByName(string name)
{
var user = _db.UserTable.Where((user) => user.Name == name).ToArray();
if (user.Length > 1)
{
logger.Error($"数据库中存在多个同名用户: {name}");
return new(new Exception($"数据库中存在多个同名用户: {name}"));
}
if (user.Length == 0)
{
logger.Info($"未找到用户: {name}");
return new(Optional<User>.None);
}
logger.Debug($"成功获取用户信息: {name}");
return new(user[0]);
}
/// <summary>
/// 根据电子邮箱获取用户信息
/// </summary>
/// <param name="email">用户的电子邮箱地址</param>
/// <returns>包含用户信息的结果,如果未找到或出错则返回相应状态</returns>
public Result<Optional<User>> GetUserByEMail(string email)
{
var user = _db.UserTable.Where((user) => user.EMail == email).ToArray();
if (user.Length > 1)
{
logger.Error($"数据库中存在多个相同邮箱的用户: {email}");
return new(new Exception($"数据库中存在多个相同邮箱的用户: {email}"));
}
if (user.Length == 0)
{
logger.Info($"未找到邮箱对应的用户: {email}");
return new(Optional<User>.None);
}
logger.Debug($"成功获取用户信息: {email}");
return new(user[0]);
}
/// <summary>
/// 验证用户密码
/// </summary>
/// <param name="name">用户名</param>
/// <param name="password">用户密码</param>
/// <returns>如果密码正确返回用户信息,否则返回空</returns>
public Result<Optional<User>> CheckUserPassword(string name, string password)
{
var ret = GetUserByName(name);
if (!ret.IsSuccessful)
return new(ret.Error);
if (!ret.Value.HasValue)
return new(Optional<User>.None);
var user = ret.Value.Value;
if (user.Password == password)
{
logger.Info($"用户 {name} 密码验证成功");
return new(user);
}
else
{
logger.Warn($"用户 {name} 密码验证失败");
return new(Optional<User>.None);
}
}
/// <summary>
/// 绑定用户与实验板
/// </summary>
/// <param name="userId">用户的唯一标识符</param>
/// <param name="boardId">实验板的唯一标识符</param>
/// <param name="expireTime">绑定过期时间</param>
/// <returns>更新的记录数</returns>
public int BindUserToBoard(Guid userId, Guid boardId, DateTime expireTime)
{
// 获取用户信息
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"未找到用户: {userId}");
return 0;
}
// 更新用户的板子绑定信息
var userResult = _db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, boardId)
.Set(u => u.BoardExpireTime, expireTime)
.Update();
// 更新板子的用户绑定信息
var boardResult = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, BoardStatus.Busy)
.Set(b => b.OccupiedUserID, userId)
.Set(b => b.OccupiedUserName, user.Name)
.Update();
logger.Info($"用户 {userId} ({user.Name}) 已绑定到实验板 {boardId},过期时间: {expireTime}");
return userResult + boardResult;
}
/// <summary>
/// 解除用户与实验板的绑定
/// </summary>
/// <param name="userId">用户的唯一标识符</param>
/// <returns>更新的记录数</returns>
public int UnbindUserFromBoard(Guid userId)
{
// 获取用户当前绑定的板子ID
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
Guid boardId = user?.BoardID ?? Guid.Empty;
// 清空用户的板子绑定信息
var userResult = _db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
.Update();
// 如果用户原本绑定了板子,则清空板子的用户绑定信息
int boardResult = 0;
if (boardId != Guid.Empty)
{
boardResult = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, BoardStatus.Available)
.Set(b => b.OccupiedUserID, Guid.Empty)
.Set(b => b.OccupiedUserName, (string?)null)
.Update();
logger.Info($"实验板 {boardId} 状态已设置为空闲,用户绑定信息已清空");
}
logger.Info($"用户 {userId} 已解除实验板绑定");
return userResult + boardResult;
}
/// <summary>
/// 自动分配一个未被占用的IP地址
/// </summary>
/// <returns>分配的IP地址字符串</returns>
public string AllocateIpAddr()
{
var usedIps = _db.BoardTable.Select(b => b.IpAddr).ToArray();
for (int i = 1; i <= 254; i++)
{
string ip = $"169.254.109.{i}";
if (!usedIps.Contains(ip))
return ip;
}
throw new Exception("没有可用的IP地址");
}
/// <summary>
/// 自动分配一个未被占用的MAC地址
/// </summary>
/// <returns>分配的MAC地址字符串</returns>
public string AllocateMacAddr()
{
var usedMacs = _db.BoardTable.Select(b => b.MacAddr).ToArray();
// 以 02-00-00-xx-xx-xx 格式分配02 表示本地管理地址
for (int i = 1; i <= 0xFFFFFF; i++)
{
string mac = $"02-00-00-{(i >> 16) & 0xFF:X2}-{(i >> 8) & 0xFF:X2}-{i & 0xFF:X2}";
if (!usedMacs.Contains(mac))
return mac;
}
throw new Exception("没有可用的MAC地址");
}
/// <summary>
/// 添加一块新的 FPGA 板子到数据库
/// </summary>
/// <param name="name">FPGA 板子的名称</param>
/// <returns>插入的记录数</returns>
public Guid AddBoard(string name)
{
if (string.IsNullOrWhiteSpace(name) || name.Contains('\'') || name.Contains(';'))
{
logger.Error("实验板名称非法,包含不允许的字符");
throw new ArgumentException("实验板名称非法");
}
var board = new Board()
{
BoardName = name,
IpAddr = AllocateIpAddr(),
MacAddr = AllocateMacAddr(),
Status = BoardStatus.Disabled,
};
var result = _db.Insert(board);
logger.Info($"新实验板已添加: {name} ({board.IpAddr}:{board.MacAddr})");
return board.ID;
}
/// <summary>
/// 根据名称删除实验板
/// </summary>
/// <param name="name">实验板的名称</param>
/// <returns>删除的记录数</returns>
public int DeleteBoardByName(string name)
{
// 先获取要删除的板子信息
var board = _db.BoardTable.Where(b => b.BoardName == name).FirstOrDefault();
if (board == null)
{
logger.Warn($"未找到名称为 {name} 的实验板");
return 0;
}
// 如果板子被占用,先解除绑定
if (board.OccupiedUserID != Guid.Empty)
{
_db.UserTable
.Where(u => u.ID == board.OccupiedUserID)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
.Update();
logger.Info($"已解除用户 {board.OccupiedUserID} 与实验板 {name} 的绑定");
}
var result = _db.BoardTable.Where(b => b.BoardName == name).Delete();
logger.Info($"实验板已删除: {name},删除记录数: {result}");
return result;
}
/// <summary>
/// 根据ID删除实验板
/// </summary>
/// <param name="id">实验板的唯一标识符</param>
/// <returns>删除的记录数</returns>
public int DeleteBoardByID(Guid id)
{
// 先获取要删除的板子信息
var board = _db.BoardTable.Where(b => b.ID == id).FirstOrDefault();
if (board == null)
{
logger.Warn($"未找到ID为 {id} 的实验板");
return 0;
}
// 如果板子被占用,先解除绑定
if (board.OccupiedUserID != Guid.Empty)
{
_db.UserTable
.Where(u => u.ID == board.OccupiedUserID)
.Set(u => u.BoardID, Guid.Empty)
.Set(u => u.BoardExpireTime, (DateTime?)null)
.Update();
logger.Info($"已解除用户 {board.OccupiedUserID} 与实验板 {id} 的绑定");
}
var result = _db.BoardTable.Where(b => b.ID == id).Delete();
logger.Info($"实验板已删除: {id},删除记录数: {result}");
return result;
}
/// <summary>
/// 根据实验板ID获取实验板信息
/// </summary>
/// <param name="id">实验板的唯一标识符</param>
/// <returns>包含实验板信息的结果,如果未找到则返回空</returns>
public Result<Optional<Board>> GetBoardByID(Guid id)
{
var boards = _db.BoardTable.Where(board => board.ID == id).ToArray();
if (boards.Length > 1)
{
logger.Error($"数据库中存在多个相同ID的实验板: {id}");
return new(new Exception($"数据库中存在多个相同ID的实验板: {id}"));
}
if (boards.Length == 0)
{
logger.Info($"未找到ID对应的实验板: {id}");
return new(Optional<Board>.None);
}
logger.Debug($"成功获取实验板信息: {id}");
return new(boards[0]);
}
/// <summary>
/// 根据用户名获取实验板信息
/// </summary>
/// <param name="userName">用户名</param>
/// <returns>包含实验板信息的结果,如果未找到则返回空</returns>
public Result<Optional<Board>> GetBoardByUserName(string userName)
{
var boards = _db.BoardTable.Where(board => board.OccupiedUserName == userName).ToArray();
if (boards.Length > 1)
{
logger.Error($"数据库中存在多个相同用户名的实验板: {userName}");
return new(new Exception($"数据库中存在多个相同用户名的实验板: {userName}"));
}
if (boards.Length == 0)
{
logger.Info($"未找到用户名对应的实验板: {userName}");
return new(Optional<Board>.None);
}
logger.Debug($"成功获取实验板信息: {userName}");
return new(boards[0]);
}
/// <summary>
/// 获取所有实验板信息
/// </summary>
/// <returns>所有实验板的数组</returns>
public Board[] GetAllBoard()
{
var boards = _db.BoardTable.ToArray();
logger.Debug($"获取所有实验板,共 {boards.Length} 块");
return boards;
}
/// <summary>
/// 获取一块可用的实验板并将其状态设置为繁忙
/// </summary>
/// <param name="userId">要分配板子的用户ID</param>
/// <param name="expireTime">绑定过期时间</param>
/// <returns>可用的实验板,如果没有可用的板子则返回空</returns>
public Optional<Board> GetAvailableBoard(Guid userId, DateTime expireTime)
{
var boards = _db.BoardTable.Where(
(board) => board.Status == BoardStatus.Available
).ToArray();
if (boards.Length == 0)
{
logger.Warn("没有可用的实验板");
return new(null);
}
else
{
var board = boards[0];
var user = _db.UserTable.Where(u => u.ID == userId).FirstOrDefault();
if (user == null)
{
logger.Error($"未找到用户: {userId}");
return new(null);
}
// 更新板子状态和用户绑定信息
_db.BoardTable
.Where(target => target.ID == board.ID)
.Set(target => target.Status, BoardStatus.Busy)
.Set(target => target.OccupiedUserID, userId)
.Set(target => target.OccupiedUserName, user.Name)
.Update();
// 更新用户的板子绑定信息
_db.UserTable
.Where(u => u.ID == userId)
.Set(u => u.BoardID, board.ID)
.Set(u => u.BoardExpireTime, expireTime)
.Update();
board.Status = BoardStatus.Busy;
board.OccupiedUserID = userId;
board.OccupiedUserName = user.Name;
logger.Info($"实验板 {board.BoardName} ({board.ID}) 已分配给用户 {user.Name} ({userId}),过期时间: {expireTime}");
return new(board);
}
}
/// <summary>
/// [TODO:description]
/// </summary>
/// <param name="boardId">[TODO:parameter]</param>
/// <param name="newName">[TODO:parameter]</param>
/// <returns>[TODO:return]</returns>
public int UpdateBoardName(Guid boardId, string newName)
{
if (string.IsNullOrWhiteSpace(newName) || newName.Contains('\'') || newName.Contains(';'))
{
logger.Error("实验板名称非法,包含不允许的字符");
return 0;
}
var result = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.BoardName, newName)
.Update();
logger.Info($"实验板名称已更新: {boardId} -> {newName}");
return result;
}
/// <summary>
/// [TODO:description]
/// </summary>
/// <param name="boardId">[TODO:parameter]</param>
/// <param name="newStatus">[TODO:parameter]</param>
/// <returns>[TODO:return]</returns>
public int UpdateBoardStatus(Guid boardId, BoardStatus newStatus)
{
var result = _db.BoardTable
.Where(b => b.ID == boardId)
.Set(b => b.Status, newStatus)
.Update();
logger.Info($"TODO");
return result;
}
}

View File

@@ -0,0 +1,257 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using DotNext;
using Peripherals.SevenDigitalTubesClient;
using System.Collections.Concurrent;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IDigitalTubesHub
{
Task<bool> StartScan();
Task<bool> StopScan();
Task<bool> SetFrequency(int frequency);
Task<DigitalTubeTaskStatus?> GetStatus();
}
[Receiver]
public interface IDigitalTubesReceiver
{
Task OnReceive(byte[] data);
}
[TranspilationSource]
public class DigitalTubeTaskStatus
{
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
public DigitalTubeTaskStatus(ScanTaskInfo info)
{
Frequency = info.Frequency;
IsRunning = info.IsRunning;
}
}
public class ScanTaskInfo
{
public string BoardID { get; set; }
public string ClientID { get; set; }
public Task? ScanTask { get; set; }
public SevenDigitalTubesCtrl TubeClient { get; set; }
public CancellationTokenSource CTS { get; set; } = new();
public int Frequency { get; set; } = 100;
public bool IsRunning { get; set; } = false;
public ScanTaskInfo(
string boardID, string clientID, SevenDigitalTubesCtrl client)
{
BoardID = boardID;
ClientID = clientID;
TubeClient = client;
}
}
[Authorize]
[EnableCors("SignalR")]
public class DigitalTubesHub : Hub<IDigitalTubesReceiver>, IDigitalTubesHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<DigitalTubesHub, IDigitalTubesReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private ConcurrentDictionary<(string, string), ScanTaskInfo> _scanTasks = new();
public DigitalTubesHub(IHubContext<DigitalTubesHub, IDigitalTubesReceiver> hubContext)
{
_hubContext = hubContext;
}
private Optional<Database.Board> TryGetBoard()
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (string.IsNullOrEmpty(userName))
{
logger.Error("User name is null or empty");
return null;
}
var userRet = _userManager.GetUserByName(userName);
if (!userRet.IsSuccessful || !userRet.Value.HasValue)
{
logger.Error($"User '{userName}' not found");
return null;
}
var user = userRet.Value.Value;
var boardRet = _userManager.GetBoardByID(user.BoardID);
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Board not found");
return null;
}
return boardRet.Value.Value;
}
private Task ScanAllTubes(ScanTaskInfo scanInfo)
{
var token = scanInfo.CTS.Token;
return Task.Run(async () =>
{
var cntError = 0;
while (!token.IsCancellationRequested)
{
var beginTime = DateTime.Now;
var waitTime = TimeSpan.FromMilliseconds(1000 / scanInfo.Frequency);
var dataRet = await scanInfo.TubeClient.ScanAllTubes();
if (!dataRet.IsSuccessful)
{
logger.Error($"Failed to scan tubes: {dataRet.Error}");
cntError++;
if (cntError > 3)
{
logger.Error($"Too many errors, stopping scan");
break;
}
}
await _hubContext.Clients.Client(scanInfo.ClientID).OnReceive(dataRet.Value);
var processTime = DateTime.Now - beginTime;
if (processTime < waitTime)
{
await Task.Delay(waitTime - processTime, token);
}
}
scanInfo.IsRunning = false;
}, token)
.ContinueWith((task) =>
{
if (task.IsFaulted)
{
logger.Error(
$"Digital tubes scan operation failesj for board {task.Exception}");
}
else if (task.IsCanceled)
{
logger.Info(
$"Digital tubes scan operation cancelled for board {scanInfo.BoardID}");
}
else
{
logger.Info(
$"Digital tubes scan completed successfully for board {scanInfo.BoardID}");
}
});
}
public async Task<bool> StartScan()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_scanTasks.TryGetValue(key, out var existing) && existing.IsRunning)
return true;
var cts = new CancellationTokenSource();
var scanTaskInfo = new ScanTaskInfo(
board.ID.ToString(), Context.ConnectionId,
new SevenDigitalTubesCtrl(board.IpAddr, board.Port, 0)
);
scanTaskInfo.ScanTask = ScanAllTubes(scanTaskInfo);
_scanTasks[key] = scanTaskInfo;
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to start scan");
return false;
}
}
public async Task<bool> StopScan()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_scanTasks.TryRemove(key, out var scanInfo))
{
scanInfo.IsRunning = false;
scanInfo.CTS.Cancel();
if (scanInfo.ScanTask != null)
await scanInfo.ScanTask;
scanInfo.CTS.Dispose();
}
return true;
}
catch (Exception ex)
{
logger.Error(ex, "Failed to stop scan");
return false;
}
}
public async Task<bool> SetFrequency(int frequency)
{
try
{
if (frequency < 1 || frequency > 1000)
return false;
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_scanTasks.TryGetValue(key, out var scanInfo) && scanInfo.IsRunning)
{
scanInfo.Frequency = frequency;
return true;
}
else
{
logger.Warn($"SetFrequency called but no running scan for board {board.ID} and client {Context.ConnectionId}");
return false;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to set frequency");
return false;
}
}
public async Task<DigitalTubeTaskStatus?> GetStatus()
{
try
{
var board = TryGetBoard().OrThrow(() => new Exception("Board not found"));
var key = (board.ID.ToString(), Context.ConnectionId);
if (_scanTasks.TryGetValue(key, out var scanInfo))
{
return new DigitalTubeTaskStatus(scanInfo);
}
else
{
return null;
}
}
catch (Exception ex)
{
logger.Error(ex, "Failed to get status");
throw new Exception("Failed to get status", ex);
}
}
}

View File

@@ -28,10 +28,12 @@ public interface IJtagReceiver
public class JtagHub : Hub<IJtagReceiver>, IJtagHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private static ConcurrentDictionary<string, int> FreqTable = new();
private static ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
private readonly IHubContext<JtagHub, IJtagReceiver> _hubContext;
private readonly Database.UserManager _userManager = new();
private static ConcurrentDictionary<string, int> FreqTable = new();
private static ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
public JtagHub(IHubContext<JtagHub, IJtagReceiver> hubContext)
{
@@ -42,8 +44,7 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
{
try
{
using var db = new Database.AppDataConnection();
var board = db.GetBoardByUserName(userName);
var board = _userManager.GetBoardByUserName(userName);
if (!board.IsSuccessful)
{
logger.Error($"Find Board {board.Value.Value.ID} failed because {board.Error}");
@@ -66,6 +67,8 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
}
public async Task<bool> SetBoundaryScanFreq(int freq)
{
return await Task.Run(() =>
{
try
{
@@ -84,6 +87,7 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
logger.Error(error);
return false;
}
});
}
public async Task<bool> StartBoundaryScan(int freq = 100)
@@ -142,6 +146,8 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
}
public async Task<bool> StopBoundaryScan()
{
return await Task.Run(() =>
{
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
if (userName is null)
@@ -160,6 +166,8 @@ public class JtagHub : Hub<IJtagReceiver>, IJtagHub
logger.Info($"Boundary scan stopped for user {userName}");
return true;
});
}
private async Task BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)

View File

@@ -1,17 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.Cors;
using TypedSignalR.Client;
using Tapper;
using server.Services;
#pragma warning disable 1998
namespace server.Hubs;
[Hub]
public interface IProgressHub
{
Task<bool> Join(string taskId);
Task<bool> Leave(string taskId);
Task<ProgressInfo?> GetProgress(string taskId);
}
[Receiver]
@@ -23,8 +26,7 @@ public interface IProgressReceiver
[TranspilationSource]
public enum ProgressStatus
{
Pending,
InProgress,
Running,
Completed,
Canceled,
Failed
@@ -33,10 +35,10 @@ public enum ProgressStatus
[TranspilationSource]
public class ProgressInfo
{
public string TaskId { get; }
public ProgressStatus Status { get; }
public int ProgressPercent { get; }
public string ErrorMessage { get; }
public required string TaskId { get; set; }
public required ProgressStatus Status { get; set; }
public required double ProgressPercent { get; set; }
public required string ErrorMessage { get; set; }
};
[Authorize]
@@ -44,18 +46,32 @@ public class ProgressInfo
public class ProgressHub : Hub<IProgressReceiver>, IProgressHub
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
private readonly ProgressTrackerService _tracker;
public ProgressHub(IHubContext<ProgressHub, IProgressReceiver> hubContext, ProgressTrackerService tracker)
{
_hubContext = hubContext;
_tracker = tracker;
}
private readonly ProgressTracker _progressTracker = MsgBus.ProgressTracker;
public async Task<bool> Join(string taskId)
{
return _tracker.BindTask(taskId, Context.ConnectionId);
await Groups.AddToGroupAsync(Context.ConnectionId, taskId);
// 发送当前状态(如果存在)
var task = _progressTracker.GetTask(taskId);
if (task != null)
{
await Clients.Caller.OnReceiveProgress(task.Value.ToProgressInfo());
}
logger.Info($"Client {Context.ConnectionId} joined task {taskId}");
return true;
}
public async Task<bool> Leave(string taskId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, taskId);
logger.Info($"Client {Context.ConnectionId} left task {taskId}");
return true;
}
public async Task<ProgressInfo?> GetProgress(string taskId)
{
return _progressTracker.GetTask(taskId)?.ToProgressInfo();
}
}

View File

@@ -1,7 +1,8 @@
using server.Services;
/// <summary>
/// 多线程通信总线
/// </summary>
public static class MsgBus
public sealed class MsgBus
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
@@ -11,17 +12,44 @@ public static class MsgBus
/// </summary>
public static UDPServer UDPServer { get { return udpServer; } }
// 添加静态ProgressTracker引用
private static ProgressTracker? _progressTracker;
/// <summary>
/// 设置全局ProgressTracker实例
/// </summary>
public static void SetProgressTracker(ProgressTracker progressTracker)
{
_progressTracker = progressTracker;
}
public static ProgressTracker ProgressTracker
{
get
{
if (_progressTracker == null)
{
throw new InvalidOperationException("ProgressTracker is not set.");
}
return _progressTracker;
}
}
private static bool isRunning = false;
/// <summary>
/// 获取通信总线运行状态
/// </summary>
public static bool IsRunning { get { return isRunning; } }
private MsgBus() { }
static MsgBus() { }
/// <summary>
/// 通信总线初始化
/// </summary>
/// <returns>无</returns>
public async static void Init()
public static void Init()
{
if (!ArpClient.IsAdministrator())
{

View File

@@ -1,6 +1,5 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.CameraClient;
@@ -16,7 +15,7 @@ static class CameraAddr
public const UInt32 CAMERA_POWER = BASE + 0x10; //[0]: rstn, 0 is reset. [8]: power down, 1 is down.
}
class Camera
public class Camera
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();

View File

@@ -170,7 +170,7 @@ public class DebuggerClient
/// <returns>操作结果,成功返回状态标志字节,失败返回错误信息</returns>
public async ValueTask<Result<byte>> ReadFlag()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, DebuggerAddr.Signal, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, DebuggerAddr.Signal, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read flag: {ret.Error}");

View File

@@ -1,6 +1,5 @@
using System.Net;
using DotNext;
using Peripherals.PowerClient;
using WebProtocol;
namespace Peripherals.HdmiInClient;
@@ -12,7 +11,7 @@ static class HdmiInAddr
public const UInt32 HdmiIn_READFIFO = BASE + 0x1;
}
class HdmiIn
public class HdmiIn
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
@@ -100,6 +99,51 @@ class HdmiIn
return result.Value;
}
public async ValueTask<(byte[] header, byte[] data, byte[] footer)?> GetMJpegFrame()
{
// 从HDMI读取RGB24数据
var readStartTime = DateTime.UtcNow;
var frameResult = await ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
if (!frameResult.IsSuccessful || frameResult.Value == null)
{
logger.Warn("HDMI帧读取失败或为空");
return null;
}
var rgb24Data = frameResult.Value;
// 验证数据长度是否正确 (RGB24为每像素2字节)
var expectedLength = _currentWidth * _currentHeight * 2;
if (rgb24Data.Length != expectedLength)
{
logger.Warn("HDMI数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb24Data.Length);
}
// 将RGB24转换为JPEG参考Camera版本的处理
var jpegStartTime = DateTime.UtcNow;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Data, _currentWidth, _currentHeight, 80);
var jpegEndTime = DateTime.UtcNow;
var jpegTime = (jpegEndTime - jpegStartTime).TotalMilliseconds;
if (!jpegResult.IsSuccessful)
{
logger.Error("HDMI RGB24转JPEG失败: {Error}", jpegResult.Error);
return null;
}
var jpegData = jpegResult.Value;
// 发送MJPEG帧使用Camera版本的格式
var mjpegFrameHeader = Common.Image.CreateMjpegFrameHeader(jpegData.Length);
var mjpegFrameFooter = Common.Image.CreateMjpegFrameFooter();
return (mjpegFrameHeader, jpegData, mjpegFrameFooter);
}
/// <summary>
/// 获取当前分辨率
/// </summary>

View File

@@ -296,7 +296,7 @@ public class I2c
// 读取数据
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, I2cAddr.Read);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, I2cAddr.Read);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read data from I2C FIFO: {ret.Error}");

View File

@@ -0,0 +1,608 @@
using System.Net;
using DotNext;
using Common;
namespace Peripherals.JpegClient;
static class JpegAddr
{
const UInt32 BASE = 0xA000_0000;
public const UInt32 CAPTURE_RD_CTRL = BASE + 0x0;
public const UInt32 CAPTURE_WR_CTRL = BASE + 0x1;
public const UInt32 START_WR_ADDR0 = BASE + 0x2;
public const UInt32 END_WR_ADDR0 = BASE + 0x3;
public const UInt32 START_WR_ADDR1 = BASE + 0x4;
public const UInt32 END_WR_ADDR1 = BASE + 0x5;
public const UInt32 START_RD_ADDR0 = BASE + 0x6;
public const UInt32 END_RD_ADDR0 = BASE + 0x7;
public const UInt32 HDMI_NOT_READY = BASE + 0x8;
public const UInt32 HDMI_HEIGHT_WIDTH = BASE + 0x9;
public const UInt32 JPEG_HEIGHT_WIDTH = BASE + 0xA;
public const UInt32 JPEG_ADD_NEED_FRAME_NUM = BASE + 0xB;
public const UInt32 JPEG_FRAME_SAVE_NUM = BASE + 0xC;
public const UInt32 JPEG_FIFO_FRAME_INFO = BASE + 0xD;
public const UInt32 JPEG_QUANTIZATION_TABLE = BASE + 0x100;
public const UInt32 ADDR_HDMI_WD_START = 0x0400_0000;
public const UInt32 ADDR_JPEG_START = 0x0800_0000;
public const UInt32 ADDR_JPEG_END = 0x09FF_FFFF;
}
public class JpegInfo
{
public UInt32 Width { get; set; }
public UInt32 Height { get; set; }
public UInt32 Size { get; set; }
public JpegInfo(UInt32 width, UInt32 height, UInt32 size)
{
Width = width;
Height = height;
Size = size;
}
public JpegInfo(byte[] data)
{
if (data.Length < 8)
throw new ArgumentException("Invalid data length", nameof(data));
Width = ((UInt32)(data[5] << 8 + data[6] & 0xF0));
Height = ((UInt32)((data[6] & 0x0F) << 4 + data[7]));
Size = Number.BytesToUInt32(data, 0, 4).Value;
}
}
public enum JpegSampleRate : UInt32
{
RATE_1_1 = 0b1111_1111_1111_1111_1111_1111_1111_1111,
RATE_1_2 = 0b1010_1010_1010_1010_1010_1010_1010_1010,
RATE_1_4 = 0b1000_1000_1000_1000_1000_1000_1000_1000,
RATE_3_4 = 0b1110_1110_1110_1110_1110_1110_1110_1110,
RATE_1_8 = 0b1000_0000_1000_0000_1000_0000_1000_0000,
RATE_3_8 = 0b1001_0010_0100_1001_1001_0010_0100_1001,
RATE_7_8 = 0b1111_1110_1111_1110_1111_1110_1111_1110,
RATE_1_16 = 0b1000_0000_0000_0000_1000_0000_0000_0000,
RATE_3_16 = 0b1000_0100_0010_0000_1000_0100_0010_0000,
RATE_5_16 = 0b1001_0001_0010_0010_0100_0100_1000_1001,
RATE_15_16 = 0b1111_1111_1111_1110_1111_1111_1111_1110,
RATE_1_32 = 0b1000_0000_0000_0000_0000_0000_0000_0000,
RATE_31_32 = 0b1111_1111_1111_1111_1111_1111_1111_1110,
}
public class Jpeg
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 2000;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public int Width { get; set; }
public int Height { get; set; }
public Jpeg(string address, int port, int taskID, int timeout = 2000)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.taskID = taskID;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.timeout = timeout;
}
public async ValueTask<Result<bool>> Init(bool enable = true)
{
{
var ret = await CheckHdmiIsReady();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI ready: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("HDMI not ready");
return new(false);
}
}
int width = -1, height = -1;
{
var ret = await GetHdmiResolution();
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
(width, height) = ret.Value;
}
{
var ret = await ConnectJpeg2Hdmi(width, height);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to connect JPEG to HDMI: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error("Failed to connect JPEG to HDMI");
return false;
}
}
if (enable)
return await SetEnable(true);
else return true;
}
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (enable)
{
{
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b11, 0b01],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG enable: {ret.Error}");
return ret.Value;
}
}
{
var ret = await AddFrameNum2Process(1);
if (!ret)
{
logger.Error($"Failed to AddFrameNum2Process: {ret}");
return ret.Value;
}
}
return true;
}
else
{
var ret = await UDPClientPool.WriteAddrSeq(
this.ep,
this.taskID,
[JpegAddr.CAPTURE_RD_CTRL, JpegAddr.CAPTURE_WR_CTRL],
[0b00, 0b00],
this.timeout
);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set JPEG disable: {ret.Error}");
return false;
}
return ret.Value;
}
}
public async ValueTask<Result<bool>> CheckHdmiIsReady()
{
var ret = await UDPClientPool.ReadAddrWithWait(
this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b00, 0b01, 100, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to check HDMI status: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
public async ValueTask<Result<(int, int)>> GetHdmiResolution()
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get HDMI resolution: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value.Options.Data;
if (data == null || data.Length != 4)
{
logger.Error($"Invalid HDMI resolution data length: {data?.Length ?? 0}");
return new(new Exception("Invalid HDMI resolution data length"));
}
var width = data[3] | (data[2] << 8);
var height = data[1] | (data[0] << 8);
this.Width = width;
this.Height = height;
logger.Info($"HDMI resolution: {width}x{height}");
return new((width, height));
}
public async ValueTask<Result<bool>> ConnectJpeg2Hdmi(int width, int height)
{
if (width <= 0 || height <= 0)
{
logger.Error($"Invalid HDMI resolution: {width}x{height}");
return new(new ArgumentException("Invalid HDMI resolution"));
}
var frameSize = (UInt32)(width * height);
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_HEIGHT_WIDTH, (uint)((height << 16) + width), this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_WR_ADDR0, JpegAddr.ADDR_HDMI_WD_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set HDMI output end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set HDMI output address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_RD_ADDR0, JpegAddr.ADDR_HDMI_WD_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg input address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_RD_ADDR0,
JpegAddr.ADDR_HDMI_WD_START + frameSize - 1, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg input end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg input end address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.START_WR_ADDR1, JpegAddr.ADDR_JPEG_START, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg output start address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg output start address");
return false;
}
}
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.END_WR_ADDR1, JpegAddr.ADDR_JPEG_END, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to set jpeg output end address: {ret.Error}");
return new(ret.Error);
}
if (!ret.Value)
{
logger.Error($"Failed to set jpeg output end address");
return false;
}
}
return true;
}
// public async ValueTask<bool> SetSampleRate(uint rate)
// {
// var ret = await UDPClientPool.WriteAddr(
// this.ep, this.taskID, JpegAddr.FRAME_SAMPLE_RATE, rate, this.timeout);
// if (!ret.IsSuccessful)
// {
// logger.Error($"Failed to set JPEG sample rate: {ret.Error}");
// return false;
// }
// return ret.Value;
// }
// public async ValueTask<bool> SetSampleRate(JpegSampleRate rate)
// {
// return await SetSampleRate((uint)rate);
// }
public async ValueTask<uint> GetFrameNumber()
{
const int maxAttempts = 10;
const int delayMs = 5;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame number on attempt {attempt + 1}: {ret.Error}");
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
continue;
}
return 0;
}
var frameNumber = Number.BytesToUInt32(ret.Value.Options.Data ?? Array.Empty<byte>()).Value;
if (frameNumber != 0)
{
return frameNumber;
}
// 如果不是最后一次尝试等待5ms后重试
if (attempt < maxAttempts - 1)
{
await Task.Delay(delayMs);
}
}
// 所有尝试都失败或返回0
return 0;
}
public async ValueTask<Optional<List<JpegInfo>>> GetFrameInfo(int num)
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, JpegAddr.JPEG_FIFO_FRAME_INFO, num, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame info: {ret.Error}");
return new(null);
}
var data = ret.Value.Options.Data;
if (data == null || data.Length == 0)
{
logger.Error($"Data is null or empty");
return new(null);
}
if (data.Length != num * 2)
{
logger.Error(
$"Data length should be {num * 2} bytes, instead of {data.Length} bytes");
return new(null);
}
var infos = new List<JpegInfo>();
for (int i = 0; i < num; i++)
{
infos.Add(new JpegInfo(data[i..(i + 1)]));
}
return new(infos);
}
public async ValueTask<Result<bool>> AddFrameNum2Process(uint cnt)
{
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, JpegAddr.JPEG_ADD_NEED_FRAME_NUM, cnt, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to update pointer: {ret.Error}");
return ret.Value;
}
return ret.Value;
}
public async ValueTask<Result<byte[]?>> GetFrame(uint offset, uint length)
{
if (!MsgBus.IsRunning)
{
logger.Error("Message bus is not running");
return new(new Exception("Message bus is not running"));
}
MsgBus.UDPServer.ClearUDPData(this.ep.Address.ToString(), this.ep.Port);
var firstReadLength = (int)(Math.Min(
length,
JpegAddr.ADDR_JPEG_END - JpegAddr.ADDR_JPEG_START - offset
));
var secondReadLength = (int)(length - firstReadLength);
var dataBytes = new byte[length];
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.ADDR_JPEG_START + offset, firstReadLength, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame data: {ret.Error}");
return null;
}
if (ret.Value.Length != firstReadLength)
{
logger.Error($"Data length should be {firstReadLength} bytes, instead of {ret.Value.Length} bytes");
return null;
}
Buffer.BlockCopy(ret.Value, 0, dataBytes, 0, firstReadLength);
}
if (secondReadLength > 0)
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.ADDR_JPEG_START, secondReadLength, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame data: {ret.Error}");
return null;
}
if (ret.Value.Length != secondReadLength)
{
logger.Error($"Data length should be {secondReadLength} bytes, instead of {ret.Value.Length} bytes");
return null;
}
Buffer.BlockCopy(ret.Value, 0, dataBytes, firstReadLength, secondReadLength);
}
return dataBytes;
}
public async ValueTask<List<byte[]>> GetMultiFrames(uint offset, uint[] sizes)
{
var frames = new List<byte[]>();
for (int i = 0; i < sizes.Length; i++)
{
var ret = await GetFrame(offset, sizes[i]);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to get JPEG frame {i} data: {ret.Error}");
continue;
}
if (ret.Value == null)
{
logger.Error($"Frame {i} data is null");
continue;
}
if (ret.Value.Length != sizes[i])
{
logger.Error(
$"Frame {i} data length should be {sizes[i]} bytes, instead of {ret.Value.Length} bytes");
continue;
}
frames.Add(ret.Value);
offset += sizes[i];
}
{
var ret = await AddFrameNum2Process((uint)sizes.Length);
if (!ret) logger.Error($"Failed to update pointer");
}
return frames;
}
public async ValueTask<Result<List<byte[]>?>> GetMultiFrames(uint offset)
{
if (!MsgBus.IsRunning)
{
logger.Error("Message bus is not running");
return new(new Exception("Message bus is not running"));
}
MsgBus.UDPServer.ClearUDPData(this.ep.Address.ToString(), this.ep.Port);
var frameNum = await GetFrameNumber();
if (frameNum == 0) return null;
List<uint>? frameSizes = null;
{
var ret = await GetFrameInfo((int)frameNum);
if (!ret.HasValue || ret.Value.Count == 0)
{
logger.Error($"Failed to get frame info");
return null;
}
frameSizes = ret.Value.Select(x => x.Size).ToList();
}
var frames = await GetMultiFrames(offset, frameSizes.ToArray());
if (frames.Count == 0)
{
logger.Error($"Failed to get frames");
return null;
}
return frames;
}
public async ValueTask<Result<uint[]?>> GetQuantizationTable()
{
const int totalQuantValues = 8 * 8 * 3; // Y(64) + Cb(64) + Cr(64) = 192个量化值
const int bytesPerValue = 4; // 每个量化值32bit = 4字节
const int totalBytes = totalQuantValues * bytesPerValue; // 总共768字节
try
{
var ret = await UDPClientPool.ReadAddr4Bytes(
this.ep, this.taskID, JpegAddr.JPEG_QUANTIZATION_TABLE, totalBytes, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read JPEG quantization table: {ret.Error}");
return new(ret.Error);
}
var data = ret.Value;
if (data == null || data.Length != totalBytes)
{
logger.Error($"Invalid quantization table data length: expected {totalBytes}, got {data?.Length ?? 0}");
return new(new Exception("Invalid quantization table data length"));
}
var quantTable = new uint[totalQuantValues];
for (int i = 0; i < totalQuantValues; i++)
{
// 每32bit为一个量化值按小端序读取
var offset = i * bytesPerValue;
quantTable[i] = (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24));
}
logger.Debug($"Successfully read JPEG quantization table with {totalQuantValues} values");
return quantTable;
}
catch (Exception ex)
{
logger.Error(ex, "Exception occurred while reading JPEG quantization table");
return new(ex);
}
}
}

View File

@@ -380,6 +380,7 @@ public class JtagStatusReg
public class Jtag
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ProgressTracker _progressTracker = MsgBus.ProgressTracker;
private const int CLOCK_FREQ = 50; // MHz
@@ -392,6 +393,7 @@ public class Jtag
public readonly string address;
private IPEndPoint ep;
/// <summary>
/// Jtag 构造函数
/// </summary>
@@ -444,10 +446,10 @@ public class Jtag
async ValueTask<Result<bool>> WriteFIFO(
UInt32 devAddr, UInt32 data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, ProgressReporter? progress = null)
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progressId);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed"));
}
@@ -458,17 +460,18 @@ public class Jtag
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
progress?.Finish();
_progressTracker?.AdvanceProgress(progressId, 10);
return ret.Value;
}
}
async ValueTask<Result<bool>> WriteFIFO(
UInt32 devAddr, byte[] data, UInt32 result,
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, ProgressReporter? progress = null)
UInt32 resultMask = 0xFF_FF_FF_FF, UInt32 delayMilliseconds = 0, string progressId = "")
{
{
var ret = await UDPClientPool.WriteAddr(this.ep, 0, devAddr, data, this.timeout, progress?.CreateChild(80));
var ret = await UDPClientPool.WriteAddr(
this.ep, 0, devAddr, data, this.timeout, progressId);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value) return new(new Exception("Write FIFO failed"));
}
@@ -479,7 +482,7 @@ public class Jtag
{
var ret = await UDPClientPool.ReadAddrWithWait(this.ep, 0, JtagAddr.STATE, result, resultMask, 0, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return ret.Value;
}
}
@@ -564,7 +567,7 @@ public class Jtag
}
async ValueTask<Result<bool>> LoadDRCareInput(
byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500, ProgressReporter? progress = null)
byte[] bytesArray, UInt32 timeout = 10_000, UInt32 cycle = 500, string progressId = "")
{
var bytesLen = ((uint)(bytesArray.Length * 8));
if (bytesLen > Math.Pow(2, 28)) return new(new Exception("Length is over 2^(28 - 3)"));
@@ -579,14 +582,15 @@ public class Jtag
else if (!ret.Value) return new(new Exception("Write CMD_JTAG_LOAD_DR_CAREI Failed"));
}
progress?.Report(10);
_progressTracker.AdvanceProgress(progressId, 10);
{
var ret = await WriteFIFO(
JtagAddr.WRITE_DATA,
bytesArray, 0x01_00_00_00,
JtagState.CMD_EXEC_FINISH,
progress: progress?.CreateChild(90)
0,
progressId
);
if (!ret.IsSuccessful) return new(ret.Error);
@@ -709,56 +713,55 @@ public class Jtag
/// 下载比特流到 JTAG 设备
/// </summary>
/// <param name="bitstream">比特流数据</param>
/// <param name="progressId">进度ID</param>
/// <returns>指示下载是否成功的异步结果</returns>
public async ValueTask<Result<bool>> DownloadBitstream(byte[] bitstream, ProgressReporter? progress = null)
public async ValueTask<Result<bool>> DownloadBitstream(
byte[] bitstream, string progressId = "")
{
// Clear Data
MsgBus.UDPServer.ClearUDPData(this.address, 0);
logger.Trace($"Clear up udp server {this.address,0} receive data");
if (progress != null)
{
progress.ExpectedSteps = 25;
progress.Increase();
}
_progressTracker.AdvanceProgress(progressId, 10);
Result<bool> ret;
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag initialize");
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JRST);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JRST Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await ExecRDCmd(JtagCmd.JTAG_DR_CFGI);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_CFGI Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag ready to write bitstream");
ret = await IdleDelay(100000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await LoadDRCareInput(bitstream, progress: progress?.CreateChild(50));
ret = await LoadDRCareInput(bitstream, progressId: progressId);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Load Data Failed"));
@@ -767,40 +770,40 @@ public class Jtag
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await RunTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Run Test Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await ExecRDCmd(JtagCmd.JTAG_DR_JWAKEUP);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Execute Command JTAG_DR_JWAKEUP Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
logger.Trace("Jtag reset device");
ret = await IdleDelay(10000);
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag IDLE Delay Failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
var retCode = await ReadStatusReg();
if (!retCode.IsSuccessful) return new(retCode.Error);
var jtagStatus = new JtagStatusReg(retCode.Value);
if (!(jtagStatus.done && jtagStatus.wakeup_over && jtagStatus.init_complete))
return new(new Exception("Jtag download bitstream failed"));
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
ret = await CloseTest();
if (!ret.IsSuccessful) return new(ret.Error);
else if (!ret.Value) return new(new Exception("Jtag Close Test Failed"));
logger.Trace("Jtag download bitstream successfully");
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 10);
// Finish
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return true;
}

View File

@@ -366,7 +366,7 @@ public class Analyzer
/// <returns>操作结果,成功返回寄存器值,否则返回异常信息</returns>
public async ValueTask<Result<CaptureStatus>> ReadCaptureStatus()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, AnalyzerAddr.CAPTURE_MODE, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, AnalyzerAddr.CAPTURE_MODE, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read capture status: {ret.Error}");

View File

@@ -232,7 +232,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样频率值,否则返回异常信息</returns>
public async ValueTask<Result<UInt32>> GetADFrequency()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_FREQ, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_FREQ, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD frequency: {ret.Error}");
@@ -255,7 +255,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样幅度值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADVpp()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_VPP, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_VPP, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD VPP: {ret.Error}");
@@ -275,7 +275,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样最大值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADMax()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_MAX, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_MAX, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD max: {ret.Error}");
@@ -295,7 +295,7 @@ class Oscilloscope
/// <returns>操作结果,成功返回采样最小值,否则返回异常信息</returns>
public async ValueTask<Result<byte>> GetADMin()
{
var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, OscilloscopeAddr.AD_MIN, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, this.taskID, OscilloscopeAddr.AD_MIN, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Failed to read AD min: {ret.Error}");

View File

@@ -339,7 +339,7 @@ public class RemoteUpdater
}
{
var ret = await UDPClientPool.ReadAddr(this.ep, 0, RemoteUpdaterAddr.ReadCRC, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, 0, RemoteUpdaterAddr.ReadCRC, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
var bytes = ret.Value.Options.Data;
@@ -543,7 +543,7 @@ public class RemoteUpdater
logger.Trace("Clear udp data finished");
{
var ret = await UDPClientPool.ReadAddr(this.ep, 0, RemoteUpdaterAddr.Version, this.timeout);
var ret = await UDPClientPool.ReadAddrByte(this.ep, 0, RemoteUpdaterAddr.Version, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
var retData = ret.Value.Options.Data;

View File

@@ -0,0 +1,79 @@
using System.Net;
using DotNext;
using Common;
namespace Peripherals.SevenDigitalTubesClient;
static class SevenDigitalTubesAddr
{
public const UInt32 BASE = 0xB000_0000;
}
public class SevenDigitalTubesCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
/// <summary>
/// 初始化七段数码管控制器
/// </summary>
/// <param name="address">七段数码管控制器IP地址</param>
/// <param name="port">七段数码管控制器端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="timeout">超时时间(毫秒)</param>
public SevenDigitalTubesCtrl(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<byte>> ReadTube(int num)
{
if (num < 0 || num > 31)
throw new ArgumentOutOfRangeException(nameof(num), "Tube number must be between 0 and 31");
var ret = await UDPClientPool.ReadAddrByte(
this.ep, this.taskID, SevenDigitalTubesAddr.BASE + (UInt32)num, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Read tubes failed: {ret.Error}");
return new(ret.Error);
}
if (ret.Value.Options.Data == null || ret.Value.Options.Data.Length < 4)
return new(new Exception("Data length is too short"));
var data = Number.BytesToUInt32(ret.Value.Options.Data, 0, 4).Value;
if ((data >> 8) != num)
{
logger.Error($"Read wrong tube number: {num} != {data >> 8}");
return new(new Exception($"Read wrong tube number: {num} != {data >> 8}"));
}
return (byte)(data & 0xFF);
}
public async ValueTask<Result<byte[]>> ScanAllTubes()
{
var tubes = new byte[32];
for (int i = 0; i < 32; i++)
{
var ret = await ReadTube(i);
if (!ret.IsSuccessful)
return new(ret.Error);
tubes[i] = ret.Value;
}
return tubes;
}
}

View File

@@ -0,0 +1,65 @@
using System.Collections;
using System.Net;
using DotNext;
namespace Peripherals.SwitchClient;
class SwitchCtrlAddr
{
public const UInt32 BASE = 0xB0_00_00_20;
public const UInt32 ENABLE = BASE;
}
/// <summary>
/// 矩阵键盘外设类,用于控制和管理矩阵键盘的功能。
/// </summary>
public class SwitchCtrl
{
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
readonly int timeout = 500;
readonly int taskID;
readonly int port;
readonly string address;
private IPEndPoint ep;
public SwitchCtrl(string address, int port, int taskID, int timeout = 500)
{
if (timeout < 0)
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
this.address = address;
this.port = port;
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
this.taskID = taskID;
this.timeout = timeout;
}
public async ValueTask<Result<bool>> SetEnable(bool enable)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, SwitchCtrlAddr.ENABLE, enable ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful) return new(ret.Error);
return ret.Value;
}
public async ValueTask<Result<bool>> SetSwitchOnOff(int num, bool onOff)
{
if (MsgBus.IsRunning)
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
else return new(new Exception("Message Bus not work!"));
var ret = await UDPClientPool.WriteAddr(
this.ep, this.taskID, SwitchCtrlAddr.BASE + (UInt32)num, onOff ? 0x1U : 0x0U, this.timeout);
if (!ret.IsSuccessful)
{
logger.Error($"Set Switch {onOff} failed: {ret.Error}");
return new(ret.Error);
}
return ret.Value;
}
}

View File

@@ -1,6 +1,7 @@
using System.Net;
using System.Collections.Concurrent;
using Peripherals.HdmiInClient;
using Peripherals.JpegClient;
namespace server.Services;
@@ -12,18 +13,32 @@ public class HdmiVideoStreamEndpoint
public string SnapshotUrl { get; set; } = "";
}
public class HdmiVideoStreamClient
{
public required HdmiIn HdmiInClient { get; set; }
public required Jpeg JpegClient { get; set; }
public required CancellationTokenSource CTS { get; set; }
public required int Offset { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
public class HttpHdmiVideoStreamService : BackgroundService
{
private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private HttpListener? _httpListener;
private readonly int _serverPort = 4322;
private readonly ConcurrentDictionary<string, HdmiIn> _hdmiInDict = new();
private readonly ConcurrentDictionary<string, CancellationTokenSource> _hdmiInCtsDict = new();
private readonly ConcurrentDictionary<string, HdmiVideoStreamClient> _clientDict = new();
public override async Task StartAsync(CancellationToken cancellationToken)
{
_httpListener = new HttpListener();
_httpListener.Prefixes.Add($"http://*:{_serverPort}/");
_httpListener.Prefixes.Add($"http://{Global.LocalHost}:{_serverPort}/");
_httpListener.Start();
logger.Info($"HDMI Video Stream Service started on port {_serverPort}");
@@ -31,41 +46,32 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
while (!stoppingToken.IsCancellationRequested)
{
HttpListenerContext? context = null;
if (_httpListener == null) continue;
try
{
logger.Debug("Waiting for HTTP request...");
context = await _httpListener.GetContextAsync();
var contextTask = _httpListener.GetContextAsync();
var completedTask = await Task.WhenAny(contextTask, Task.Delay(-1, stoppingToken));
if (completedTask == contextTask)
{
var context = contextTask.Result;
logger.Debug($"Received request: {context.Request.Url?.AbsolutePath}");
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
catch (ObjectDisposedException)
else
{
// Listener closed, exit loop
break;
}
catch (HttpListenerException)
{
// Listener closed, exit loop
break;
}
catch (Exception ex)
{
logger.Error(ex, "Error in GetContextAsync");
break;
}
if (context != null)
_ = HandleRequestAsync(context, stoppingToken);
}
}
finally
{
_httpListener?.Close();
logger.Info("HDMI Video Stream Service stopped.");
}
}
@@ -75,7 +81,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
// 禁用所有活跃的HDMI传输
var disableTasks = new List<Task>();
foreach (var hdmiKey in _hdmiInDict.Keys)
foreach (var hdmiKey in _clientDict.Keys)
{
disableTasks.Add(DisableHdmiTransmissionAsync(hdmiKey));
}
@@ -84,10 +90,8 @@ public class HttpHdmiVideoStreamService : BackgroundService
await Task.WhenAll(disableTasks);
// 清空字典
_hdmiInDict.Clear();
_hdmiInCtsDict.Clear();
_clientDict.Clear();
_httpListener?.Close(); // 立即关闭监听器,唤醒阻塞
await base.StopAsync(cancellationToken);
}
@@ -95,18 +99,17 @@ public class HttpHdmiVideoStreamService : BackgroundService
{
try
{
var cts = _hdmiInCtsDict[key];
cts.Cancel();
var client = _clientDict[key];
client.CTS.Cancel();
var hdmiIn = _hdmiInDict[key];
var disableResult = await hdmiIn.EnableTrans(false);
if (disableResult.IsSuccessful)
var disableResult = await client.JpegClient.SetEnable(false);
if (disableResult)
{
logger.Info("Successfully disabled HDMI transmission");
}
else
{
logger.Error($"Failed to disable HDMI transmission: {disableResult.Error}");
logger.Error($"Failed to disable HDMI transmission");
}
}
catch (Exception ex)
@@ -115,40 +118,18 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
}
// 获取/创建 HdmiIn 实例
private async Task<HdmiIn?> GetOrCreateHdmiInAsync(string boardId)
private async Task<HdmiVideoStreamClient?> GetOrCreateClientAsync(string boardId)
{
if (_hdmiInDict.TryGetValue(boardId, out var hdmiIn))
if (_clientDict.TryGetValue(boardId, out var client))
{
try
{
var enableResult = await hdmiIn.EnableTrans(true);
if (!enableResult.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {enableResult.Error}");
return null;
}
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
}
catch (Exception ex)
{
logger.Error(ex, $"Exception occurred while enabling HDMI transmission for board {boardId}");
return null;
client.Width = client.JpegClient.Width;
client.Height = client.JpegClient.Height;
return client;
}
_hdmiInDict[boardId] = hdmiIn;
_hdmiInCtsDict[boardId] = new CancellationTokenSource();
return hdmiIn;
}
var userManager = new Database.UserManager();
var db = new Database.AppDataConnection();
if (db == null)
{
logger.Error("Failed to create HdmiIn instance");
return null;
}
var boardRet = db.GetBoardByID(Guid.Parse(boardId));
var boardRet = userManager.GetBoardByID(Guid.Parse(boardId));
if (!boardRet.IsSuccessful || !boardRet.Value.HasValue)
{
logger.Error($"Failed to get board with ID {boardId}");
@@ -157,18 +138,35 @@ public class HttpHdmiVideoStreamService : BackgroundService
var board = boardRet.Value.Value;
hdmiIn = new HdmiIn(board.IpAddr, board.Port, 0); // taskID 可根据实际需求调整
client = new HdmiVideoStreamClient()
{
HdmiInClient = new HdmiIn(board.IpAddr, board.Port, 1),
JpegClient = new Jpeg(board.IpAddr, board.Port, 1),
CTS = new CancellationTokenSource(),
Offset = 0
};
// 启用HDMI传输
try
{
var enableResult = await hdmiIn.EnableTrans(true);
if (!enableResult.IsSuccessful)
// var hdmiEnableRet = await client.JpegClient.EnableTrans(true);
// if (!hdmiEnableRet.IsSuccessful)
// {
// logger.Error($"Failed to enable HDMI transmission for board {boardId}: {hdmiEnableRet.Error}");
// return null;
// }
// logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
var jpegEnableRet = await client.JpegClient.Init(true);
if (!jpegEnableRet.IsSuccessful)
{
logger.Error($"Failed to enable HDMI transmission for board {boardId}: {enableResult.Error}");
logger.Error($"Failed to enable JPEG transmission for board {boardId}: {jpegEnableRet.Error}");
return null;
}
logger.Info($"Successfully enabled HDMI transmission for board {boardId}");
logger.Info($"Successfully enabled JPEG transmission for board {boardId}");
client.Width = client.JpegClient.Width;
client.Height = client.JpegClient.Height;
}
catch (Exception ex)
{
@@ -176,9 +174,8 @@ public class HttpHdmiVideoStreamService : BackgroundService
return null;
}
_hdmiInDict[boardId] = hdmiIn;
_hdmiInCtsDict[boardId] = new CancellationTokenSource();
return hdmiIn;
_clientDict[boardId] = client;
return client;
}
private async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken)
@@ -191,27 +188,22 @@ public class HttpHdmiVideoStreamService : BackgroundService
return;
}
var hdmiIn = await GetOrCreateHdmiInAsync(boardId);
if (hdmiIn == null)
var client = await GetOrCreateClientAsync(boardId);
if (client == null)
{
await SendErrorAsync(context.Response, "Invalid boardId or board not available");
return;
}
var hdmiInToken = _hdmiInCtsDict[boardId].Token;
if (hdmiInToken == null)
{
await SendErrorAsync(context.Response, "HDMI input is not available");
return;
}
var hdmiInToken = _clientDict[boardId].CTS.Token;
if (path == "/snapshot")
{
await HandleSnapshotRequestAsync(context.Response, hdmiIn, hdmiInToken);
await HandleSnapshotRequestAsync(context.Response, client, hdmiInToken);
}
else if (path == "/mjpeg")
{
await HandleMjpegStreamAsync(context.Response, hdmiIn, hdmiInToken);
await HandleMjpegStreamAsync(context.Response, client, hdmiInToken);
}
else if (path == "/video")
{
@@ -223,18 +215,16 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
}
private async Task HandleSnapshotRequestAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
private async Task HandleSnapshotRequestAsync(
HttpListenerResponse response, HdmiVideoStreamClient client, CancellationToken cancellationToken)
{
try
{
logger.Debug("处理HDMI快照请求");
const int frameWidth = 960; // HDMI输入分辨率
const int frameHeight = 540;
// 从HDMI读取RGB565数据
var frameResult = await hdmiIn.ReadFrame();
if (!frameResult.IsSuccessful || frameResult.Value == null)
var frameResult = await client.JpegClient.GetMultiFrames((uint)client.Offset);
if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
{
logger.Error("HDMI快照获取失败");
response.StatusCode = 500;
@@ -244,51 +234,39 @@ public class HttpHdmiVideoStreamService : BackgroundService
return;
}
var rgb565Data = frameResult.Value;
var jpegData = frameResult.Value[0];
// 验证数据长度
var expectedLength = frameWidth * frameHeight * 2;
if (rgb565Data.Length != expectedLength)
var quantTableResult = await client.JpegClient.GetQuantizationTable();
if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
{
logger.Warn("HDMI快照数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb565Data.Length);
}
// 将RGB565转换为RGB24
var rgb24Result = Common.Image.ConvertRGB565ToRGB24(rgb565Data, frameWidth, frameHeight, isLittleEndian: false);
if (!rgb24Result.IsSuccessful)
{
logger.Error("HDMI快照RGB565转RGB24失败: {Error}", rgb24Result.Error);
logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to process HDMI snapshot");
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
// 将RGB24转换为JPEG
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Result.Value, frameWidth, frameHeight, 80);
if (!jpegResult.IsSuccessful)
var jpegImage = Common.Image.CompleteJpegData(jpegData, client.Width, client.Height, quantTableResult.Value);
if (!jpegImage.IsSuccessful)
{
logger.Error("HDMI快照RGB24转JPEG失败: {Error}", jpegResult.Error);
logger.Error("JPEG数据补全失败");
response.StatusCode = 500;
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to encode HDMI snapshot");
var errorBytes = System.Text.Encoding.UTF8.GetBytes("Failed to complete JPEG data");
await response.OutputStream.WriteAsync(errorBytes, 0, errorBytes.Length, cancellationToken);
response.Close();
return;
}
var jpegData = jpegResult.Value;
// 设置响应头参考Camera版本
response.ContentType = "image/jpeg";
response.ContentLength64 = jpegData.Length;
response.ContentLength64 = jpegImage.Value.Length;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegImage.Value, 0, jpegImage.Value.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegData.Length);
logger.Debug("已发送HDMI快照图像大小{Size} 字节", jpegImage.Value.Length);
}
catch (Exception ex)
{
@@ -301,7 +279,8 @@ public class HttpHdmiVideoStreamService : BackgroundService
}
}
private async Task HandleMjpegStreamAsync(HttpListenerResponse response, HdmiIn hdmiIn, CancellationToken cancellationToken)
private async Task HandleMjpegStreamAsync(
HttpListenerResponse response, HdmiVideoStreamClient client, CancellationToken cancellationToken)
{
try
{
@@ -313,71 +292,55 @@ public class HttpHdmiVideoStreamService : BackgroundService
logger.Debug("开始HDMI MJPEG流传输");
var quantTableResult = await client.JpegClient.GetQuantizationTable();
if (!quantTableResult.IsSuccessful || quantTableResult.Value == null)
{
logger.Error("获取JPEG量化表失败: {Error}", quantTableResult.Error);
response.StatusCode = 500;
await response.OutputStream.WriteAsync(
System.Text.Encoding.UTF8.GetBytes("Failed to get quantization table"), 0, 0, cancellationToken);
response.Close();
return;
}
var quantTable = quantTableResult.Value;
int frameCounter = 0;
const int frameWidth = 960; // HDMI输入分辨率
const int frameHeight = 540;
while (!cancellationToken.IsCancellationRequested)
{
try
{
var frameStartTime = DateTime.UtcNow;
// 从HDMI读取RGB565数据
var readStartTime = DateTime.UtcNow;
var frameResult = await hdmiIn.ReadFrame();
var readEndTime = DateTime.UtcNow;
var readTime = (readEndTime - readStartTime).TotalMilliseconds;
if (!frameResult.IsSuccessful || frameResult.Value == null)
var frameResult =
await client.JpegClient.GetMultiFrames((uint)client.Offset);
if (!frameResult.IsSuccessful || frameResult.Value == null || frameResult.Value.Count == 0)
{
logger.Warn("HDMI帧读取失败或为空");
logger.Error("获取HDMI帧失败");
await Task.Delay(100, cancellationToken);
continue;
}
var rgb565Data = frameResult.Value;
// 验证数据长度是否正确 (RGB565为每像素2字节)
var expectedLength = frameWidth * frameHeight * 2;
if (rgb565Data.Length != expectedLength)
foreach (var framebytes in frameResult.Value)
{
logger.Warn("HDMI数据长度不匹配期望: {Expected}, 实际: {Actual}",
expectedLength, rgb565Data.Length);
}
// 将RGB565转换为RGB24参考Camera版本的处理
var convertStartTime = DateTime.UtcNow;
var rgb24Result = Common.Image.ConvertRGB565ToRGB24(rgb565Data, frameWidth, frameHeight, isLittleEndian: false);
var convertEndTime = DateTime.UtcNow;
var convertTime = (convertEndTime - convertStartTime).TotalMilliseconds;
if (!rgb24Result.IsSuccessful)
var jpegImage = Common.Image.CompleteJpegData(framebytes, client.Width, client.Height, quantTable);
if (!jpegImage.IsSuccessful)
{
logger.Error("HDMI RGB565转RGB24失败: {Error}", rgb24Result.Error);
logger.Error("JPEG数据不完整");
await Task.Delay(100, cancellationToken);
continue;
}
// 将RGB24转换为JPEG参考Camera版本的处理
var jpegStartTime = DateTime.UtcNow;
var jpegResult = Common.Image.ConvertRGB24ToJpeg(rgb24Result.Value, frameWidth, frameHeight, 80);
var jpegEndTime = DateTime.UtcNow;
var jpegTime = (jpegEndTime - jpegStartTime).TotalMilliseconds;
if (!jpegResult.IsSuccessful)
var frameRet = Common.Image.CreateMjpegFrameFromJpeg(jpegImage.Value);
if (!frameRet.IsSuccessful)
{
logger.Error("HDMI RGB24转JPEG失败: {Error}", jpegResult.Error);
logger.Error("创建MJPEG失败");
await Task.Delay(100, cancellationToken);
continue;
}
var frame = frameRet.Value;
var jpegData = jpegResult.Value;
// 发送MJPEG帧使用Camera版本的格式
var mjpegFrameHeader = Common.Image.CreateMjpegFrameHeader(jpegData.Length);
var mjpegFrameFooter = Common.Image.CreateMjpegFrameFooter();
await response.OutputStream.WriteAsync(mjpegFrameHeader, 0, mjpegFrameHeader.Length, cancellationToken);
await response.OutputStream.WriteAsync(jpegData, 0, jpegData.Length, cancellationToken);
await response.OutputStream.WriteAsync(mjpegFrameFooter, 0, mjpegFrameFooter.Length, cancellationToken);
await response.OutputStream.WriteAsync(frame.header, 0, frame.header.Length, cancellationToken);
await response.OutputStream.WriteAsync(frame.data, 0, frame.data.Length, cancellationToken);
await response.OutputStream.WriteAsync(frame.footer, 0, frame.footer.Length, cancellationToken);
await response.OutputStream.FlushAsync(cancellationToken);
frameCounter++;
@@ -387,13 +350,10 @@ public class HttpHdmiVideoStreamService : BackgroundService
// 性能统计日志每30帧记录一次
if (frameCounter % 30 == 0)
{
logger.Debug("HDMI帧 {FrameNumber} 性能统计 - 读取: {ReadTime:F1}ms, RGB转换: {ConvertTime:F1}ms, JPEG转换: {JpegTime:F1}ms, 总计: {TotalTime:F1}ms, JPEG大小: {JpegSize} 字节",
frameCounter, readTime, convertTime, jpegTime, totalTime, jpegData.Length);
logger.Debug("HDMI帧 {FrameNumber} 性能统计 - 总计: {TotalTime:F1}ms, JPEG大小: {JpegSize} 字节",
frameCounter, totalTime, frame.data.Length);
}
}
catch (Exception ex)
{
logger.Error(ex, "处理HDMI帧时发生错误");
}
}
}
@@ -406,7 +366,7 @@ public class HttpHdmiVideoStreamService : BackgroundService
try
{
// 停止传输时禁用HDMI传输
await hdmiIn.EnableTrans(false);
await client.HdmiInClient.EnableTrans(false);
logger.Info("已禁用HDMI传输");
}
catch (Exception ex)
@@ -461,8 +421,9 @@ public class HttpHdmiVideoStreamService : BackgroundService
/// <returns>返回所有可用的HDMI视频流终端点列表</returns>
public List<HdmiVideoStreamEndpoint>? GetAllVideoEndpoints()
{
var db = new Database.AppDataConnection();
var boards = db?.GetAllBoard();
var userManager = new Database.UserManager();
var boards = userManager.GetAllBoard();
if (boards == null)
return null;
@@ -472,9 +433,9 @@ public class HttpHdmiVideoStreamService : BackgroundService
endpoints.Add(new HdmiVideoStreamEndpoint
{
BoardId = board.ID.ToString(),
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={board.ID}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={board.ID}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={board.ID}"
MjpegUrl = $"http://{Global.LocalHost}:{_serverPort}/mjpeg?boardId={board.ID}",
VideoUrl = $"http://{Global.LocalHost}:{_serverPort}/video?boardId={board.ID}",
SnapshotUrl = $"http://{Global.LocalHost}:{_serverPort}/snapshot?boardId={board.ID}"
});
}
return endpoints;
@@ -490,9 +451,9 @@ public class HttpHdmiVideoStreamService : BackgroundService
return new HdmiVideoStreamEndpoint
{
BoardId = boardId,
MjpegUrl = $"http://{Global.localhost}:{_serverPort}/mjpeg?boardId={boardId}",
VideoUrl = $"http://{Global.localhost}:{_serverPort}/video?boardId={boardId}",
SnapshotUrl = $"http://{Global.localhost}:{_serverPort}/snapshot?boardId={boardId}"
MjpegUrl = $"http://{Global.LocalHost}:{_serverPort}/mjpeg?boardId={boardId}",
VideoUrl = $"http://{Global.LocalHost}:{_serverPort}/video?boardId={boardId}",
SnapshotUrl = $"http://{Global.LocalHost}:{_serverPort}/snapshot?boardId={boardId}"
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
using System.Collections.Concurrent;
using Microsoft.AspNetCore.SignalR;
using server.Hubs;
namespace server.Services;
public enum TaskState { Running, Completed, Failed, Cancelled }
public readonly struct TaskProgress
{
public string Id { get; }
public int Current { get; }
public int Total { get; }
public TaskState State { get; }
public long Timestamp { get; }
public string? Error { get; }
public TaskProgress(string id, int current, int total, TaskState state, long timestamp, string? error = null)
{
Id = id;
Current = current;
Total = total;
State = state;
Timestamp = timestamp;
Error = error;
}
public TaskProgress WithUpdate(int? current = null, TaskState? state = null, string? error = null)
{
return new TaskProgress(
Id,
current ?? Current,
Total,
state ?? State,
DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
error ?? Error
);
}
public ProgressInfo ToProgressInfo()
{
return new ProgressInfo
{
TaskId = Id,
Status = State switch
{
TaskState.Running => ProgressStatus.Running,
TaskState.Completed => ProgressStatus.Completed,
TaskState.Failed => ProgressStatus.Failed,
TaskState.Cancelled => ProgressStatus.Canceled,
_ => ProgressStatus.Failed
},
ProgressPercent = Total > 0 ? ((double)Current * 100) / (double)Total : 0,
ErrorMessage = Error ?? string.Empty
};
}
}
public sealed class ProgressTracker
{
private readonly ConcurrentDictionary<string, TaskProgress> _tasks = new();
private readonly Timer _cleaner;
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
// 构造器支持可选的Hub注入
public ProgressTracker(IHubContext<ProgressHub, IProgressReceiver> hubContext)
{
_hubContext = hubContext;
_cleaner = new Timer(CleanExpiredTasks, null,
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public void CleanExpiredTasks(object? obj)
{
var cutoff = DateTimeOffset.Now.AddMinutes(-3).ToUnixTimeSeconds();
var expired = _tasks.Where(kvp => kvp.Value.Timestamp < cutoff).Select(kvp => kvp.Key).ToList();
foreach (var id in expired)
{
_tasks.TryRemove(id, out _);
}
}
public string CreateTask(int total)
{
var id = Guid.NewGuid().ToString();
var task = new TaskProgress(id, 0, total, TaskState.Running, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
_tasks[id] = task;
NotifyIfNeeded(task);
return id;
}
// 核心更新方法,现在包含自动通知
public bool UpdateTask(string id, Func<TaskProgress, TaskProgress> updater)
{
if (!_tasks.TryGetValue(id, out var current))
return false;
var updated = updater(current);
if (_tasks.TryUpdate(id, updated, current))
{
NotifyIfNeeded(updated);
return true;
}
return false;
}
// 自动通知逻辑 - 简单直接
private void NotifyIfNeeded(TaskProgress task)
{
_hubContext.Clients.Group(task.Id).OnReceiveProgress(task.ToProgressInfo());
}
public bool UpdateProgress(string id, int current)
{
return UpdateTask(id, p => p.WithUpdate(
current: Math.Min(current, p.Total)));
}
public bool AdvanceProgress(string id, int steps)
{
return UpdateTask(id, p => p.WithUpdate(
current: Math.Min(p.Current + steps, p.Total)));
}
public bool CancelProgress(string id)
{
return UpdateTask(id, p => p.WithUpdate(state: TaskState.Cancelled));
}
public bool CompleteProgress(string id)
{
return UpdateTask(id, p => p.WithUpdate(
current: p.Total, state: TaskState.Completed));
}
public bool FailProgress(string id, string? error)
{
return UpdateTask(id, p => p.WithUpdate(
state: TaskState.Failed, error: error));
}
public TaskProgress? GetTask(string id)
{
_tasks.TryGetValue(id, out var task);
return task.Id == null ? null : task;
}
}

View File

@@ -1,288 +0,0 @@
using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;
using DotNext;
using Common;
using server.Hubs;
namespace server.Services;
public class ProgressReporter : ProgressInfo, IProgress<int>
{
private int _progress = 0;
private int _stepProgress = 1;
private int _expectedSteps = 100;
private int _parentProportion = 100;
public int Progress => _progress;
public int MaxProgress { get; set; } = 100;
public int StepProgress
{
get => _stepProgress;
set
{
_stepProgress = value;
ExpectedSteps = MaxProgress / value;
}
}
public int ExpectedSteps
{
get => _expectedSteps;
set
{
_expectedSteps = value;
MaxProgress = Number.IntPow(10, Number.GetLength(value));
StepProgress = MaxProgress / value;
}
}
public Func<int, Task>? ReporterFunc { get; set; } = null;
public ProgressReporter? Parent { get; set; }
public ProgressReporter? Child { get; set; }
private ProgressStatus _status = ProgressStatus.Pending;
private string _errorMessage;
public string TaskId { get; set; } = new Guid().ToString();
public int ProgressPercent => _progress * 100 / MaxProgress;
public ProgressStatus Status => _status;
public string ErrorMessage => _errorMessage;
public ProgressReporter(Func<int, Task>? reporter = null, int initProgress = 0, int maxProgress = 100, int step = 1)
{
_progress = initProgress;
MaxProgress = maxProgress;
StepProgress = step;
ReporterFunc = reporter;
}
public ProgressReporter(int parentProportion, int expectedSteps = 100, Func<int, Task>? reporter = null)
{
this._parentProportion = parentProportion;
MaxProgress = Number.IntPow(10, Number.GetLength(expectedSteps));
StepProgress = MaxProgress / expectedSteps;
ReporterFunc = reporter;
}
private async void ForceReport(int value)
{
try
{
if (ReporterFunc != null)
await ReporterFunc(value);
if (Parent != null)
Parent.Increase((value - _progress) / StepProgress * _parentProportion / (MaxProgress / StepProgress));
_progress = value;
}
catch (OperationCanceledException ex)
{
_errorMessage = ex.Message;
this._status = ProgressStatus.Canceled;
}
catch (Exception ex)
{
_errorMessage = ex.Message;
this._status = ProgressStatus.Failed;
}
}
public async void Report(int value)
{
if (this._status == ProgressStatus.Pending)
this._status = ProgressStatus.InProgress;
else if (this.Status != ProgressStatus.InProgress)
return;
if (value > MaxProgress) return;
ForceReport(value);
}
public void Increase(int? value = null)
{
if (this._status == ProgressStatus.Pending)
this._status = ProgressStatus.InProgress;
else if (this.Status != ProgressStatus.InProgress)
return;
if (value.HasValue)
{
if (_progress + value.Value >= MaxProgress) return;
this.Report(_progress + value.Value);
}
else
{
if (_progress + StepProgress >= MaxProgress) return;
this.Report(_progress + StepProgress);
}
}
public void Finish()
{
this._status = ProgressStatus.Completed;
this.ForceReport(MaxProgress);
}
public void Cancel()
{
this._status = ProgressStatus.Canceled;
this._errorMessage = "User Cancelled";
this.ForceReport(_progress);
}
public void Error(string message)
{
this._status = ProgressStatus.Failed;
this._errorMessage = message;
this.ForceReport(_progress);
}
public ProgressReporter CreateChild(int proportion, int expectedSteps = 100)
{
var child = new ProgressReporter(proportion, expectedSteps);
child.Parent = this;
this.Child = child;
return child;
}
}
public class ProgressTrackerService : BackgroundService
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private readonly ConcurrentDictionary<string, TaskProgressInfo> _taskMap = new();
private readonly IHubContext<ProgressHub, IProgressReceiver> _hubContext;
private class TaskProgressInfo
{
public ProgressReporter Reporter { get; set; }
public string? ConnectionId { get; set; }
public required CancellationToken CancellationToken { get; set; }
public required CancellationTokenSource CancellationTokenSource { get; set; }
public required DateTime UpdatedAt { get; set; }
}
public ProgressTrackerService(IHubContext<ProgressHub, IProgressReceiver> hubContext)
{
_hubContext = hubContext;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTime.UtcNow;
foreach (var kvp in _taskMap)
{
var info = kvp.Value;
// 超过 1 分钟且任务已完成/失败/取消
if ((now - info.UpdatedAt).TotalMinutes > 1 &&
(info.Reporter.Status == ProgressStatus.Completed ||
info.Reporter.Status == ProgressStatus.Failed ||
info.Reporter.Status == ProgressStatus.Canceled))
{
_taskMap.TryRemove(kvp.Key, out _);
logger.Info($"Cleaned up task {kvp.Key}");
}
}
}
catch (Exception ex)
{
logger.Error(ex, "Error during ProgressTracker cleanup");
}
await Task.Delay(TimeSpan.FromSeconds(30));
}
}
public (string, ProgressReporter) CreateTask(CancellationToken? cancellationToken = null)
{
CancellationTokenSource? cancellationTokenSource;
if (cancellationToken.HasValue)
{
cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value);
}
else
{
cancellationTokenSource = new CancellationTokenSource();
}
var progressInfo = new TaskProgressInfo
{
ConnectionId = null,
UpdatedAt = DateTime.UtcNow,
CancellationToken = cancellationTokenSource.Token,
CancellationTokenSource = cancellationTokenSource,
};
var progress = new ProgressReporter(async value =>
{
cancellationTokenSource.Token.ThrowIfCancellationRequested();
// 通过 SignalR 推送进度
if (progressInfo.ConnectionId != null)
await _hubContext.Clients.Client(progressInfo.ConnectionId).OnReceiveProgress(progressInfo.Reporter);
});
progressInfo.Reporter = progress;
_taskMap.TryAdd(progressInfo.Reporter.TaskId, progressInfo);
return (progressInfo.Reporter.TaskId, progress);
}
public Optional<ProgressReporter> GetReporter(string taskId)
{
if (_taskMap.TryGetValue(taskId, out var info))
{
return info.Reporter;
}
return Optional<ProgressReporter>.None;
}
public Optional<ProgressStatus> GetProgressStatus(string taskId)
{
if (_taskMap.TryGetValue(taskId, out var info))
{
return info.Reporter.Status;
}
return Optional<ProgressStatus>.None;
}
public bool BindTask(string taskId, string connectionId)
{
if (_taskMap.TryGetValue(taskId, out var info) && info != null)
{
lock (info)
{
info.ConnectionId = connectionId;
}
return true;
}
return false;
}
public bool CancelTask(string taskId)
{
try
{
if (_taskMap.TryGetValue(taskId, out var info) && info != null)
{
lock (info)
{
info.CancellationTokenSource.Cancel();
info.Reporter.Cancel();
info.UpdatedAt = DateTime.UtcNow;
}
return true;
}
return false;
}
catch (Exception ex)
{
logger.Error(ex, $"Failed to cancel task {taskId}");
return false;
}
}
}

View File

@@ -8,11 +8,11 @@ using server.Services;
/// <summary>
/// UDP客户端发送池
/// </summary>
public class UDPClientPool
public sealed class UDPClientPool
{
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
private static IPAddress localhost = IPAddress.Parse("127.0.0.1");
private static ProgressTracker _progressTracker = MsgBus.ProgressTracker;
/// <summary>
/// 发送字符串
@@ -183,62 +183,34 @@ public class UDPClientPool
return await Task.Run(() => { return SendDataPack(endPoint, pkg); });
}
/// <summary>
/// 发送字符串到本地
/// </summary>
/// <param name="port">端口</param>
/// <param name="stringArray">字符串数组</param>
/// <returns>是否成功</returns>
public static bool SendStringLocalHost(int port, string[] stringArray)
{
return SendString(new IPEndPoint(localhost, port), stringArray);
}
/// <summary>
/// 循环发送字符串到本地
/// </summary>
/// <param name="times">发送总次数</param>
/// <param name="sleepMilliSeconds">间隔时间</param>
/// <param name="port">端口</param>
/// <param name="stringArray">字符串数组</param>
/// <returns>是否成功</returns>
public static bool CycleSendStringLocalHost(int times, int sleepMilliSeconds, int port, string[] stringArray)
{
var isSuccessful = true;
while (times-- >= 0)
{
isSuccessful = SendStringLocalHost(port, stringArray);
if (!isSuccessful) break;
Thread.Sleep(sleepMilliSeconds);
}
return isSuccessful;
}
/// <summary>
/// 读取设备地址数据
/// </summary>
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param>
/// <param name="dataLength">数据长度(0~255)</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的数据包</returns>
public static async ValueTask<Result<RecvDataPackage>> ReadAddr(
IPEndPoint endPoint, int taskID, uint devAddr, int timeout = 1000)
IPEndPoint endPoint, int taskID, uint devAddr, int dataLength, int timeout = 1000)
{
if (dataLength <= 0) return new(new ArgumentException(
$"Data length must be greater than 0, instead of {dataLength}"));
if (dataLength > 255) return new(new ArgumentException(
$"Data length must be less than or equal to 255, instead of {dataLength}"));
var ret = false;
var opts = new SendAddrPackOptions()
{
BurstType = BurstType.FixedBurst,
BurstLength = 0,
BurstLength = ((byte)(dataLength - 1)),
CommandID = Convert.ToByte(taskID),
Address = devAddr,
IsWrite = false,
};
// Read Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send Address Package Failed!"));
@@ -260,6 +232,20 @@ public class UDPClientPool
return retPack;
}
/// <summary>
/// 读取设备地址数据
/// </summary>
/// <param name="endPoint">IP端点IP地址与端口</param>
/// <param name="taskID">任务ID</param>
/// <param name="devAddr">设备地址</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>读取结果,包含接收到的数据包</returns>
public static async ValueTask<Result<RecvDataPackage>> ReadAddrByte(
IPEndPoint endPoint, int taskID, uint devAddr, int timeout = 1000)
{
return await ReadAddr(endPoint, taskID, devAddr, 1, timeout);
}
/// <summary>
/// 读取设备地址数据并校验结果
/// </summary>
@@ -275,7 +261,7 @@ public class UDPClientPool
{
var address = endPoint.Address.ToString();
var ret = await ReadAddr(endPoint, taskID, devAddr, timeout);
var ret = await ReadAddrByte(endPoint, taskID, devAddr, timeout);
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value.IsSuccessful)
return new(new Exception($"Read device {address} address {devAddr} failed"));
@@ -311,7 +297,9 @@ public class UDPClientPool
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>校验结果true表示在超时前数据匹配期望值</returns>
public static async ValueTask<Result<bool>> ReadAddrWithWait(
IPEndPoint endPoint, int taskID, uint devAddr, UInt32 result, UInt32 resultMask, int waittime = 100, int timeout = 1000)
IPEndPoint endPoint, int taskID, uint devAddr,
UInt32 result, UInt32 resultMask,
int waittime = 100, int timeout = 1000)
{
var address = endPoint.Address.ToString();
@@ -324,7 +312,7 @@ public class UDPClientPool
await Task.Delay(waittime);
try
{
var ret = await ReadAddr(endPoint, taskID, devAddr, Convert.ToInt32(timeleft.TotalMilliseconds));
var ret = await ReadAddrByte(endPoint, taskID, devAddr, Convert.ToInt32(timeleft.TotalMilliseconds));
if (!ret.IsSuccessful) return new(ret.Error);
if (!ret.Value.IsSuccessful)
return new(new Exception($"Read device {address} address {devAddr} failed"));
@@ -555,7 +543,7 @@ public class UDPClientPool
var resultData = new List<byte>();
for (int i = 0; i < length; i++)
{
var ret = await ReadAddr(endPoint, taskID, addr[i], timeout);
var ret = await ReadAddrByte(endPoint, taskID, addr[i], timeout);
if (!ret.IsSuccessful)
{
logger.Error($"ReadAddrSeq failed at index {i}: {ret.Error}");
@@ -585,10 +573,11 @@ public class UDPClientPool
/// <param name="devAddr">设备地址</param>
/// <param name="data">要写入的32位数据</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <param name="progressId">进度报告器</param>
/// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr,
UInt32 data, int timeout = 1000, ProgressReporter? progress = null)
UInt32 data, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -599,17 +588,18 @@ public class UDPClientPool
Address = devAddr,
IsWrite = true,
};
progress?.Report(20);
_progressTracker.AdvanceProgress(progressId, 10);
// Write Register
ret = await UDPClientPool.SendAddrPackAsync(endPoint, new SendAddrPackage(opts));
if (!ret) return new(new Exception("Send 1st address package failed!"));
progress?.Report(40);
_progressTracker.AdvanceProgress(progressId, 10);
// Send Data Package
ret = await UDPClientPool.SendDataPackAsync(endPoint,
new SendDataPackage(Common.Number.NumberToBytes(data, 4).Value));
if (!ret) return new(new Exception("Send data package failed!"));
progress?.Report(60);
_progressTracker.AdvanceProgress(progressId, 10);
// Check Msg Bus
if (!MsgBus.IsRunning)
@@ -619,7 +609,7 @@ public class UDPClientPool
var udpWriteAck = await MsgBus.UDPServer.WaitForAckAsync(
endPoint.Address.ToString(), taskID, endPoint.Port, timeout);
if (!udpWriteAck.IsSuccessful) return new(udpWriteAck.Error);
progress?.Finish();
_progressTracker.AdvanceProgress(progressId, 10);
return udpWriteAck.Value.IsSuccessful;
}
@@ -632,10 +622,11 @@ public class UDPClientPool
/// <param name="devAddr">设备地址</param>
/// <param name="dataArray">要写入的字节数组</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <param name="progressId">进度报告器</param>
/// <returns>写入结果true表示写入成功</returns>
public static async ValueTask<Result<bool>> WriteAddr(
IPEndPoint endPoint, int taskID, UInt32 devAddr,
byte[] dataArray, int timeout = 1000, ProgressReporter? progress = null)
byte[] dataArray, int timeout = 1000, string progressId = "")
{
var ret = false;
var opts = new SendAddrPackOptions()
@@ -657,8 +648,6 @@ public class UDPClientPool
var writeTimes = hasRest ?
dataArray.Length / (max4BytesPerRead * (32 / 8)) + 1 :
dataArray.Length / (max4BytesPerRead * (32 / 8));
if (progress != null)
progress.ExpectedSteps = writeTimes;
for (var i = 0; i < writeTimes; i++)
{
// Sperate Data Array
@@ -688,10 +677,9 @@ public class UDPClientPool
if (!udpWriteAck.Value.IsSuccessful)
return false;
progress?.Increase();
_progressTracker.AdvanceProgress(progressId, 1);
}
progress?.Finish();
return true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,13 +4,13 @@ import Dialog from "./components/Dialog.vue";
import { Alert, useAlertProvider } from "./components/Alert";
import { ref, provide, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useThemeStore } from "./stores/theme";
const router = useRouter();
const theme = useThemeStore();
// 主题切换状态管理
const isDarkMode = ref(
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
const isDarkMode = ref(theme.isDarkTheme());
// Navbar显示状态管理
const showNavbar = ref(true);
@@ -46,6 +46,7 @@ const applyTheme = () => {
// 切换主题
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value;
theme.toggleTheme();
applyTheme();
};

View File

@@ -1,3 +1,6 @@
import { ResourceClient, ResourcePurpose } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
// 定义 diagram.json 的类型结构
export interface DiagramData {
version: number;
@@ -26,11 +29,12 @@ export interface DiagramPart {
// 连接类型定义 - 使用元组类型表示四元素数组
export type ConnectionArray = [string, string, number, string[]];
import { AuthManager } from '@/utils/AuthManager';
// 解析连接字符串为组件ID和引脚ID
export function parseConnectionPin(connectionPin: string): { componentId: string; pinId: string } {
const [componentId, pinId] = connectionPin.split(':');
export function parseConnectionPin(connectionPin: string): {
componentId: string;
pinId: string;
} {
const [componentId, pinId] = connectionPin.split(":");
return { componentId, pinId };
}
@@ -39,11 +43,13 @@ export function connectionArrayToWireItem(
connection: ConnectionArray,
index: number,
startPos = { x: 0, y: 0 },
endPos = { x: 0, y: 0 }
endPos = { x: 0, y: 0 },
): WireItem {
const [startPinStr, endPinStr, width, path] = connection;
const { componentId: startComponentId, pinId: startPinId } = parseConnectionPin(startPinStr);
const { componentId: endComponentId, pinId: endPinId } = parseConnectionPin(endPinStr);
const { componentId: startComponentId, pinId: startPinId } =
parseConnectionPin(startPinStr);
const { componentId: endComponentId, pinId: endPinId } =
parseConnectionPin(endPinStr);
return {
id: `wire-${index}`,
@@ -56,10 +62,10 @@ export function connectionArrayToWireItem(
endComponentId,
endPinId,
strokeWidth: width,
color: '#4a5568', // 默认颜色
routingMode: 'path',
color: "#4a5568", // 默认颜色
routingMode: "path",
pathCommands: path,
showLabel: false
showLabel: false,
};
}
@@ -76,7 +82,7 @@ export interface WireItem {
endPinId?: string;
strokeWidth: number;
color: string;
routingMode: 'orthogonal' | 'path';
routingMode: "orthogonal" | "path";
constraint?: string;
pathCommands?: string[];
showLabel: boolean;
@@ -88,17 +94,23 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
// 如果提供了examId优先从API加载实验的diagram
if (examId) {
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
// 获取diagram类型的资源列表
const resources = await resourceClient.getResourceList(examId, 'canvas', 'template');
const resources = await resourceClient.getResourceList(
examId,
"canvas",
ResourcePurpose.Template,
);
if (resources && resources.length > 0) {
// 获取第一个diagram资源
const diagramResource = resources[0];
// 使用动态API获取资源文件内容
const response = await resourceClient.getResourceById(diagramResource.id);
const response = await resourceClient.getResourceById(
diagramResource.id,
);
if (response && response.data) {
const text = await response.data.text();
@@ -107,24 +119,24 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
// 验证数据格式
const validation = validateDiagramData(data);
if (validation.isValid) {
console.log('成功从API加载实验diagram:', examId);
console.log("成功从API加载实验diagram:", examId);
return data;
} else {
console.warn('API返回的diagram数据格式无效:', validation.errors);
console.warn("API返回的diagram数据格式无效:", validation.errors);
}
}
} else {
console.log('未找到实验diagram资源使用默认加载方式');
console.log("未找到实验diagram资源使用默认加载方式");
}
} catch (error) {
console.warn('从API加载实验diagram失败使用默认加载方式:', error);
console.warn("从API加载实验diagram失败使用默认加载方式:", error);
}
}
// 如果没有examId或API加载失败尝试从静态文件加载不再使用本地存储
// 从静态文件加载(作为备选方案)
const response = await fetch('/src/components/diagram.json');
const response = await fetch("/src/components/diagram.json");
if (!response.ok) {
throw new Error(`Failed to load diagram.json: ${response.statusText}`);
}
@@ -135,11 +147,11 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
if (validation.isValid) {
return data;
} else {
console.warn('静态diagram文件数据格式无效:', validation.errors);
throw new Error('所有diagram数据源都无效');
console.warn("静态diagram文件数据格式无效:", validation.errors);
throw new Error("所有diagram数据源都无效");
}
} catch (error) {
console.error('Error loading diagram data:', error);
console.error("Error loading diagram data:", error);
// 返回空的默认数据结构
return createEmptyDiagram();
}
@@ -149,17 +161,17 @@ export async function loadDiagramData(examId?: string): Promise<DiagramData> {
export function createEmptyDiagram(): DiagramData {
return {
version: 1,
author: 'user',
editor: 'user',
author: "user",
editor: "user",
parts: [],
connections: []
connections: [],
};
}
// 保存图表数据(已禁用本地存储)
export function saveDiagramData(data: DiagramData): void {
// 本地存储功能已禁用 - 不再保存到localStorage
console.debug('saveDiagramData called but localStorage saving is disabled');
console.debug("saveDiagramData called but localStorage saving is disabled");
}
// 更新组件位置
@@ -167,15 +179,13 @@ export function updatePartPosition(
data: DiagramData,
partId: string,
x: number,
y: number
y: number,
): DiagramData {
return {
...data,
parts: data.parts.map(part =>
part.id === partId
? { ...part, x, y }
: part
)
parts: data.parts.map((part) =>
part.id === partId ? { ...part, x, y } : part,
),
};
}
@@ -184,21 +194,21 @@ export function updatePartAttribute(
data: DiagramData,
partId: string,
attrName: string,
value: any
value: any,
): DiagramData {
return {
...data,
parts: data.parts.map(part =>
parts: data.parts.map((part) =>
part.id === partId
? {
...part,
attrs: {
...part.attrs,
[attrName]: value
[attrName]: value,
},
}
}
: part
)
: part,
),
};
}
@@ -210,72 +220,79 @@ export function addConnection(
endComponentId: string,
endPinId: string,
width: number = 2,
path: string[] = []
path: string[] = [],
): DiagramData {
const newConnection: ConnectionArray = [
`${startComponentId}:${startPinId}`,
`${endComponentId}:${endPinId}`,
width,
path
path,
];
return {
...data,
connections: [...data.connections, newConnection]
connections: [...data.connections, newConnection],
};
}
// 删除连接
export function deleteConnection(
data: DiagramData,
connectionIndex: number
connectionIndex: number,
): DiagramData {
return {
...data,
connections: data.connections.filter((_, index) => index !== connectionIndex)
connections: data.connections.filter(
(_, index) => index !== connectionIndex,
),
};
}
// 查找与组件关联的所有连接
export function findConnectionsByPart(
data: DiagramData,
partId: string
partId: string,
): { connection: ConnectionArray; index: number }[] {
return data.connections
.map((connection, index) => ({ connection, index }))
.filter(({ connection }) => {
const [startPin, endPin] = connection;
const startCompId = startPin.split(':')[0];
const endCompId = endPin.split(':')[0];
const startCompId = startPin.split(":")[0];
const endCompId = endPin.split(":")[0];
return startCompId === partId || endCompId === partId;
});
}
// 添加验证diagram.json文件的函数
export function validateDiagramData(data: any): { isValid: boolean; errors: string[] } {
export function validateDiagramData(data: any): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
// 检查版本号
if (!data.version) {
errors.push('缺少version字段');
errors.push("缺少version字段");
}
// 检查parts数组
if (!Array.isArray(data.parts)) {
errors.push('parts字段不是数组');
errors.push("parts字段不是数组");
} else {
// 验证parts中的每个对象
data.parts.forEach((part: any, index: number) => {
if (!part.id) errors.push(`parts[${index}]缺少id`);
if (!part.type) errors.push(`parts[${index}]缺少type`);
if (typeof part.x !== 'number') errors.push(`parts[${index}]缺少有效的x坐标`);
if (typeof part.y !== 'number') errors.push(`parts[${index}]缺少有效的y坐标`);
if (typeof part.x !== "number")
errors.push(`parts[${index}]缺少有效的x坐标`);
if (typeof part.y !== "number")
errors.push(`parts[${index}]缺少有效的y坐标`);
});
}
// 检查connections数组
if (!Array.isArray(data.connections)) {
errors.push('connections字段不是数组');
errors.push("connections字段不是数组");
} else {
// 验证connections中的每个数组
data.connections.forEach((conn: any, index: number) => {
@@ -286,15 +303,15 @@ export function validateDiagramData(data: any): { isValid: boolean; errors: stri
const [startPin, endPin, width] = conn;
if (typeof startPin !== 'string' || !startPin.includes(':')) {
if (typeof startPin !== "string" || !startPin.includes(":")) {
errors.push(`connections[${index}]的起始针脚格式无效`);
}
if (typeof endPin !== 'string' || !endPin.includes(':')) {
if (typeof endPin !== "string" || !endPin.includes(":")) {
errors.push(`connections[${index}]的结束针脚格式无效`);
}
if (typeof width !== 'number') {
if (typeof width !== "number") {
errors.push(`connections[${index}]的宽度不是有效的数字`);
}
});
@@ -302,6 +319,6 @@ export function validateDiagramData(data: any): { isValid: boolean; errors: stri
return {
isValid: errors.length === 0,
errors
errors,
};
}

View File

@@ -29,9 +29,10 @@ export interface TemplateConfig {
export const previewSizes: Record<string, number> = {
MechanicalButton: 0.4,
Switch: 0.35,
EC11RotaryEncoder: 0.4,
Pin: 0.8,
SMT_LED: 0.7,
SevenSegmentDisplay: 0.4,
SevenSegmentDisplayUltimate: 0.4,
HDMI: 0.5,
DDR: 0.5,
ETH: 0.5,
@@ -48,9 +49,10 @@ export const previewSizes: Record<string, number> = {
export const availableComponents: ComponentConfig[] = [
{ type: "MechanicalButton", name: "机械按钮" },
{ type: "Switch", name: "开关" },
{ type: "EC11RotaryEncoder", name: "EC11旋转编码器" },
{ type: "Pin", name: "引脚" },
{ type: "SMT_LED", name: "贴片LED" },
{ type: "SevenSegmentDisplay", name: "数码管" },
{ type: "SevenSegmentDisplayUltimate", name: "数码管" },
{ type: "HDMI", name: "HDMI接口" },
{ type: "DDR", name: "DDR内存" },
{ type: "ETH", name: "以太网接口" },

View File

@@ -31,8 +31,16 @@ export type Channel = {
// 全局模式选项
const globalModes = [
{value: GlobalCaptureMode.AND,label: "AND",description: "所有条件都满足时触发",},
{value: GlobalCaptureMode.OR,label: "OR",description: "任一条件满足时触发",},
{
value: GlobalCaptureMode.AND,
label: "AND",
description: "所有条件都满足时触发",
},
{
value: GlobalCaptureMode.OR,
label: "OR",
description: "任一条件满足时触发",
},
{ value: GlobalCaptureMode.NAND, label: "NAND", description: "AND的非" },
{ value: GlobalCaptureMode.NOR, label: "NOR", description: "OR的非" },
];
@@ -70,14 +78,46 @@ const channelDivOptions = [
];
const ClockDivOptions = [
{ value: AnalyzerClockDiv.DIV1, label: "120MHz", description: "采样频率120MHz" },
{ value: AnalyzerClockDiv.DIV2, label: "60MHz", description: "采样频率60MHz" },
{ value: AnalyzerClockDiv.DIV4, label: "30MHz", description: "采样频率30MHz" },
{ value: AnalyzerClockDiv.DIV8, label: "15MHz", description: "采样频率15MHz" },
{ value: AnalyzerClockDiv.DIV16, label: "7.5MHz", description: "采样频率7.5MHz" },
{ value: AnalyzerClockDiv.DIV32, label: "3.75MHz", description: "采样频率3.75MHz" },
{ value: AnalyzerClockDiv.DIV64, label: "1.875MHz", description: "采样频率1.875MHz" },
{ value: AnalyzerClockDiv.DIV128, label: "937.5KHz", description: "采样频率937.5KHz" },
{
value: AnalyzerClockDiv.DIV1,
label: "120MHz",
description: "采样频率120MHz",
},
{
value: AnalyzerClockDiv.DIV2,
label: "60MHz",
description: "采样频率60MHz",
},
{
value: AnalyzerClockDiv.DIV4,
label: "30MHz",
description: "采样频率30MHz",
},
{
value: AnalyzerClockDiv.DIV8,
label: "15MHz",
description: "采样频率15MHz",
},
{
value: AnalyzerClockDiv.DIV16,
label: "7.5MHz",
description: "采样频率7.5MHz",
},
{
value: AnalyzerClockDiv.DIV32,
label: "3.75MHz",
description: "采样频率3.75MHz",
},
{
value: AnalyzerClockDiv.DIV64,
label: "1.875MHz",
description: "采样频率1.875MHz",
},
{
value: AnalyzerClockDiv.DIV128,
label: "937.5KHz",
description: "采样频率937.5KHz",
},
];
// 捕获深度限制常量
@@ -170,40 +210,64 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 转换通道数字到枚举值
const getChannelDivEnum = (channelCount: number): AnalyzerChannelDiv => {
switch (channelCount) {
case 1: return AnalyzerChannelDiv.ONE;
case 2: return AnalyzerChannelDiv.TWO;
case 4: return AnalyzerChannelDiv.FOUR;
case 8: return AnalyzerChannelDiv.EIGHT;
case 16: return AnalyzerChannelDiv.XVI;
case 32: return AnalyzerChannelDiv.XXXII;
default: return AnalyzerChannelDiv.EIGHT;
case 1:
return AnalyzerChannelDiv.ONE;
case 2:
return AnalyzerChannelDiv.TWO;
case 4:
return AnalyzerChannelDiv.FOUR;
case 8:
return AnalyzerChannelDiv.EIGHT;
case 16:
return AnalyzerChannelDiv.XVI;
case 32:
return AnalyzerChannelDiv.XXXII;
default:
return AnalyzerChannelDiv.EIGHT;
}
};
// 验证捕获深度
const validateCaptureLength = (value: number): { valid: boolean; message?: string } => {
const validateCaptureLength = (
value: number,
): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "捕获深度必须是整数" };
}
if (value < CAPTURE_LENGTH_MIN) {
return { valid: false, message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}` };
return {
valid: false,
message: `捕获深度不能小于 ${CAPTURE_LENGTH_MIN}`,
};
}
if (value > CAPTURE_LENGTH_MAX) {
return { valid: false, message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}` };
return {
valid: false,
message: `捕获深度不能大于 ${CAPTURE_LENGTH_MAX.toLocaleString()}`,
};
}
return { valid: true };
};
// 验证预捕获深度
const validatePreCaptureLength = (value: number, currentCaptureLength: number): { valid: boolean; message?: string } => {
const validatePreCaptureLength = (
value: number,
currentCaptureLength: number,
): { valid: boolean; message?: string } => {
if (!Number.isInteger(value)) {
return { valid: false, message: "预捕获深度必须是整数" };
}
if (value < PRE_CAPTURE_LENGTH_MIN) {
return { valid: false, message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}` };
return {
valid: false,
message: `预捕获深度不能小于 ${PRE_CAPTURE_LENGTH_MIN}`,
};
}
if (value >= currentCaptureLength) {
return { valid: false, message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})` };
return {
valid: false,
message: `预捕获深度不能大于等于捕获深度 (${currentCaptureLength})`,
};
}
return { valid: true };
};
@@ -241,7 +305,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
// 设置通道组
const setChannelDiv = (channelCount: number) => {
// 验证通道数量是否有效
if (!channelDivOptions.find(option => option.value === channelCount)) {
if (!channelDivOptions.find((option) => option.value === channelCount)) {
console.error(`无效的通道组设置: ${channelCount}`);
return;
}
@@ -257,7 +321,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
channels[i].enabled = true;
}
const option = channelDivOptions.find(opt => opt.value === channelCount);
const option = channelDivOptions.find(
(opt) => opt.value === channelCount,
);
alert?.success(`已设置为${option?.label}`, 2000);
};
@@ -294,7 +360,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
const getCaptureData = async () => {
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 获取捕获数据,使用当前设置的捕获长度
const base64Data = await client.getCaptureData(captureLength.value);
@@ -324,10 +390,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 1 },
() => new Array(sampleCount),
);
y = Array.from({ length: 1 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应8个时间单位
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
@@ -348,10 +411,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 2 },
() => new Array(sampleCount),
);
y = Array.from({ length: 2 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应4个时间单位的2通道数据
// 位分布:[T3_CH1, T3_CH0, T2_CH1, T2_CH0, T1_CH1, T1_CH0, T0_CH1, T0_CH0]
@@ -375,10 +435,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建通道数据数组
y = Array.from(
{ length: 4 },
() => new Array(sampleCount),
);
y = Array.from({ length: 4 }, () => new Array(sampleCount));
// 解析数据每个字节的8个位对应2个时间单位的4通道数据
// 位分布:[T1_CH3, T1_CH2, T1_CH1, T1_CH0, T0_CH3, T0_CH2, T0_CH1, T0_CH0]
@@ -408,10 +465,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建8个通道的数据
y = Array.from(
{ length: 8 },
() => new Array(sampleCount),
);
y = Array.from({ length: 8 }, () => new Array(sampleCount));
// 解析每个字节的8个位到对应通道
for (let i = 0; i < sampleCount; i++) {
@@ -432,10 +486,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建16个通道的数据
y = Array.from(
{ length: 16 },
() => new Array(sampleCount),
);
y = Array.from({ length: 16 }, () => new Array(sampleCount));
// 解析数据每2个字节为一个时间单位
for (let timeIndex = 0; timeIndex < sampleCount; timeIndex++) {
@@ -464,10 +515,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
); // 转换为微秒
// 创建32个通道的数据
y = Array.from(
{ length: 32 },
() => new Array(sampleCount),
);
y = Array.from({ length: 32 }, () => new Array(sampleCount));
// 解析数据每4个字节为一个时间单位
for (let timeIndex = 0; timeIndex < sampleCount; timeIndex++) {
@@ -525,7 +573,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 1. 先应用配置
alert?.info("正在应用配置...", 2000);
@@ -632,7 +680,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 执行强制捕获来停止当前捕获
const forceSuccess = await client.setCaptureMode(false, false);
@@ -661,7 +709,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedLogicAnalyzerClient();
const client = AuthManager.createClient(LogicAnalyzerClient);
// 执行强制捕获来停止当前捕获
const forceSuccess = await client.setCaptureMode(true, true);
@@ -677,7 +725,7 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
`强制捕获失败: ${error instanceof Error ? error.message : "未知错误"}`,
3000,
);
} finally{
} finally {
release();
}
};

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { ref } from "vue";
import { MdEditor } from "md-editor-v3";
import "md-editor-v3/lib/style.css";
import { useThemeStore } from "@/stores/theme";
const theme = useThemeStore();
const text = ref("# Hello Editor");
async function handleSaveEvent(v: string, h: Promise<string>) {}
async function loadMarkdownFromString(markdown: string) {
text.value = markdown;
}
async function loadMarkdownFromUrl(url: string) {
const response = await fetch(url);
const markdown = await response.text();
text.value = markdown;
}
defineExpose({
loadMarkdownFromString,
loadMarkdownFromUrl,
});
</script>
<template>
<MdEditor v-model="text" :theme="theme.currentMode" />
</template>
<style lang="postcss" scoped></style>

View File

@@ -1,26 +1,27 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { marked } from 'marked';
import hljs from 'highlight.js';
import { computed, onMounted, ref, watch } from "vue";
import { marked } from "marked";
import hljs from "highlight.js";
// 导入亮色主题样式
import 'highlight.js/styles/github.css'; // 亮色主题
import "highlight.js/styles/github.css"; // 亮色主题
// 导入主题存储
import { useThemeStore } from '@/stores/theme';
import { AuthManager } from '@/utils/AuthManager';
import { useThemeStore } from "@/stores/theme";
import { AuthManager } from "@/utils/AuthManager";
import { ResourceClient, ResourcePurpose } from "@/APIClient";
const props = defineProps({
content: {
type: String,
required: true
required: true,
},
removeFirstH1: {
type: Boolean,
default: false
default: false,
},
examId: {
type: String,
default: ''
}
default: "",
},
});
// 使用主题存储
@@ -32,17 +33,26 @@ const isDarkMode = computed(() => themeStore.isDarkTheme());
const imageResourceCache = ref<Map<string, string>>(new Map());
// 获取图片资源ID的函数
async function getImageResourceId(examId: string, imagePath: string): Promise<string | null> {
async function getImageResourceId(
examId: string,
imagePath: string,
): Promise<string | null> {
try {
const client = AuthManager.createAuthenticatedResourceClient();
const resources = await client.getResourceList(examId, 'images', 'template');
const client = AuthManager.createClient(ResourceClient);
const resources = await client.getResourceList(
examId,
"images",
ResourcePurpose.Template,
);
// 查找匹配的图片资源
const imageResource = resources.find(r => r.name === imagePath || r.name.endsWith(imagePath));
const imageResource = resources.find(
(r) => r.name === imagePath || r.name.endsWith(imagePath),
);
return imageResource ? imageResource.id.toString() : null;
} catch (error) {
console.error('获取图片资源ID失败:', error);
console.error("获取图片资源ID失败:", error);
return null;
}
}
@@ -50,7 +60,7 @@ async function getImageResourceId(examId: string, imagePath: string): Promise<st
// 通过资源ID获取图片数据URL
async function getImageDataUrl(resourceId: string): Promise<string | null> {
try {
const client = AuthManager.createAuthenticatedResourceClient();
const client = AuthManager.createClient(ResourceClient);
const response = await client.getResourceById(parseInt(resourceId));
if (response && response.data) {
@@ -59,16 +69,19 @@ async function getImageDataUrl(resourceId: string): Promise<string | null> {
return null;
} catch (error) {
console.error('获取图片数据失败:', error);
console.error("获取图片数据失败:", error);
return null;
}
}
// 监听主题变化
watch(() => themeStore.currentTheme, () => {
watch(
() => themeStore.currentTheme,
() => {
// 主题变化时更新代码高亮样式
updateCodeBlocksTheme();
});
},
);
// 更新代码块主题样式
const updateCodeBlocksTheme = () => {
@@ -78,17 +91,17 @@ const updateCodeBlocksTheme = () => {
};
const renderedContent = computed(() => {
if (!props.content) return '<p>没有内容</p>';
if (!props.content) return "<p>没有内容</p>";
let processedContent = props.content;
// 如果需要,移除第一个一级标题
if (props.removeFirstH1) {
const lines = processedContent.split('\n');
const firstH1Index = lines.findIndex(line => line.startsWith('# '));
const lines = processedContent.split("\n");
const firstH1Index = lines.findIndex((line) => line.startsWith("# "));
if (firstH1Index !== -1) {
processedContent = lines.slice(firstH1Index + 1).join('\n');
processedContent = lines.slice(firstH1Index + 1).join("\n");
}
}
@@ -102,28 +115,33 @@ const renderedContent = computed(() => {
console.log(`原始图片路径: ${href}, examId: ${props.examId}`);
// 如果是相对路径且有实验ID需要通过动态API获取
if (props.examId && href && href.startsWith('./')) {
if (props.examId && href && href.startsWith("./")) {
// 对于相对路径的图片我们需要先获取图片资源ID然后通过动态API获取
// 暂时保留原始路径,在后处理中进行替换
src = href;
console.log(`保留原始路径用于后处理: ${src}`);
}
const titleAttr = title ? ` title="${title}"` : '';
const altAttr = text ? ` alt="${text}"` : '';
const dataOriginal = href && href.startsWith('./') ? ` data-original-src="${href}"` : '';
console.log(`最终渲染的HTML: <img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`);
const titleAttr = title ? ` title="${title}"` : "";
const altAttr = text ? ` alt="${text}"` : "";
const dataOriginal =
href && href.startsWith("./") ? ` data-original-src="${href}"` : "";
console.log(
`最终渲染的HTML: <img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`,
);
return `<img src="${src}"${titleAttr}${altAttr}${dataOriginal} />`;
};
// 重写代码块渲染方法,添加语言信息
renderer.code = (code, incomingLanguage) => {
// 确保语言参数是字符串
const language = incomingLanguage || 'plaintext';
const language = incomingLanguage || "plaintext";
// 验证语言
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext';
const validLanguage = hljs.getLanguage(language) ? language : "plaintext";
// 高亮代码
const highlightedCode = hljs.highlight(code, { language: validLanguage }).value;
const highlightedCode = hljs.highlight(code, {
language: validLanguage,
}).value;
// 添加语言标签到代码块
return `<pre class="hljs" data-language="${validLanguage}"><code class="language-${validLanguage}">${highlightedCode}</code></pre>`;
@@ -133,23 +151,30 @@ const renderedContent = computed(() => {
let html = marked.parse(processedContent, {
renderer: renderer,
gfm: true,
breaks: true
breaks: true,
}) as string;
// 后处理HTML异步处理图片
if (props.examId) {
// 查找所有需要处理的图片
const imgMatches = Array.from(html.matchAll(/(<img[^>]+data-original-src=["'])\.\/([^"']+)(["'][^>]*>)/g));
const imgMatches = Array.from(
html.matchAll(
/(<img[^>]+data-original-src=["'])\.\/([^"']+)(["'][^>]*>)/g,
),
);
// 异步处理每个图片
imgMatches.forEach(async (match) => {
const [fullMatch, prefix, path, suffix] = match;
const imagePath = path.replace('images/', '');
const imagePath = path.replace("images/", "");
// 检查缓存
if (imageResourceCache.value.has(imagePath)) {
const cachedUrl = imageResourceCache.value.get(imagePath)!;
html = html.replace(fullMatch, `${prefix}${cachedUrl}${suffix.replace(' data-original-src="./'+path+'"', '')}`);
html = html.replace(
fullMatch,
`${prefix}${cachedUrl}${suffix.replace(' data-original-src="./' + path + '"', "")}`,
);
return;
}
@@ -164,14 +189,19 @@ const renderedContent = computed(() => {
imageResourceCache.value.set(imagePath, dataUrl);
// 更新HTML中的图片src
const updatedHtml = html.replace(fullMatch, `${prefix}${dataUrl}${suffix.replace(' data-original-src="./'+path+'"', '')}`);
const updatedHtml = html.replace(
fullMatch,
`${prefix}${dataUrl}${suffix.replace(' data-original-src="./' + path + '"', "")}`,
);
// 触发重新渲染
setTimeout(() => {
const imgElements = document.querySelectorAll(`img[data-original-src="./${path}"]`);
imgElements.forEach(img => {
const imgElements = document.querySelectorAll(
`img[data-original-src="./${path}"]`,
);
imgElements.forEach((img) => {
(img as HTMLImageElement).src = dataUrl;
img.removeAttribute('data-original-src');
img.removeAttribute("data-original-src");
});
}, 0);
}
@@ -192,7 +222,11 @@ onMounted(() => {
</script>
<template>
<div class="markdown-content" :data-theme="themeStore.currentTheme" v-html="renderedContent"></div>
<div
class="markdown-content"
:data-theme="themeStore.currentTheme"
v-html="renderedContent"
></div>
</template>
<style scoped>
@@ -211,7 +245,7 @@ onMounted(() => {
display: block;
margin: 1rem auto;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.markdown-content :deep(h1) {
@@ -223,7 +257,7 @@ onMounted(() => {
line-height: 1.3;
padding-bottom: 0.7rem;
border-bottom: 2px solid hsl(var(--p) / 0.7);
text-shadow: 1px 1px 2px rgba(0,0,0,0.05);
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.05);
}
.markdown-content :deep(h2) {
@@ -268,7 +302,7 @@ onMounted(() => {
.markdown-content :deep(h4::before),
.markdown-content :deep(h5::before),
.markdown-content :deep(h6::before) {
content: '▶';
content: "▶";
color: hsl(var(--p) / 0.7);
position: absolute;
left: 0.2rem;
@@ -343,13 +377,13 @@ onMounted(() => {
overflow-x: auto;
border: 1px solid hsl(var(--b2));
margin: 1.5rem 0;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
position: relative;
color: var(--code-color, hsl(var(--bc)));
}
.markdown-content :deep(pre::before) {
content: '';
content: "";
position: absolute;
top: 0;
left: 0;
@@ -375,7 +409,9 @@ onMounted(() => {
/* 内联代码样式 */
.markdown-content :deep(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
background-color: var(--inline-code-bg, hsl(var(--b3) / 0.7));
padding: 0.2rem 0.5rem;
border-radius: 0.25rem;
@@ -392,7 +428,9 @@ onMounted(() => {
color: inherit;
font-size: 0.95em;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
}
/* 为常见语言添加一些特殊的高亮效果 */
@@ -443,7 +481,7 @@ onMounted(() => {
background-color: hsl(var(--b1));
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid hsl(var(--b2));
}
@@ -481,7 +519,7 @@ onMounted(() => {
color: hsl(var(--bc) / 0.9);
font-style: italic;
border-radius: 0 0.5rem 0.5rem 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.03);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
position: relative;
}
@@ -672,7 +710,7 @@ onMounted(() => {
background-color: hsl(var(--b1));
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid hsl(var(--b2));
}
@@ -710,7 +748,7 @@ onMounted(() => {
color: hsl(var(--bc) / 0.9);
font-style: italic;
border-radius: 0 0.5rem 0.5rem 0;
box-shadow: 0 2px 5px rgba(0,0,0,0.03);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.03);
position: relative;
}

View File

@@ -44,7 +44,7 @@
</router-link>
</li>
<li class="my-1 hover:translate-x-1 transition-all duration-300">
<router-link to="/markdown-test" class="text-base font-medium">
<router-link to="/markdown" class="text-base font-medium">
<FileText class="icon" />
Markdown测试
</router-link>
@@ -145,6 +145,7 @@ import {
ChevronDownIcon,
} from "lucide-vue-next";
import { AuthManager } from "@/utils/AuthManager";
import { DataClient } from "@/APIClient";
const router = useRouter();
@@ -158,7 +159,7 @@ const loadUserInfo = async () => {
try {
const authenticated = await AuthManager.isAuthenticated();
if (authenticated) {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
userName.value = userInfo.name;
isLoggedIn.value = true;

View File

@@ -4,6 +4,7 @@ import { Mutex } from "async-mutex";
import {
OscilloscopeFullConfig,
OscilloscopeDataResponse,
OscilloscopeApiClient,
} from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
@@ -31,7 +32,8 @@ const DEFAULT_CONFIG: OscilloscopeFullConfig = new OscilloscopeFullConfig({
});
// 采样频率常量(后端返回)
const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() => {
const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
() => {
const oscData = shallowRef<OscilloscopeDataType>();
const alert = useRequiredInjection(useAlertStore);
@@ -43,14 +45,18 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
const isCapturing = ref(false);
// 配置
const config = reactive<OscilloscopeFullConfig>(new OscilloscopeFullConfig({ ...DEFAULT_CONFIG }));
const config = reactive<OscilloscopeFullConfig>(
new OscilloscopeFullConfig({ ...DEFAULT_CONFIG }),
);
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency ? 1_000_000_000 / oscData.value.adFrequency : 200
oscData.value?.adFrequency
? 1_000_000_000 / oscData.value.adFrequency
: 200,
);
// 应用配置
@@ -62,7 +68,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
const release = await operationMutex.acquire();
isApplying.value = true;
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
const success = await client.initialize({ ...config });
if (success) {
alert.success("示波器配置已应用", 2000);
@@ -85,12 +91,12 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
const clearOscilloscopeData = () => {
oscData.value = undefined;
}
};
// 获取数据
const getOscilloscopeData = async () => {
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
const resp: OscilloscopeDataResponse = await client.getData();
// 解析波形数据
@@ -104,7 +110,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
// 构建时间轴
const x = Array.from(
{ length: bytes.length },
(_, i) => (i * samplePeriodNs.value) / 1000 // us
(_, i) => (i * samplePeriodNs.value) / 1000, // us
);
const y = Array.from(bytes);
@@ -154,7 +160,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
const started = await client.startCapture();
if (!started) throw new Error("无法启动捕获");
alert.info("开始捕获...", 2000);
@@ -180,7 +186,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
stopAutoRefresh();
const release = await operationMutex.acquire();
try {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
const stopped = await client.stopCapture();
if (!stopped) throw new Error("无法停止捕获");
alert.info("捕获已停止", 2000);
@@ -193,7 +199,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
// 更新触发参数
const updateTrigger = async (level: number, risingEdge: boolean) => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.updateTrigger(level, risingEdge);
if (ok) {
@@ -209,8 +215,11 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
};
// 更新采样参数
const updateSampling = async (horizontalShift: number, decimationRate: number) => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const updateSampling = async (
horizontalShift: number,
decimationRate: number,
) => {
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.updateSampling(horizontalShift, decimationRate);
if (ok) {
@@ -227,7 +236,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
// 手动刷新RAM
const refreshRAM = async () => {
const client = AuthManager.createAuthenticatedOscilloscopeApiClient();
const client = AuthManager.createClient(OscilloscopeApiClient);
try {
const ok = await client.refreshRAM();
if (ok) {
@@ -245,9 +254,12 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
const freq = 5_000_000;
const duration = 0.001; // 1ms
const points = Math.floor(freq * duration);
const x = Array.from({ length: points }, (_, i) => (i * 1_000_000_000 / freq) / 1000);
const x = Array.from(
{ length: points },
(_, i) => (i * 1_000_000_000) / freq / 1000,
);
const y = Array.from({ length: points }, (_, i) =>
Math.floor(Math.sin(i * 0.01) * 127 + 128)
Math.floor(Math.sin(i * 0.01) * 127 + 128),
);
oscData.value = {
x,
@@ -282,6 +294,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(() =
refreshRAM,
generateTestData,
};
});
},
);
export { useProvideOscilloscope, useOscilloscopeState, DEFAULT_CONFIG };

View File

@@ -4,7 +4,8 @@
@wheel.prevent="handleWheel"
@mouseenter="pauseAutoRotation"
@mouseleave="resumeAutoRotation"
> <!-- 例程卡片堆叠 -->
>
<!-- 例程卡片堆叠 -->
<div class="card-stack relative mx-auto">
<div
v-for="(tutorial, index) in tutorials"
@@ -16,26 +17,39 @@
>
<!-- 卡片内容 -->
<div class="relative">
<!-- 图片 --> <img
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`"
<!-- 图片 -->
<img
:src="
tutorial.thumbnail ||
`https://kaifage.com/api/placeholder/600/400?text=${tutorial.title}&color=000000&bgColor=ffffff&fontSize=72`
"
class="w-full object-contain"
:alt="tutorial.title"
style="width: 600px; height: 400px;"
style="width: 600px; height: 400px"
/>
<!-- 卡片蒙层 -->
<div
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
:class="{'opacity-10': index === currentIndex}"
:class="{ 'opacity-10': index === currentIndex }"
></div>
<!-- 标题覆盖层 -->
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
<div
class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent"
>
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
<h3 class="text-lg font-bold text-base-content">
{{ tutorial.title }}
</h3>
<p class="text-sm opacity-80 truncate">
{{ tutorial.description }}
</p>
<!-- 标签显示 -->
<div v-if="tutorial.tags && tutorial.tags.length > 0" class="flex flex-wrap gap-1">
<div
v-if="tutorial.tags && tutorial.tags.length > 0"
class="flex flex-wrap gap-1"
>
<span
v-for="tag in tutorial.tags.slice(0, 3)"
:key="tag"
@@ -64,10 +78,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { AuthManager } from '@/utils/AuthManager';
import type { ExamSummary } from '@/APIClient';
import { ref, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import { ExamClient, ResourceClient, type ExamInfo } from "@/APIClient";
// 接口定义
interface Tutorial {
@@ -104,21 +118,21 @@ const handleCardClick = (index: number, tutorialId: string) => {
// 从数据库加载实验数据
onMounted(async () => {
try {
console.log('正在从数据库加载实验数据...');
console.log("正在从数据库加载实验数据...");
// 创建认证客户端
const client = AuthManager.createAuthenticatedExamClient();
const client = AuthManager.createClient(ExamClient);
// 获取实验列表
const examList: ExamSummary[] = await client.getExamList();
const examList: ExamInfo[] = await client.getExamList();
// 筛选可见的实验并转换为Tutorial格式
const visibleExams = examList
.filter(exam => exam.isVisibleToUsers)
.filter((exam) => exam.isVisibleToUsers)
.slice(0, 6); // 限制轮播显示最多6个实验
if (visibleExams.length === 0) {
console.warn('没有找到可见的实验');
console.warn("没有找到可见的实验");
return;
}
@@ -128,12 +142,18 @@ onMounted(async () => {
try {
// 获取实验的封面资源(模板资源)
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template');
const resourceClient = AuthManager.createClient(ResourceClient);
const resourceList = await resourceClient.getResourceList(
exam.id,
"cover",
"template",
);
if (resourceList && resourceList.length > 0) {
// 使用第一个封面资源
const coverResource = resourceList[0];
const fileResponse = await resourceClient.getResourceById(coverResource.id);
const fileResponse = await resourceClient.getResourceById(
coverResource.id,
);
// 创建Blob URL作为缩略图
thumbnail = URL.createObjectURL(fileResponse.data);
}
@@ -144,29 +164,31 @@ onMounted(async () => {
return {
id: exam.id,
title: exam.name,
description: '点击查看实验详情',
description: "点击查看实验详情",
thumbnail,
tags: exam.tags || []
tags: exam.tags || [],
};
});
tutorials.value = await Promise.all(tutorialPromises);
console.log('成功加载实验数据:', tutorials.value.length, '个实验');
console.log("成功加载实验数据:", tutorials.value.length, "个实验");
// 启动自动旋转
startAutoRotation();
} catch (error) {
console.error('加载实验数据失败:', error);
console.error("加载实验数据失败:", error);
// 如果加载失败,显示默认的占位内容
tutorials.value = [{
id: 'placeholder',
title: '实验数据加载中...',
description: '请稍后或刷新页面重试',
tutorials.value = [
{
id: "placeholder",
title: "实验数据加载中...",
description: "请稍后或刷新页面重试",
thumbnail: undefined,
tags: []
}];
tags: [],
},
];
}
});
@@ -177,8 +199,8 @@ onUnmounted(() => {
}
// 清理创建的Blob URLs
tutorials.value.forEach(tutorial => {
if (tutorial.thumbnail && tutorial.thumbnail.startsWith('blob:')) {
tutorials.value.forEach((tutorial) => {
if (tutorial.thumbnail && tutorial.thumbnail.startsWith("blob:")) {
URL.revokeObjectURL(tutorial.thumbnail);
}
});
@@ -200,7 +222,8 @@ const nextCard = () => {
// 上一张卡片
const prevCard = () => {
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
currentIndex.value =
(currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
};
// 设置活动卡片
@@ -234,36 +257,44 @@ const resumeAutoRotation = () => {
const goToExam = (examId: string) => {
// 跳转到实验列表页面并传递examId参数页面将自动打开对应的实验详情模态框
router.push({
path: '/exam',
query: { examId: examId }
path: "/exam",
query: { examId: examId },
});
};
// 计算卡片类和样式
const getCardClass = (index: number) => {
const isActive = index === currentIndex.value;
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
const isPrev =
index === currentIndex.value - 1 ||
(currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext =
index === currentIndex.value + 1 ||
(currentIndex.value === tutorials.value.length - 1 && index === 0);
return {
'z-30': isActive,
'z-20': isPrev || isNext,
'z-10': !isActive && !isPrev && !isNext,
'hover:scale-105': isActive,
'cursor-pointer': true
"z-30": isActive,
"z-20": isPrev || isNext,
"z-10": !isActive && !isPrev && !isNext,
"hover:scale-105": isActive,
"cursor-pointer": true,
};
};
const getCardStyle = (index: number) => {
const isActive = index === currentIndex.value;
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
const isPrev =
index === currentIndex.value - 1 ||
(currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext =
index === currentIndex.value + 1 ||
(currentIndex.value === tutorials.value.length - 1 && index === 0);
// 基本样式
let style = {
transform: 'scale(1) translateY(0) rotate(0deg)',
opacity: '1',
filter: 'blur(0)'
transform: "scale(1) translateY(0) rotate(0deg)",
opacity: "1",
filter: "blur(0)",
};
// 活动卡片
@@ -273,26 +304,26 @@ const getCardStyle = (index: number) => {
// 上一张卡片
if (isPrev) {
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
style.opacity = '0.7';
style.filter = 'blur(1px)';
style.transform = "scale(0.85) translateY(-10%) rotate(-5deg)";
style.opacity = "0.7";
style.filter = "blur(1px)";
return style;
}
// 下一张卡片
if (isNext) {
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
style.opacity = '0.7';
style.filter = 'blur(1px)';
style.transform = "scale(0.85) translateY(10%) rotate(5deg)";
style.opacity = "0.7";
style.filter = "blur(1px)";
return style;
}
// 其他卡片
style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
style.opacity = '0.4';
style.filter = 'blur(2px)';
style.transform = "scale(0.7) translateY(0) rotate(0deg)";
style.opacity = "0.4";
style.filter = "blur(2px)";
return style;
}
};
</script>
<style scoped>

View File

@@ -16,22 +16,32 @@
<span class="text-sm">{{ bitstream.name }}</span>
<div class="flex gap-2">
<button
@click="downloadExampleBitstream(bitstream)"
@click="handleExampleBitstream('download', bitstream)"
class="btn btn-sm btn-secondary"
:disabled="isDownloading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div
v-if="
currentTask === 'downloading' &&
currentBitstreamId === bitstream.id
"
>
<div v-if="isDownloading">
<span class="loading loading-spinner loading-xs"></span>
{{ downloadProgress }}%
下载中...
</div>
<div v-else>下载示例</div>
</button>
<button
@click="programExampleBitstream(bitstream)"
@click="handleExampleBitstream('program', bitstream)"
class="btn btn-sm btn-primary"
:disabled="isDownloading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div
v-if="
currentTask === 'programming' &&
currentBitstreamId === bitstream.id
"
>
<div v-if="isProgramming">
<span class="loading loading-spinner loading-xs"></span>
烧录中...
</div>
@@ -63,14 +73,18 @@
<!-- Upload Button -->
<div class="card-actions w-full">
<button
@click="handleClick"
@click="handleUploadAndDownload"
class="btn btn-primary grow"
:disabled="isUploading || isProgramming"
:disabled="currentTask !== 'none'"
>
<div v-if="isUploading">
<div v-if="currentTask === 'uploading'">
<span class="loading loading-spinner"></span>
上传中...
</div>
<div v-else-if="currentTask === 'programming'">
<span class="loading loading-spinner"></span>
{{ currentProgressPercent }}% ...
</div>
<div v-else>上传并下载</div>
</button>
</div>
@@ -78,24 +92,19 @@
</template>
<script lang="ts" setup>
import { computed, ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure
import { ref, useTemplateRef, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager";
import { useDialogStore } from "@/stores/dialog";
import { isNull, isUndefined } from "lodash";
import { useEquipments } from "@/stores/equipments";
import type { HubConnection } from "@microsoft/signalr";
import type {
IProgressHub,
IProgressReceiver,
} from "@/TypedSignalR.Client/server.Hubs";
import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client";
import { ProgressStatus } from "@/server.Hubs";
import { useRequiredInjection } from "@/utils/Common";
import { useAlertStore } from "./Alert";
import { ResourceClient, ResourcePurpose } from "@/APIClient";
import { useProgressStore } from "@/stores/progress";
import { ProgressStatus, type ProgressInfo } from "@/utils/signalR/server.Hubs";
interface Props {
maxMemory?: number;
examId?: string; // 新增examId属性
examId?: string;
}
const props = withDefaults(defineProps<Props>(), {
@@ -108,203 +117,166 @@ const emits = defineEmits<{
}>();
const alert = useRequiredInjection(useAlertStore);
const progressTracker = useProgressStore();
const dialog = useDialogStore();
const eqps = useEquipments();
const isUploading = ref(false);
const isDownloading = ref(false);
const isProgramming = ref(false);
const availableBitstreams = ref<{ id: number; name: string }[]>([]);
// Progress
const downloadTaskId = ref("");
const downloadProgress = ref(0);
const progressHubConnection = ref<HubConnection>();
const progressHubProxy = ref<IProgressHub>();
const progressHubReceiver: IProgressReceiver = {
onReceiveProgress: async (msg) => {
if (msg.taskId == downloadTaskId.value) {
if (msg.status == ProgressStatus.InProgress) {
downloadProgress.value = msg.progressPercent;
} else if (msg.status == ProgressStatus.Failed) {
dialog.error(msg.errorMessage);
} else if (msg.status == ProgressStatus.Completed) {
alert.info("比特流下载成功");
}
}
},
};
onMounted(async () => {
progressHubConnection.value =
AuthManager.createAuthenticatedProgressHubConnection();
progressHubProxy.value = getHubProxyFactory("IProgressHub").createHubProxy(
progressHubConnection.value,
);
getReceiverRegister("IProgressReceiver").register(
progressHubConnection.value,
progressHubReceiver,
);
});
const availableBitstreams = ref<{ id: string; name: string }[]>([]);
const fileInput = useTemplateRef("fileInput");
const bitstream = defineModel("bitstreamFile", {
type: File,
default: undefined,
});
const bitstream = ref<File | undefined>(undefined);
// 用一个状态变量替代多个
const currentTask = ref<"none" | "uploading" | "downloading" | "programming">(
"none",
);
const currentBitstreamId = ref<string>("");
const currentProgressId = ref<string>("");
const currentProgressPercent = ref<number>(0);
// 初始化时加载示例比特流
onMounted(async () => {
if (!isUndefined(bitstream.value) && !isNull(fileInput.value)) {
if (bitstream.value && fileInput.value) {
let fileList = new DataTransfer();
fileList.items.add(bitstream.value);
fileInput.value.files = fileList.files;
}
await loadAvailableBitstreams();
});
// 加载可用的比特流文件列表
async function loadAvailableBitstreams() {
console.log("加载可用比特流文件examId:", props.examId);
if (!props.examId) {
availableBitstreams.value = [];
return;
}
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取比特流模板资源列表
const resources = await resourceClient.getResourceList(
props.examId,
"bitstream",
"template",
);
availableBitstreams.value =
resources.map((r) => ({ id: r.id, name: r.name })) || [];
} catch (error) {
console.error("加载比特流列表失败:", error);
availableBitstreams.value = [];
}
}
// 下载示例比特流
async function downloadExampleBitstream(bitstream: {
id: number;
name: string;
}) {
if (isDownloading.value) return;
isDownloading.value = true;
try {
const resourceClient = AuthManager.createAuthenticatedResourceClient();
// 使用新的ResourceClient API获取资源文件
const response = await resourceClient.getResourceById(bitstream.id);
if (response && response.data) {
// 创建下载链接
const url = URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || bitstream.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
dialog.info("示例比特流下载成功");
} else {
dialog.error("下载失败:响应数据为空");
}
} catch (error) {
console.error("下载示例比特流失败:", error);
dialog.error("下载示例比特流失败");
} finally {
isDownloading.value = false;
}
}
// 直接烧录示例比特流
async function programExampleBitstream(bitstream: {
id: number;
name: string;
}) {
if (isProgramming.value) return;
isProgramming.value = true;
try {
const downloadTaskId = await eqps.jtagDownloadBitstream(bitstream.id);
} catch (error) {
console.error("烧录示例比特流失败:", error);
dialog.error("烧录示例比特流失败");
} finally {
isProgramming.value = false;
}
}
function handleFileChange(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0]; // 获取选中的第一个文件
if (!file) {
return;
}
bitstream.value = file;
const file = target.files?.[0];
bitstream.value = file || undefined;
}
function checkFile(file: File): boolean {
const maxBytes = props.maxMemory! * 1024 * 1024; // 将最大容量从 MB 转换为字节
if (file.size > maxBytes) {
function checkFileInput(): boolean {
if (!bitstream.value) {
dialog.error(`未选择文件`);
return false;
}
const maxBytes = props.maxMemory! * 1024 * 1024;
if (bitstream.value.size > maxBytes) {
dialog.error(`文件大小超过最大限制: ${props.maxMemory}MB`);
return false;
}
return true;
}
async function handleClick(event: Event): Promise<void> {
console.log("上传按钮被点击");
if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
dialog.error(`未选择文件`);
async function downloadBitstream() {
currentTask.value = "programming";
try {
currentProgressId.value = await eqps.jtagDownloadBitstream(
currentBitstreamId.value,
);
progressTracker.register(
currentProgressId.value,
"programBitstream",
handleProgressUpdate,
);
} catch {
dialog.error("比特流烧录失败");
cleanProgressTracker();
}
}
function cleanProgressTracker() {
currentTask.value = "none";
currentProgressId.value = "";
currentBitstreamId.value = "";
currentProgressPercent.value = 0;
progressTracker.unregister(currentProgressId.value, "programBitstream");
}
async function loadAvailableBitstreams() {
if (!props.examId) {
availableBitstreams.value = [];
return;
}
if (!checkFile(bitstream.value)) return;
isUploading.value = true;
let uploadedBitstreamId: number | null = null;
try {
console.log("开始上传比特流文件:", bitstream.value.name);
const bitstreamId = await eqps.jtagUploadBitstream(
bitstream.value,
const resourceClient = AuthManager.createClient(ResourceClient);
const resources = await resourceClient.getResourceList(
props.examId,
"bitstream",
ResourcePurpose.Template,
);
availableBitstreams.value =
resources.map((r) => ({ id: r.id, name: r.name })) || [];
} catch (error) {
availableBitstreams.value = [];
}
}
// 统一处理示例比特流的下载/烧录
async function handleExampleBitstream(
action: "download" | "program",
bitstreamObj: { id: string; name: string },
) {
if (currentTask.value !== "none") return;
currentBitstreamId.value = bitstreamObj.id;
if (action === "download") {
currentTask.value = "downloading";
try {
const resourceClient = AuthManager.createClient(ResourceClient);
const response = await resourceClient.getResourceById(bitstreamObj.id);
if (response && response.data) {
const url = URL.createObjectURL(response.data);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || bitstreamObj.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
alert.info("示例比特流下载成功");
} else {
alert.error("下载失败:响应数据为空");
}
} catch {
alert.error("下载示例比特流失败");
} finally {
currentTask.value = "none";
currentBitstreamId.value = "";
}
} else if (action === "program") {
currentBitstreamId.value = bitstreamObj.id;
await downloadBitstream();
}
}
// 上传并下载
async function handleUploadAndDownload() {
if (currentTask.value !== "none") return;
if (!checkFileInput()) return;
currentTask.value = "uploading";
let uploadedBitstreamId: string | null = null;
try {
uploadedBitstreamId = await eqps.jtagUploadBitstream(
bitstream.value!,
props.examId || "",
);
console.log("上传结果ID:", bitstreamId);
if (bitstreamId === null || bitstreamId === undefined) {
isUploading.value = false;
return;
}
uploadedBitstreamId = bitstreamId;
} catch (e) {
if (!uploadedBitstreamId) throw new Error("上传失败");
emits("finishedUpload", bitstream.value!);
} catch {
dialog.error("上传失败");
console.error(e);
currentTask.value = "none";
return;
}
isUploading.value = false;
// Download
try {
console.log("开始下载比特流ID:", uploadedBitstreamId);
if (uploadedBitstreamId === null || uploadedBitstreamId === undefined) {
dialog.error("uploadedBitstreamId is null or undefined");
} else {
isDownloading.value = true;
downloadTaskId.value =
await eqps.jtagDownloadBitstream(uploadedBitstreamId);
}
} catch (e) {
dialog.error("下载失败");
console.error(e);
currentBitstreamId.value = uploadedBitstreamId;
await downloadBitstream();
}
function handleProgressUpdate(msg: ProgressInfo) {
// console.log(msg);
if (msg.status === ProgressStatus.Running)
currentProgressPercent.value = msg.progressPercent;
else if (msg.status === ProgressStatus.Failed) {
dialog.error(`比特流烧录失败: ${msg.errorMessage}`);
cleanProgressTracker();
} else if (msg.status === ProgressStatus.Completed) {
dialog.info("比特流烧录成功");
cleanProgressTracker();
}
}
</script>

View File

@@ -212,6 +212,7 @@ import { useEquipments } from "@/stores/equipments";
import { useDialogStore } from "@/stores/dialog";
import { toInteger } from "lodash";
import { AuthManager } from "@/utils/AuthManager";
import { DDSClient } from "@/APIClient";
// Component Attributes
const props = defineProps<{
@@ -221,7 +222,7 @@ const props = defineProps<{
const emit = defineEmits(["update:modelValue"]);
// Global varibles
const dds = AuthManager.createAuthenticatedDDSClient();
const dds = AuthManager.createClient(DDSClient);
const eqps = useEquipments();
const dialog = useDialogStore();

View File

@@ -0,0 +1,258 @@
<template>
<div class="ec11-container" :style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}">
<svg xmlns="http://www.w3.org/2000/svg" :width="width" :height="height" viewBox="0 0 100 100"
class="ec11-encoder">
<defs>
<!-- 发光效果滤镜 -->
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood result="flood" flood-color="#00ff88" flood-opacity="1"></feFlood>
<feComposite in="flood" result="mask" in2="SourceGraphic" operator="in"></feComposite>
<feMorphology in="mask" result="dilated" operator="dilate" radius="1"></feMorphology>
<feGaussianBlur in="dilated" stdDeviation="2" result="blur1" />
<feGaussianBlur in="dilated" stdDeviation="4" result="blur2" />
<feGaussianBlur in="dilated" stdDeviation="8" result="blur3" />
<feMerge>
<feMergeNode in="blur3" />
<feMergeNode in="blur2" />
<feMergeNode in="blur1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- 编码器主体渐变 -->
<radialGradient id="encoderGradient" cx="50%" cy="30%">
<stop offset="0%" stop-color="#666666" />
<stop offset="70%" stop-color="#333333" />
<stop offset="100%" stop-color="#1a1a1a" />
</radialGradient>
<!-- 旋钮渐变 -->
<radialGradient id="knobGradient" cx="30%" cy="30%">
<stop offset="0%" stop-color="#555555" />
<stop offset="70%" stop-color="#222222" />
<stop offset="100%" stop-color="#111111" />
</radialGradient>
<!-- 按下状态渐变 -->
<radialGradient id="knobPressedGradient" cx="50%" cy="50%">
<stop offset="0%" stop-color="#333333" />
<stop offset="70%" stop-color="#555555" />
<stop offset="100%" stop-color="#888888" />
</radialGradient>
</defs>
<!-- 编码器底座 -->
<rect x="10" y="30" width="80" height="60" rx="8" ry="8"
fill="#2a2a2a" stroke="#444444" stroke-width="1"/>
<!-- 编码器主体外壳 -->
<circle cx="50" cy="60" r="32" fill="url(#encoderGradient)" stroke="#555555" stroke-width="1"/>
<!-- 编码器接线端子 -->
<rect x="5" y="75" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="15" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="25" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="81" y="85" width="4" height="8" fill="#c9c9c9" rx="1"/>
<rect x="91" y="75" width="4" height="8" fill="#c9c9c9" rx="1"/>
<!-- 旋钮 -->
<circle cx="50" cy="60" r="22"
:fill="isPressed ? 'url(#knobPressedGradient)' : 'url(#knobGradient)'"
stroke="#666666" stroke-width="1"
:transform="`rotate(${rotation/2} 50 60)`"
class="interactive"
@mousedown="handleMouseDown"
@mouseup="handlePress(false)"
@mouseleave="handlePress(false)"/>
<!-- 旋钮指示器 -->
<line x1="50" y1="42" x2="50" y2="48"
stroke="#ffffff" stroke-width="2" stroke-linecap="round"
:transform="`rotate(${rotation} 50 60)`"/>
<!-- 旋钮上的纹理刻度 -->
<g :transform="`rotate(${rotation} 50 60)`">
<circle cx="50" cy="60" r="18" fill="none" stroke="#777777" stroke-width="0.5"/>
<!-- 刻度线 -->
<g v-for="i in 16" :key="i">
<line :x1="50 + 16 * Math.cos((i-1) * Math.PI / 8)"
:y1="60 + 16 * Math.sin((i-1) * Math.PI / 8)"
:x2="50 + 18 * Math.cos((i-1) * Math.PI / 8)"
:y2="60 + 18 * Math.sin((i-1) * Math.PI / 8)"
stroke="#999999" stroke-width="0.5"/>
</g>
</g>
<!-- 编码器编号标签 -->
<text x="50" y="15" text-anchor="middle" font-family="Arial" font-size="10"
fill="#cccccc" font-weight="bold">
EC11-{{ encoderNumber }}
</text>
<!-- 状态指示器 -->
<circle cx="85" cy="20" r="3" :fill="isPressed ? '#ff4444' : '#444444'"
:filter="isPressed ? 'url(#glow)' : ''"
stroke="#666666" stroke-width="0.5"/>
</svg>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
interface Props {
size?: number;
encoderNumber?: number;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
encoderNumber: 1
});
// 组件状态
const isPressed = ref(false);
const rotation = ref(0);
// 拖动状态
const isDragging = ref(false);
const dragStartX = ref(0);
const lastTriggerX = ref(0);
const dragThreshold = 20; // 每20像素触发一次旋转
const hasRotated = ref(false); // 标记是否已经发生了旋转
// 计算宽高
const width = computed(() => 100 * props.size);
const height = computed(() => 100 * props.size);
// 定义发出的事件
const emit = defineEmits(['press', 'release', 'rotate-left', 'rotate-right']);
// 鼠标按下处理
function handleMouseDown(event: MouseEvent) {
isDragging.value = true;
dragStartX.value = event.clientX;
lastTriggerX.value = event.clientX;
hasRotated.value = false; // 重置旋转标记
// 添加全局鼠标事件监听
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// 鼠标移动处理
function handleMouseMove(event: MouseEvent) {
if (!isDragging.value) return;
const currentX = event.clientX;
const deltaX = currentX - lastTriggerX.value;
// 检查是否达到触发阈值
if (Math.abs(deltaX) >= dragThreshold) {
hasRotated.value = true; // 标记已经发生旋转
if (deltaX > 0) {
// 右拖动 - 右旋转
rotation.value += 15;
emit('rotate-right', {
encoderNumber: props.encoderNumber
});
} else {
// 左拖动 - 左旋转
rotation.value -= 15;
emit('rotate-left', {
encoderNumber: props.encoderNumber
});
}
// 更新最后触发位置
lastTriggerX.value = currentX;
// 保持角度在0-360度范围内
rotation.value = rotation.value % 720;
if (rotation.value < 0) {
rotation.value += 720;
}
}
}
// 鼠标松开处理
function handleMouseUp() {
isDragging.value = false;
// 只有在没有发生旋转的情况下才识别为按压事件
if (!hasRotated.value) {
// 触发按压和释放事件(模拟快速按压)
handlePress(true);
// 使用setTimeout来模拟按压和释放的时序
setTimeout(() => {
handlePress(false);
}, 100);
}
// 移除全局事件监听
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
// 按压处理
function handlePress(pressed: boolean) {
if (pressed !== isPressed.value) {
isPressed.value = pressed;
if (pressed) {
emit('press', { encoderNumber: props.encoderNumber });
} else {
emit('release', { encoderNumber: props.encoderNumber });
}
}
}
// 暴露组件方法
defineExpose({
press: () => handlePress(true),
release: () => handlePress(false),
rotateLeft: () => {
rotation.value -= 15;
emit('rotate-left', {
encoderNumber: props.encoderNumber
});
},
rotateRight: () => {
rotation.value += 15;
emit('rotate-right', {
encoderNumber: props.encoderNumber
});
},
isPressed: () => isPressed.value
});
</script>
<script lang="ts">
// 添加一个静态方法来获取默认props
export function getDefaultProps() {
return {
size: 1,
encoderNumber: 1
};
}
</script>
<style scoped lang="postcss">
.ec11-container {
display: inline-block;
user-select: none;
}
.ec11-encoder {
display: block;
overflow: visible;
}
.interactive {
cursor: pointer;
}
</style>

View File

@@ -1,65 +1,114 @@
<template>
<div class="seven-segment-display" :style="{
<div
class="seven-segment-display"
:style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}">
<svg xmlns="http://www.w3.org/2000/svg" :width="width" :height="height" viewBox="0 0 120 220" class="display">
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 120 220"
class="display"
>
<!-- 数码管基座 -->
<rect width="120" height="180" x="0" y="0" fill="#222" rx="10" ry="10" />
<rect width="110" height="170" x="5" y="5" fill="#333" rx="5" ry="5" />
<!-- 7 + 小数点每个段由多边形表示重新设计点位置使其更接近实际数码管 -->
<!-- a段 (顶部横线) -->
<polygon :points="'30,20 90,20 98,28 82,36 38,36 22,28'"
<polygon
:points="'30,20 90,20 98,28 82,36 38,36 22,28'"
:fill="isSegmentActive('a') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('a') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('a') ? 1 : 0.15 }"
class="segment"
/>
<!-- b段 (右上竖线) -->
<polygon :points="'100,30 108,38 108,82 100,90 92,82 92,38'"
<polygon
:points="'100,30 108,38 108,82 100,90 92,82 92,38'"
:fill="isSegmentActive('b') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('b') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('b') ? 1 : 0.15 }"
class="segment"
/>
<!-- c段 (右下竖线) -->
<polygon :points="'100,90 108,98 108,142 100,150 92,142 92,98'"
<polygon
:points="'100,90 108,98 108,142 100,150 92,142 92,98'"
:fill="isSegmentActive('c') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('c') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('c') ? 1 : 0.15 }"
class="segment"
/>
<!-- d段 (底部横线) -->
<polygon :points="'30,160 90,160 98,152 82,144 38,144 22,152'"
<polygon
:points="'30,160 90,160 98,152 82,144 38,144 22,152'"
:fill="isSegmentActive('d') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('d') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('d') ? 1 : 0.15 }"
class="segment"
/>
<!-- e段 (左下竖线) -->
<polygon :points="'20,90 28,98 28,142 20,150 12,142 12,98'"
<polygon
:points="'20,90 28,98 28,142 20,150 12,142 12,98'"
:fill="isSegmentActive('e') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('e') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('e') ? 1 : 0.15 }"
class="segment"
/>
<!-- f段 (左上竖线) -->
<polygon :points="'20,30 28,38 28,82 20,90 12,82 12,38'"
<polygon
:points="'20,30 28,38 28,82 20,90 12,82 12,38'"
:fill="isSegmentActive('f') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('f') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('f') ? 1 : 0.15 }"
class="segment"
/>
<!-- g段 (中间横线) -->
<polygon :points="'30,90 38,82 82,82 90,90 82,98 38,98'"
<polygon
:points="'30,90 38,82 82,82 90,90 82,98 38,98'"
:fill="isSegmentActive('g') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('g') ? 1 : 0.15 }" class="segment" />
:style="{ opacity: isSegmentActive('g') ? 1 : 0.15 }"
class="segment"
/>
<!-- dp段 (小数点) -->
<circle cx="108" cy="154" r="6" :fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }" class="segment" />
<circle
cx="108"
cy="154"
r="6"
:fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }"
class="segment"
/>
</svg>
<!-- 引脚 -->
<div v-for="pin in pins" :key="pin.pinId" :style="{
<div
v-for="pin in pins"
:key="pin.pinId"
:style="{
position: 'absolute',
left: `${pin.x * props.size}px`,
top: `${pin.y * props.size}px`,
transform: 'translate(-50%, -50%)',
}" :data-pin-wrapper="`${pin.pinId}`" :data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`">
<Pin :ref="(el) => {
}"
:data-pin-wrapper="`${pin.pinId}`"
:data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`"
>
<Pin
:ref="
(el) => {
if (el) pinRefs[pin.pinId] = el;
}
" :label="pin.pinId" :constraint="pin.constraint" :pinId="pin.pinId" @pin-click="$emit('pin-click', $event)" />
"
:label="pin.pinId"
:constraint="pin.constraint"
:pinId="pin.pinId"
@pin-click="$emit('pin-click', $event)"
/>
</div>
</div>
</template>
@@ -274,7 +323,8 @@ function updateSegmentStates() {
for (const pin of props.pins) {
if (["a", "b", "c", "d", "e", "f", "g", "dp"].includes(pin.pinId)) {
if (!pin.constraint) {
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] = false;
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] =
false;
continue;
}
const pinState = getConstraintState(pin.constraint);
@@ -285,7 +335,8 @@ function updateSegmentStates() {
newState = pinState === "low";
}
// 段状态只有在COM激活时才有效
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] = newState;
segmentStates.value[pin.pinId as keyof typeof segmentStates.value] =
newState;
}
}
@@ -332,7 +383,8 @@ function enterAfterglowMode() {
// 保存当前稳定状态作为余晖状态
for (const segmentId of ["a", "b", "c", "d", "e", "f", "g", "dp"]) {
const typedSegmentId = segmentId as keyof typeof stableSegmentStates.value;
afterglowStates.value[typedSegmentId] = stableSegmentStates.value[typedSegmentId];
afterglowStates.value[typedSegmentId] =
stableSegmentStates.value[typedSegmentId];
// 设置定时器,在余晖持续时间后退出余晖模式
if (afterglowTimers.value[segmentId]) {
@@ -343,7 +395,9 @@ function enterAfterglowMode() {
afterglowStates.value[typedSegmentId] = false;
// 检查是否所有段都已经关闭
const allSegmentsOff = Object.values(afterglowStates.value).every(state => !state);
const allSegmentsOff = Object.values(afterglowStates.value).every(
(state) => !state,
);
if (allSegmentsOff) {
exitAfterglowMode();
}
@@ -397,11 +451,6 @@ onUnmounted(() => {
}
}
});
// 暴露属性和方法
defineExpose({
updateSegmentStates,
});
</script>
<style scoped>
@@ -418,7 +467,8 @@ defineExpose({
/* 数码管发光效果 */
.segment[style*="opacity: 1"] {
filter: drop-shadow(0 0 4px v-bind(segmentColor)) drop-shadow(0 0 2px v-bind(segmentColor));
filter: drop-shadow(0 0 4px v-bind(segmentColor))
drop-shadow(0 0 2px v-bind(segmentColor));
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<div
class="seven-segment-display"
:style="{
width: width + 'px',
height: height + 'px',
position: 'relative',
}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
viewBox="0 0 120 220"
class="display"
>
<!-- 数码管基座 -->
<rect width="120" height="180" x="0" y="0" fill="#222" rx="10" ry="10" />
<rect width="110" height="170" x="5" y="5" fill="#333" rx="5" ry="5" />
<!-- 7段显示 -->
<polygon
v-for="(segment, id) in segmentPaths"
:key="id"
:points="segment.points"
:fill="isSegmentActive(id) ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive(id) ? 1 : 0.15 }"
:class="{ segment: true, active: isSegmentActive(id) }"
/>
<!-- 小数点 -->
<circle
cx="108"
cy="154"
r="6"
:fill="isSegmentActive('dp') ? segmentColor : inactiveColor"
:style="{ opacity: isSegmentActive('dp') ? 1 : 0.15 }"
:class="{ segment: true, active: isSegmentActive('dp') }"
/>
</svg>
<!-- 引脚仅在非数字孪生模式下显示 -->
<div
v-if="!props.enableDigitalTwin"
v-for="pin in props.pins"
:key="pin.pinId"
:style="{
position: 'absolute',
left: `${pin.x * props.size}px`,
top: `${pin.y * props.size}px`,
transform: 'translate(-50%, -50%)',
}"
:data-pin-wrapper="`${pin.pinId}`"
:data-pin-x="`${pin.x * props.size}`"
:data-pin-y="`${pin.y * props.size}`"
>
<Pin
:ref="
(el) => {
if (el) pinRefs[pin.pinId] = el;
}
"
:label="pin.pinId"
:constraint="pin.constraint"
:pinId="pin.pinId"
@pin-click="$emit('pin-click', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useConstraintsStore } from "../../stores/constraints";
import Pin from "./Pin.vue";
import { useEquipments } from "@/stores/equipments";
// ============================================================================
// Linus式极简数据结构一个byte解决一切
// ============================================================================
interface Props {
size?: number;
color?: string;
enableDigitalTwin?: boolean;
digitalTwinNum?: number;
afterglowDuration?: number;
cathodeType?: "common" | "anode";
pins?: Array<{
pinId: string;
constraint: string;
x: number;
y: number;
}>;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
color: "red",
enableDigitalTwin: false,
digitalTwinNum: 0,
afterglowDuration: 500,
cathodeType: "common",
pins: () => [
{ pinId: "a", constraint: "", x: 10, y: 170 },
{ pinId: "b", constraint: "", x: 24, y: 170 },
{ pinId: "c", constraint: "", x: 38, y: 170 },
{ pinId: "d", constraint: "", x: 52, y: 170 },
{ pinId: "e", constraint: "", x: 66, y: 170 },
{ pinId: "f", constraint: "", x: 80, y: 170 },
{ pinId: "g", constraint: "", x: 94, y: 170 },
{ pinId: "dp", constraint: "", x: 108, y: 170 },
{ pinId: "COM", constraint: "", x: 60, y: 10 },
],
});
// ============================================================================
// 核心状态:简单到极致
// ============================================================================
// 当前显示状态 - 8bit对应8个段
const displayByte = ref<number>(0);
// 余晖状态
const afterglowByte = ref<number>(0);
const afterglowTimer = ref<number | null>(null);
// 约束系统状态(兼容模式)
const constraintStates = ref<Record<string, boolean>>({
a: false,
b: false,
c: false,
d: false,
e: false,
f: false,
g: false,
dp: false,
});
// ============================================================================
// Bit操作硬件工程师的好品味
// ============================================================================
// 段到bit位的映射 (标准7段数码管编码)
const SEGMENT_BITS = {
a: 0, // bit 0
b: 1, // bit 1
c: 2, // bit 2
d: 3, // bit 3
e: 4, // bit 4
f: 5, // bit 5
g: 6, // bit 6
dp: 7, // bit 7
} as const;
function isBitSet(byte: number, bit: number): boolean {
return (byte & (1 << bit)) !== 0;
}
function isSegmentActive(segmentId: keyof typeof SEGMENT_BITS): boolean {
if (props.enableDigitalTwin) {
// 数字孪生模式余晖优先然后是当前byte
const bit = SEGMENT_BITS[segmentId];
return (
isBitSet(afterglowByte.value, bit) || isBitSet(displayByte.value, bit)
);
} else {
// 约束模式:使用传统逻辑
return constraintStates.value[segmentId] || false;
}
}
// ============================================================================
// SignalR数字孪生集成
// ============================================================================
const eqps = useEquipments();
async function initDigitalTwin() {
if (
!props.enableDigitalTwin ||
props.digitalTwinNum < 0 ||
props.digitalTwinNum > 31
)
return;
try {
eqps.sevenSegmentDisplaySetOnOff(props.enableDigitalTwin);
console.log(
`Digital twin initialized for address: ${props.digitalTwinNum}`,
);
} catch (error) {
console.warn("Failed to initialize digital twin:", error);
}
}
watch(
() => [eqps.sevenSegmentDisplayData],
() => {
if (
!eqps.sevenSegmentDisplayData ||
props.digitalTwinNum < 0 ||
props.digitalTwinNum > 31
)
return;
handleDigitalTwinData(eqps.sevenSegmentDisplayData[props.digitalTwinNum]);
},
);
function handleDigitalTwinData(data: any) {
let newByte = 0;
if (typeof data === "number") {
// 直接是byte数据
newByte = data & 0xff; // 确保只取低8位
} else if (data && typeof data.value === "number") {
// 包装在对象中的byte数据
newByte = data.value & 0xff;
} else if (data && data.segments) {
// 段状态对象格式
Object.keys(SEGMENT_BITS).forEach((segment) => {
if (data.segments[segment]) {
newByte |= 1 << SEGMENT_BITS[segment as keyof typeof SEGMENT_BITS];
}
});
}
updateDisplayByte(newByte);
}
function updateDisplayByte(newByte: number) {
const oldByte = displayByte.value;
displayByte.value = newByte;
// 启动余晖效果
if (oldByte !== 0 && newByte !== oldByte) {
startAfterglow(oldByte);
}
}
function startAfterglow(byte: number) {
afterglowByte.value = byte;
if (afterglowTimer.value) {
clearTimeout(afterglowTimer.value);
}
afterglowTimer.value = setTimeout(() => {
afterglowByte.value = 0;
afterglowTimer.value = null;
}, props.afterglowDuration);
}
function cleanupDigitalTwin() {
eqps.sevenSegmentDisplaySetOnOff(false);
}
// ============================================================================
// 约束系统兼容(传统模式)
// ============================================================================
const { getConstraintState, onConstraintStateChange } = useConstraintsStore();
let constraintUnsubscribe: (() => void) | null = null;
function updateConstraintStates() {
if (props.enableDigitalTwin) return; // 数字孪生模式下忽略约束
// 获取COM状态
const comPin = props.pins.find((p) => p.pinId === "COM");
const comActive = isComActive(comPin);
if (!comActive) {
// COM不活跃所有段关闭
Object.keys(constraintStates.value).forEach((key) => {
constraintStates.value[key] = false;
});
return;
}
// 更新各段状态
props.pins.forEach((pin) => {
if (Object.hasOwnProperty.call(SEGMENT_BITS, pin.pinId)) {
constraintStates.value[pin.pinId] = isPinActive(pin);
}
});
}
function isComActive(comPin: any): boolean {
if (!comPin?.constraint) return true;
const state = getConstraintState(comPin.constraint);
return props.cathodeType === "common" ? state === "low" : state === "low";
}
function isPinActive(pin: any): boolean {
if (!pin.constraint) return false;
const state = getConstraintState(pin.constraint);
return props.cathodeType === "common" ? state === "high" : state === "low";
}
// ============================================================================
// 渲染数据
// ============================================================================
const segmentPaths = {
a: { points: "30,20 90,20 98,28 82,36 38,36 22,28" },
b: { points: "100,30 108,38 108,82 100,90 92,82 92,38" },
c: { points: "100,90 108,98 108,142 100,150 92,142 92,98" },
d: { points: "30,160 90,160 98,152 82,144 38,144 22,152" },
e: { points: "20,90 28,98 28,142 20,150 12,142 12,98" },
f: { points: "20,30 28,38 28,82 20,90 12,82 12,38" },
g: { points: "30,90 38,82 82,82 90,90 82,98 38,98" },
} as const;
// ============================================================================
// 计算属性
// ============================================================================
const width = computed(() => 120 * props.size);
const height = computed(() => 220 * props.size);
const segmentColor = computed(() => props.color);
const inactiveColor = computed(() => "#FFFFFF");
const pinRefs = ref<Record<string, any>>({});
// ============================================================================
// 生命周期
// ============================================================================
onMounted(async () => {
if (props.enableDigitalTwin) {
await initDigitalTwin();
} else {
constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
updateConstraintStates();
}
});
onUnmounted(() => {
cleanupDigitalTwin();
if (constraintUnsubscribe) {
constraintUnsubscribe();
}
if (afterglowTimer.value) {
clearTimeout(afterglowTimer.value);
}
});
// 监听模式切换
watch(
() => [props.enableDigitalTwin],
async () => {
// 清理旧模式
cleanupDigitalTwin();
if (constraintUnsubscribe) {
constraintUnsubscribe();
constraintUnsubscribe = null;
}
// 初始化新模式
if (props.enableDigitalTwin) {
await initDigitalTwin();
} else {
constraintUnsubscribe = onConstraintStateChange(updateConstraintStates);
updateConstraintStates();
}
},
);
</script>
<style scoped>
.seven-segment-display {
display: inline-block;
position: relative;
}
.segment {
transition:
opacity 0.2s,
fill 0.2s;
}
.segment.active {
filter: drop-shadow(0 0 4px v-bind(segmentColor))
drop-shadow(0 0 2px v-bind(segmentColor));
}
</style>
<script lang="ts">
export function getDefaultProps() {
return {
size: 1,
color: "red",
enableDigitalTwin: false,
digitalTwinNum: 0,
afterglowDuration: 500,
cathodeType: "common",
pins: [
{ pinId: "a", constraint: "", x: 10, y: 170 },
{ pinId: "b", constraint: "", x: 24, y: 170 },
{ pinId: "c", constraint: "", x: 38, y: 170 },
{ pinId: "d", constraint: "", x: 52, y: 170 },
{ pinId: "e", constraint: "", x: 66, y: 170 },
{ pinId: "f", constraint: "", x: 80, y: 170 },
{ pinId: "g", constraint: "", x: 94, y: 170 },
{ pinId: "dp", constraint: "", x: 108, y: 170 },
{ pinId: "COM", constraint: "", x: 60, y: 10 },
],
};
}
</script>

View File

@@ -1,17 +1,30 @@
// filepath: c:\_Project\FPGA_WebLab\FPGA_WebLab\src\components\equipments\Switch.vue
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:width="width"
:height="height"
:viewBox="`4 6 ${props.switchCount + 2} 4`"
:viewBox="`4 6 ${switchCount + 2} 4`"
class="dip-switch"
>
<defs>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feFlood result="flood" flood-color="#f08a5d" flood-opacity="1"></feFlood>
<feComposite in="flood" result="mask" in2="SourceGraphic" operator="in"></feComposite>
<feMorphology in="mask" result="dilated" operator="dilate" radius="0.02"></feMorphology>
<feFlood
result="flood"
flood-color="#f08a5d"
flood-opacity="1"
></feFlood>
<feComposite
in="flood"
result="mask"
in2="SourceGraphic"
operator="in"
></feComposite>
<feMorphology
in="mask"
result="dilated"
operator="dilate"
radius="0.02"
></feMorphology>
<feGaussianBlur in="dilated" stdDeviation="0.05" result="blur1" />
<feGaussianBlur in="dilated" stdDeviation="0.1" result="blur2" />
<feGaussianBlur in="dilated" stdDeviation="0.2" result="blur3" />
@@ -23,14 +36,26 @@
</feMerge>
</filter>
</defs>
<g>
<!-- 红色背景随开关数量变化宽度 -->
<rect :width="props.switchCount + 2" height="4" x="4" y="6" fill="#c01401" rx="0.1" />
<text v-if="props.showLabels" fill="white" font-size="0.7" x="4.25" y="6.75">ON</text>
<rect
:width="switchCount + 2"
height="4"
x="4"
y="6"
fill="#c01401"
rx="0.1"
/>
<text
v-if="props.showLabels"
fill="white"
font-size="0.7"
x="4.25"
y="6.75"
>
ON
</text>
<g>
<template v-for="(_, index) in Array(props.switchCount)" :key="index">
<template v-for="(_, index) in Array(switchCount)" :key="index">
<rect
class="glow interactive"
@click="toggleBtnStatus(index)"
@@ -53,9 +78,11 @@
</text>
</template>
</g>
<g>
<template v-for="(location, index) in btnLocation" :key="`btn-${index}`">
<template
v-for="(location, index) in btnLocation"
:key="`btn-${index}`"
>
<rect
class="interactive"
@click="toggleBtnStatus(index)"
@@ -74,119 +101,112 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from "vue";
import { SwitchClient } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { isUndefined } from "lodash";
import { ref, computed, watch, onMounted } from "vue";
interface Props {
size?: number;
enableDigitalTwin?: boolean;
switchCount?: number;
// 新增属性
initialValues?: boolean[] | string; // 开关的初始状态,可以是布尔数组或逗号分隔的字符串
showLabels?: boolean; // 是否显示标签
initialValues?: string;
showLabels?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 1,
enableDigitalTwin: false,
switchCount: 6,
initialValues: () => [],
showLabels: true
initialValues: "",
showLabels: true,
});
// 计算实际宽高
const width = computed(() => {
// 每个开关占用25px宽度再加上两侧边距(20px)
return (props.switchCount * 25 + 20) * props.size;
const switchCount = computed(() => {
if (props.enableDigitalTwin) return 5;
else return props.switchCount;
});
const height = computed(() => 85 * props.size); // 高度保持固定比例
// 定义发出的事件
const emit = defineEmits(['change', 'switch-toggle']);
function getClient() {
return AuthManager.createClient(SwitchClient);
}
// 解析初始值,支持字符串和数组两种格式
const parseInitialValues = () => {
// 解析初始值
function parseInitialValues(): boolean[] {
if (Array.isArray(props.initialValues)) {
return [...props.initialValues].slice(0, props.switchCount);
} else if (typeof props.initialValues === 'string' && props.initialValues.trim() !== '') {
// 将逗号分隔的字符串转换为布尔数组
const values = props.initialValues.split(',')
.map(val => val.trim() === '1' || val.trim().toLowerCase() === 'true')
.slice(0, props.switchCount);
// 如果数组长度小于开关数量,用 false 填充
while (values.length < props.switchCount) {
values.push(false);
return [...props.initialValues].slice(0, switchCount.value);
}
return values;
if (
typeof props.initialValues === "string" &&
props.initialValues.trim() !== ""
) {
const arr = props.initialValues
.split(",")
.map((val) => val.trim() === "1" || val.trim().toLowerCase() === "true");
while (arr.length < props.switchCount) arr.push(false);
return arr.slice(0, props.switchCount);
}
// 默认返回全部为 false 的数组
return Array(props.switchCount).fill(false);
};
return Array(switchCount.value).fill(false);
}
// 初始化按钮状态
const btnStatus = ref(parseInitialValues());
// 状态唯一真相
const btnStatus = ref<boolean[]>(parseInitialValues());
// 监听 switchCount 变化,调整开关状态数组
watch(() => props.switchCount, (newCount) => {
if (newCount !== btnStatus.value.length) {
// 如果新数量大于当前数量,则扩展数组
if (newCount > btnStatus.value.length) {
btnStatus.value = [
...btnStatus.value,
...Array(newCount - btnStatus.value.length).fill(false)
];
} else {
// 如果新数量小于当前数量,则截断数组
btnStatus.value = btnStatus.value.slice(0, newCount);
// 计算宽高
const width = computed(() => (switchCount.value * 25 + 20) * props.size);
const height = computed(() => 85 * props.size);
// 按钮位置
const btnLocation = computed(() =>
btnStatus.value.map((status) => (status ? 7.025 : 8.325)),
);
// 状态变更统一处理
function updateStatus(newStates: boolean[], index?: number) {
btnStatus.value = newStates.slice(0, switchCount.value);
if (props.enableDigitalTwin) {
try {
const client = getClient();
if (!isUndefined(index))
client.setSwitchOnOff(index + 1, newStates[index]);
else client.setMultiSwitchsOnOff(btnStatus.value);
} catch (error: any) {}
}
}
}, { immediate: true });
}
// 监听 initialValues 变化,更新开关状态
watch(() => props.initialValues, () => {
// 切换单个
function toggleBtnStatus(idx: number) {
if (idx < 0 || idx >= btnStatus.value.length) return;
const newStates = [...btnStatus.value];
newStates[idx] = !newStates[idx];
updateStatus(newStates, idx);
}
// 单个设置
function setBtnStatus(idx: number, isOn: boolean) {
if (idx < 0 || idx >= btnStatus.value.length) return;
const newStates = [...btnStatus.value];
newStates[idx] = isOn;
updateStatus(newStates, idx);
}
// 监听 props 变化只同步一次
watch(
() => props.enableDigitalTwin,
(newVal) => {
const client = getClient();
client.setEnable(newVal);
},
{ immediate: true },
);
watch(
() => [switchCount.value, props.initialValues],
() => {
btnStatus.value = parseInitialValues();
});
const btnLocation = computed(() => {
return btnStatus.value.map((status) => {
return status ? 7.025 : 8.325;
});
});
function setBtnStatus(btnNum: number, isOn: boolean): void {
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = isOn;
emit('change', { index: btnNum, value: isOn, states: [...btnStatus.value] });
}
}
function toggleBtnStatus(btnNum: number): void {
if (btnNum >= 0 && btnNum < btnStatus.value.length) {
btnStatus.value[btnNum] = !btnStatus.value[btnNum];
emit('switch-toggle', {
index: btnNum,
value: btnStatus.value[btnNum],
states: [...btnStatus.value]
});
}
}
// 一次性设置所有开关状态
function setAllStates(states: boolean[]): void {
const newStates = states.slice(0, props.switchCount);
while (newStates.length < props.switchCount) {
newStates.push(false);
}
btnStatus.value = newStates;
emit('change', { states: [...btnStatus.value] });
}
// 暴露组件方法和状态
defineExpose({
setBtnStatus,
toggleBtnStatus,
setAllStates,
getBtnStatus: () => [...btnStatus.value]
});
updateStatus(btnStatus.value);
},
);
</script>
<style scoped lang="postcss">
@@ -194,17 +214,27 @@ defineExpose({
display: block;
padding: 0;
margin: 0;
line-height: 0; /* 移除行高导致的额外间距 */
font-size: 0; /* 防止文本节点造成的间距 */
line-height: 0;
font-size: 0;
box-sizing: content-box;
overflow: visible;
}
rect {
transition: all 100ms ease-in-out;
}
.interactive {
cursor: pointer;
}
</style>
<script lang="ts">
export function getDefaultProps() {
return {
size: 1,
enableDigitalTwin: false,
switchCount: 6,
initialValues: "",
showLabels: true,
};
}
</script>

View File

@@ -1,10 +1,9 @@
import './assets/main.css'
import "./assets/main.css";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from '@/App.vue'
import router from './router'
const app = createApp(App).use(router).use(createPinia()).mount('#app')
import App from "@/App.vue";
import router from "./router";
const app = createApp(App).use(router).use(createPinia()).mount("#app");

View File

@@ -4,7 +4,8 @@ import AuthView from "../views/AuthView.vue";
import ProjectView from "../views/Project/Index.vue";
import TestView from "../views/TestView.vue";
import UserView from "@/views/User/Index.vue";
import ExamView from "@/views/ExamView.vue";
import ExamView from "@/views/Exam/Index.vue";
import MarkdownEditor from "@/components/MarkdownEditor.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -15,6 +16,7 @@ const router = createRouter({
{ path: "/test", name: "test", component: TestView },
{ path: "/user", name: "user", component: UserView },
{ path: "/exam", name: "exam", component: ExamView },
{ path: "/markdown", name: "markdown", component: MarkdownEditor },
],
});

View File

@@ -7,12 +7,28 @@ import { isNumber } from "mathjs";
import { Mutex, withTimeout } from "async-mutex";
import { useConstraintsStore } from "@/stores/constraints";
import { useDialogStore } from "./dialog";
import { toFileParameterOrUndefined } from "@/utils/Common";
import {
base64ToArrayBuffer,
toFileParameterOrUndefined,
} from "@/utils/Common";
import { AuthManager } from "@/utils/AuthManager";
import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr";
import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client";
import type { ResourceInfo } from "@/APIClient";
import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs";
import { HubConnection, HubConnectionState } from "@microsoft/signalr";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import {
JtagClient,
MatrixKeyClient,
PowerClient,
ResourceClient,
ResourcePurpose,
type ResourceInfo,
} from "@/APIClient";
import type {
IDigitalTubesHub,
IJtagHub,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
export const useEquipments = defineStore("equipments", () => {
// Global Stores
@@ -23,6 +39,7 @@ export const useEquipments = defineStore("equipments", () => {
const boardPort = useLocalStorage("fpga-board-port", 1234);
// Jtag
const enableJtagBoundaryScan = ref(false);
const jtagBitstream = ref<File>();
const jtagBoundaryScanFreq = ref(100);
const jtagUserBitstreams = ref<ResourceInfo[]>([]);
@@ -36,8 +53,7 @@ export const useEquipments = defineStore("equipments", () => {
onMounted(async () => {
// 每次挂载都重新创建连接
jtagHubConnection.value =
AuthManager.createAuthenticatedJtagHubConnection();
jtagHubConnection.value = AuthManager.createHubConnection("JtagHub");
jtagHubProxy.value = getHubProxyFactory("IJtagHub").createHubProxy(
jtagHubConnection.value,
);
@@ -59,46 +75,6 @@ export const useEquipments = defineStore("equipments", () => {
}
});
// Matrix Key
const matrixKeyStates = reactive(new Array<boolean>(16).fill(false));
const matrixKeypadClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
// Power
const powerClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
// Enable Setting
const enableJtagBoundaryScan = ref(false);
const enableMatrixKey = ref(false);
const enablePower = ref(false);
function setMatrixKey(
keyNum: number | string | undefined,
keyValue: boolean,
): boolean {
let _keyNum: number;
if (isString(keyNum)) {
_keyNum = toNumber(keyNum);
} else if (isNumber(keyNum)) {
_keyNum = keyNum;
} else {
return false;
}
if (z.number().nonnegative().max(16).safeParse(_keyNum).success) {
matrixKeyStates[_keyNum] = keyValue;
return true;
}
return false;
}
async function jtagBoundaryScanSetOnOff(enable: boolean) {
if (isUndefined(jtagHubProxy.value)) {
console.error("JtagHub Not Initialize...");
@@ -126,15 +102,15 @@ export const useEquipments = defineStore("equipments", () => {
async function jtagUploadBitstream(
bitstream: File,
examId?: string,
): Promise<number | null> {
): Promise<string | null> {
try {
// 自动开启电源
await powerSetOnOff(true);
const resourceClient = AuthManager.createAuthenticatedResourceClient();
const resourceClient = AuthManager.createClient(ResourceClient);
const resp = await resourceClient.addResource(
"bitstream",
"user",
ResourcePurpose.User,
examId || null,
toFileParameterOrUndefined(bitstream),
);
@@ -152,7 +128,7 @@ export const useEquipments = defineStore("equipments", () => {
}
}
async function jtagDownloadBitstream(bitstreamId?: number): Promise<string> {
async function jtagDownloadBitstream(bitstreamId?: string): Promise<string> {
if (bitstreamId === null || bitstreamId === undefined) {
dialog.error("请先选择要下载的比特流");
return "";
@@ -163,7 +139,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.downloadBitstream(
boardAddr.value,
boardPort.value,
@@ -185,7 +161,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.getDeviceIDCode(
boardAddr.value,
boardPort.value,
@@ -205,7 +181,7 @@ export const useEquipments = defineStore("equipments", () => {
// 自动开启电源
await powerSetOnOff(true);
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
const resp = await jtagClient.setSpeed(
boardAddr.value,
boardPort.value,
@@ -220,12 +196,38 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// Matrix Key
const enableMatrixKey = ref(false);
const matrixKeyStates = reactive(new Array<boolean>(16).fill(false));
const matrixKeypadClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
function setMatrixKey(
keyNum: number | string | undefined,
keyValue: boolean,
): boolean {
let _keyNum: number;
if (isString(keyNum)) {
_keyNum = toNumber(keyNum);
} else if (isNumber(keyNum)) {
_keyNum = keyNum;
} else {
return false;
}
if (z.number().nonnegative().max(16).safeParse(_keyNum).success) {
matrixKeyStates[_keyNum] = keyValue;
return true;
}
return false;
}
async function matrixKeypadSetKeyStates(keyStates: boolean[]) {
const release = await matrixKeypadClientMutex.acquire();
console.log("set Key !!!!!!!!!!!!");
try {
const matrixKeypadClient =
AuthManager.createAuthenticatedMatrixKeyClient();
const matrixKeypadClient = AuthManager.createClient(MatrixKeyClient);
const resp = await matrixKeypadClient.setMatrixKeyStatus(
boardAddr.value,
boardPort.value,
@@ -243,9 +245,8 @@ export const useEquipments = defineStore("equipments", () => {
async function matrixKeypadEnable(enable: boolean) {
const release = await matrixKeypadClientMutex.acquire();
try {
const matrixKeypadClient = AuthManager.createClient(MatrixKeyClient);
if (enable) {
const matrixKeypadClient =
AuthManager.createAuthenticatedMatrixKeyClient();
const resp = await matrixKeypadClient.enabelMatrixKey(
boardAddr.value,
boardPort.value,
@@ -253,8 +254,6 @@ export const useEquipments = defineStore("equipments", () => {
enableMatrixKey.value = resp;
return resp;
} else {
const matrixKeypadClient =
AuthManager.createAuthenticatedMatrixKeyClient();
const resp = await matrixKeypadClient.disableMatrixKey(
boardAddr.value,
boardPort.value,
@@ -271,10 +270,17 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// Power
const powerClientMutex = withTimeout(
new Mutex(),
1000,
new Error("Matrixkeyclient Mutex Timeout!"),
);
const enablePower = ref(false);
async function powerSetOnOff(enable: boolean) {
const release = await powerClientMutex.acquire();
try {
const powerClient = AuthManager.createAuthenticatedPowerClient();
const powerClient = AuthManager.createClient(PowerClient);
const resp = await powerClient.setPowerOnOff(
boardAddr.value,
boardPort.value,
@@ -290,6 +296,74 @@ export const useEquipments = defineStore("equipments", () => {
}
}
// Seven Segment Display
const enableSevenSegmentDisplay = ref(false);
const sevenSegmentDisplayFrequency = ref(100);
const sevenSegmentDisplayData = ref<Uint8Array>();
const sevenSegmentDisplayHub = ref<HubConnection>();
const sevenSegmentDisplayHubProxy = ref<IDigitalTubesHub>();
async function sevenSegmentDisplaySetOnOff(enable: boolean) {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
if (enable) {
await sevenSegmentDisplayHubProxy.value.startScan();
} else {
await sevenSegmentDisplayHubProxy.value.stopScan();
}
}
async function sevenSegmentDisplaySetFrequency(frequency: number) {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
await sevenSegmentDisplayHubProxy.value.setFrequency(frequency);
}
async function sevenSegmentDisplayGetStatus() {
if (!sevenSegmentDisplayHub.value || !sevenSegmentDisplayHubProxy.value)
return;
if (sevenSegmentDisplayHub.value.state === HubConnectionState.Disconnected)
await sevenSegmentDisplayHub.value.start();
return await sevenSegmentDisplayHubProxy.value.getStatus();
}
async function handleSevenSegmentDisplayOnReceive(msg: string) {
const bytes = base64ToArrayBuffer(msg);
sevenSegmentDisplayData.value = new Uint8Array(bytes);
}
onMounted(async () => {
// 每次挂载都重新创建连接
sevenSegmentDisplayHub.value =
AuthManager.createHubConnection("DigitalTubesHub");
sevenSegmentDisplayHubProxy.value = getHubProxyFactory(
"IDigitalTubesHub",
).createHubProxy(sevenSegmentDisplayHub.value);
getReceiverRegister("IDigitalTubesReceiver").register(
sevenSegmentDisplayHub.value,
{
onReceive: handleSevenSegmentDisplayOnReceive,
},
);
});
onUnmounted(() => {
// 断开连接,清理资源
if (sevenSegmentDisplayHub.value) {
sevenSegmentDisplayHub.value.stop();
sevenSegmentDisplayHub.value = undefined;
sevenSegmentDisplayHubProxy.value = undefined;
}
});
return {
boardAddr,
boardPort,
@@ -317,5 +391,13 @@ export const useEquipments = defineStore("equipments", () => {
enablePower,
powerClientMutex,
powerSetOnOff,
// Seven Segment Display
enableSevenSegmentDisplay,
sevenSegmentDisplayData,
sevenSegmentDisplayFrequency,
sevenSegmentDisplaySetOnOff,
sevenSegmentDisplaySetFrequency,
sevenSegmentDisplayGetStatus,
};
});

83
src/stores/progress.ts Normal file
View File

@@ -0,0 +1,83 @@
import type { HubConnection } from "@microsoft/signalr";
import type {
IProgressHub,
IProgressReceiver,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import { ProgressStatus, type ProgressInfo } from "@/utils/signalR/server.Hubs";
import { onMounted, onUnmounted, ref, shallowRef } from "vue";
import { defineStore } from "pinia";
import { AuthManager } from "@/utils/AuthManager";
import { forEach, isUndefined } from "lodash";
export type ProgressCallback = (msg: ProgressInfo) => void;
export const useProgressStore = defineStore("progress", () => {
// taskId -> name -> callback
const progressCallbackFuncs = shallowRef<
Map<string, Map<string, ProgressCallback>>
>(new Map());
const progressHubConnection = shallowRef<HubConnection>();
const progressHubProxy = shallowRef<IProgressHub>();
const progressHubReceiver: IProgressReceiver = {
onReceiveProgress: async (msg) => {
const taskMap = progressCallbackFuncs.value.get(msg.taskId);
if (taskMap) {
for (const func of taskMap.values()) {
func(msg);
}
}
},
};
onMounted(async () => {
progressHubConnection.value =
AuthManager.createHubConnection("ProgressHub");
progressHubProxy.value = getHubProxyFactory("IProgressHub").createHubProxy(
progressHubConnection.value,
);
getReceiverRegister("IProgressReceiver").register(
progressHubConnection.value,
progressHubReceiver,
);
progressHubConnection.value.start();
});
onUnmounted(() => {
if (progressHubConnection.value) {
progressHubConnection.value.stop();
progressHubConnection.value = undefined;
progressHubProxy.value = undefined;
}
});
function register(progressId: string, name: string, func: ProgressCallback) {
progressHubProxy.value?.join(progressId);
let taskMap = progressCallbackFuncs.value.get(progressId);
if (!taskMap) {
taskMap = new Map();
progressCallbackFuncs.value?.set(progressId, taskMap);
}
taskMap.set(name, func);
}
function unregister(taskId: string, name: string) {
progressHubProxy.value?.leave(taskId);
const taskMap = progressCallbackFuncs.value.get(taskId);
if (taskMap) {
taskMap.delete(name);
if (taskMap.size === 0) {
progressCallbackFuncs.value?.delete(taskId);
}
}
}
return {
register,
unregister,
};
});

View File

@@ -1,67 +1,73 @@
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { ref, computed, watch } from "vue";
import { defineStore } from "pinia";
// 本地存储主题的键名
const THEME_STORAGE_KEY = 'fpga-weblab-theme'
const THEME_STORAGE_KEY = "fpga-weblab-theme";
export const useThemeStore = defineStore('theme', () => {
const allTheme = ["winter", "night"]
export const useThemeStore = defineStore("theme", () => {
const allTheme = ["winter", "night"];
const darkTheme = "night";
const lightTheme = "winter";
// 尝试从本地存储中获取保存的主题
const getSavedTheme = (): string | null => {
return localStorage.getItem(THEME_STORAGE_KEY)
}
return localStorage.getItem(THEME_STORAGE_KEY);
};
// 检测系统主题偏好
const getPreferredTheme = (): string => {
const savedTheme = getSavedTheme()
const savedTheme = getSavedTheme();
// 如果有保存的主题设置,优先使用
if (savedTheme && allTheme.includes(savedTheme)) {
return savedTheme
return savedTheme;
}
// 否则检测系统主题模式
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? darkTheme : lightTheme
}
return window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches
? darkTheme
: lightTheme;
};
// 初始化主题为首选主题
const currentTheme = ref(getPreferredTheme())
const currentTheme = ref(getPreferredTheme());
const currentMode = computed(() =>
currentTheme.value === darkTheme ? "dark" : "light",
);
// 保存主题到本地存储
const saveTheme = (theme: string) => {
localStorage.setItem(THEME_STORAGE_KEY, theme)
}
localStorage.setItem(THEME_STORAGE_KEY, theme);
};
// 当主题变化时,保存到本地存储
watch(currentTheme, (newTheme) => {
saveTheme(newTheme)
})
saveTheme(newTheme);
});
// 添加系统主题变化的监听
const setupThemeListener = () => {
if (window.matchMedia) {
const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const colorSchemeQuery = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const handler = (e: MediaQueryListEvent) => {
// 只有当用户没有手动设置过主题时,才跟随系统变化
if (!getSavedTheme()) {
currentTheme.value = e.matches ? darkTheme : lightTheme
}
currentTheme.value = e.matches ? darkTheme : lightTheme;
}
};
// 添加主题变化监听器
colorSchemeQuery.addEventListener('change', handler)
}
colorSchemeQuery.addEventListener("change", handler);
}
};
function setTheme(theme: string) {
const isContained: boolean = allTheme.includes(theme)
const isContained: boolean = allTheme.includes(theme);
if (isContained) {
currentTheme.value = theme
saveTheme(theme) // 保存主题到本地存储
}
else {
console.error(`Not have such theme: ${theme}`)
currentTheme.value = theme;
saveTheme(theme); // 保存主题到本地存储
} else {
console.error(`Not have such theme: ${theme}`);
}
}
@@ -77,26 +83,26 @@ export const useThemeStore = defineStore('theme', () => {
}
function isDarkTheme(): boolean {
return currentTheme.value == darkTheme
return currentTheme.value == darkTheme;
}
function isLightTheme(): boolean {
return currentTheme.value == lightTheme
return currentTheme.value == lightTheme;
}
// 初始化时设置系统主题变化监听器
if (typeof window !== 'undefined') {
setupThemeListener()
if (typeof window !== "undefined") {
setupThemeListener();
}
return {
allTheme,
currentTheme,
currentMode,
setTheme,
toggleTheme,
isDarkTheme,
isLightTheme,
setupThemeListener
}
})
setupThemeListener,
};
});

View File

@@ -1,325 +1,105 @@
import {
DataClient,
VideoStreamClient,
BsdlParserClient,
DDSClient,
JtagClient,
MatrixKeyClient,
PowerClient,
RemoteUpdateClient,
TutorialClient,
UDPClient,
LogicAnalyzerClient,
NetConfigClient,
OscilloscopeApiClient,
DebuggerClient,
ExamClient,
ResourceClient,
HdmiVideoStreamClient,
} from "@/APIClient";
import router from "@/router";
import { DataClient } from "@/APIClient";
import { HubConnectionBuilder } from "@microsoft/signalr";
import axios, { type AxiosInstance } from "axios";
import { isNull } from "lodash";
// 支持的客户端类型联合类型
type SupportedClient =
| DataClient
| VideoStreamClient
| BsdlParserClient
| DDSClient
| JtagClient
| MatrixKeyClient
| PowerClient
| RemoteUpdateClient
| TutorialClient
| LogicAnalyzerClient
| UDPClient
| NetConfigClient
| OscilloscopeApiClient
| DebuggerClient
| ExamClient
| ResourceClient
| HdmiVideoStreamClient;
// 简单到让人想哭的认证管理器
export class AuthManager {
// 存储token到localStorage
public static setToken(token: string): void {
localStorage.setItem("authToken", token);
private static readonly TOKEN_KEY = "authToken";
// 核心数据:就是个字符串
static getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
// 从localStorage获取token
public static getToken(): string | null {
return localStorage.getItem("authToken");
static setToken(token: string): void {
localStorage.setItem(this.TOKEN_KEY, token);
}
// 清除token
public static clearToken(): void {
localStorage.removeItem("authToken");
static clearToken(): void {
localStorage.removeItem(this.TOKEN_KEY);
}
// 检查是否已认证
public static async isAuthenticated(): Promise<boolean> {
return await AuthManager.verifyToken();
// 核心功能创建带认证的HTTP配置
static getAuthHeaders(): Record<string, string> {
const token = this.getToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
// 通用的为HTTP请求添加Authorization header的方法
public static addAuthHeader(client: SupportedClient): void {
const token = AuthManager.getToken();
if (token) {
// 创建一个自定义的 http 对象,包装原有的 fetch 方法
const customHttp = {
fetch: (url: RequestInfo, init?: RequestInit) => {
if (!init) init = {};
if (!init.headers) init.headers = {};
// 添加Authorization header
if (typeof init.headers === "object" && init.headers !== null) {
(init.headers as any)["Authorization"] = `Bearer ${token}`;
}
// 使用全局 fetch 或 window.fetch
return (window as any).fetch(url, init);
},
};
// 重新构造客户端,传入自定义的 http 对象
const ClientClass = client.constructor as new (
// 一个方法搞定所有客户端不要17个垃圾方法
static createClient<T>(
ClientClass: new (baseUrl?: string, config?: any) => T,
baseUrl?: string,
http?: any,
) => SupportedClient;
const newClient = new ClientClass(undefined, customHttp);
// 将新客户端的属性复制到原客户端(这是一个 workaround
// 更好的做法是返回新的客户端实例
Object.setPrototypeOf(client, Object.getPrototypeOf(newClient));
Object.assign(client, newClient);
}
}
// 私有方法创建带认证的HTTP客户端
private static createAuthenticatedHttp() {
const token = AuthManager.getToken();
if (!token) {
return null;
}
return {
fetch: (url: RequestInfo, init?: RequestInit) => {
if (!init) init = {};
if (!init.headers) init.headers = {};
if (typeof init.headers === "object" && init.headers !== null) {
(init.headers as any)["Authorization"] = `Bearer ${token}`;
}
return (window as any).fetch(url, init);
},
};
}
// 私有方法创建带认证的Axios实例
private static createAuthenticatedAxiosInstance(): AxiosInstance | null {
const token = AuthManager.getToken();
if (!token) return null;
const instance = axios.create();
instance.interceptors.request.use((config) => {
config.headers = config.headers || {};
(config.headers as any)["Authorization"] = `Bearer ${token}`;
return config;
});
return instance;
}
// 通用的创建已认证客户端的方法(使用泛型)
public static createAuthenticatedClient<T extends SupportedClient>(
ClientClass: new (baseUrl?: string, instance?: AxiosInstance) => T,
): T {
const axiosInstance = AuthManager.createAuthenticatedAxiosInstance();
return axiosInstance
? new ClientClass(undefined, axiosInstance)
: new ClientClass();
}
// 便捷方法:创建已配置认证的各种客户端
public static createAuthenticatedDataClient(): DataClient {
return AuthManager.createAuthenticatedClient(DataClient);
}
public static createAuthenticatedVideoStreamClient(): VideoStreamClient {
return AuthManager.createAuthenticatedClient(VideoStreamClient);
}
public static createAuthenticatedBsdlParserClient(): BsdlParserClient {
return AuthManager.createAuthenticatedClient(BsdlParserClient);
}
public static createAuthenticatedDDSClient(): DDSClient {
return AuthManager.createAuthenticatedClient(DDSClient);
}
public static createAuthenticatedJtagClient(): JtagClient {
return AuthManager.createAuthenticatedClient(JtagClient);
}
public static createAuthenticatedMatrixKeyClient(): MatrixKeyClient {
return AuthManager.createAuthenticatedClient(MatrixKeyClient);
}
public static createAuthenticatedPowerClient(): PowerClient {
return AuthManager.createAuthenticatedClient(PowerClient);
}
public static createAuthenticatedRemoteUpdateClient(): RemoteUpdateClient {
return AuthManager.createAuthenticatedClient(RemoteUpdateClient);
}
public static createAuthenticatedTutorialClient(): TutorialClient {
return AuthManager.createAuthenticatedClient(TutorialClient);
}
public static createAuthenticatedUDPClient(): UDPClient {
return AuthManager.createAuthenticatedClient(UDPClient);
}
public static createAuthenticatedLogicAnalyzerClient(): LogicAnalyzerClient {
return AuthManager.createAuthenticatedClient(LogicAnalyzerClient);
}
public static createAuthenticatedNetConfigClient(): NetConfigClient {
return AuthManager.createAuthenticatedClient(NetConfigClient);
}
public static createAuthenticatedOscilloscopeApiClient(): OscilloscopeApiClient {
return AuthManager.createAuthenticatedClient(OscilloscopeApiClient);
}
public static createAuthenticatedDebuggerClient(): DebuggerClient {
return AuthManager.createAuthenticatedClient(DebuggerClient);
}
public static createAuthenticatedExamClient(): ExamClient {
return AuthManager.createAuthenticatedClient(ExamClient);
}
public static createAuthenticatedResourceClient(): ResourceClient {
return AuthManager.createAuthenticatedClient(ResourceClient);
}
public static createAuthenticatedHdmiVideoStreamClient(): HdmiVideoStreamClient {
return AuthManager.createAuthenticatedClient(HdmiVideoStreamClient);
}
public static createAuthenticatedJtagHubConnection() {
const token = this.getToken();
if (isNull(token)) {
router.push("/login");
throw Error("Token Null!");
if (!token) {
return new ClientClass(baseUrl);
}
// 对于axios客户端
const axiosInstance = axios.create({
headers: this.getAuthHeaders(),
});
return new ClientClass(baseUrl, axiosInstance);
}
// SignalR连接 - 简单明了
static createHubConnection(
hubPath: "ProgressHub" | "JtagHub" | "DigitalTubesHub",
) {
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/JtagHub", {
accessTokenFactory: () => token,
.withUrl(`http://127.0.0.1:5000/hubs/${hubPath}`, {
accessTokenFactory: () => this.getToken() ?? "",
})
.withAutomaticReconnect()
.build();
}
public static createAuthenticatedProgressHubConnection() {
const token = this.getToken();
if (isNull(token)) {
router.push("/login");
throw Error("Token Null!");
}
return new HubConnectionBuilder()
.withUrl("http://127.0.0.1:5000/hubs/ProgressHub", {
accessTokenFactory: () => token,
})
.withAutomaticReconnect()
.build();
}
// 登录函数
public static async login(
username: string,
password: string,
): Promise<boolean> {
// 认证逻辑 - 去除所有废话
static async login(username: string, password: string): Promise<boolean> {
try {
const client = new DataClient();
const token = await client.login(username, password);
if (token) {
AuthManager.setToken(token);
if (!token) return false;
// 验证token
const authClient = AuthManager.createAuthenticatedDataClient();
await authClient.testAuth();
this.setToken(token);
// 验证token - 如果失败直接抛异常
await this.createClient(DataClient).testAuth();
return true;
}
return false;
} catch (error) {
AuthManager.clearToken();
throw error;
} catch {
this.clearToken();
throw new Error("Login failed");
}
}
// 登出函数
public static logout(): void {
AuthManager.clearToken();
static logout(): void {
this.clearToken();
}
// 验证当前token是否有效
public static async verifyToken(): Promise<boolean> {
// 简单的验证 - 不要搞复杂
static async isAuthenticated(): Promise<boolean> {
if (!this.getToken()) return false;
try {
const token = AuthManager.getToken();
if (!token) {
return false;
}
const client = AuthManager.createAuthenticatedDataClient();
await client.testAuth();
await this.createClient(DataClient).testAuth();
return true;
} catch (error) {
AuthManager.clearToken();
} catch {
this.clearToken();
return false;
}
}
// 验证管理员权限
public static async verifyAdminAuth(): Promise<boolean> {
static async isAdminAuthenticated(): Promise<boolean> {
if (!this.getToken()) return false;
try {
const token = AuthManager.getToken();
if (!token) {
return false;
}
const client = AuthManager.createAuthenticatedDataClient();
await client.testAdminAuth();
await this.createClient(DataClient).testAdminAuth();
return true;
} catch (error) {
// 只有在token完全无效的情况下才清除token
// 401错误表示token有效但权限不足不应清除token
if (error && typeof error === "object" && "status" in error) {
// 如果是403 (Forbidden) 或401 (Unauthorized)说明token有效但权限不足
if (error.status === 401 || error.status === 403) {
} catch {
this.clearToken();
return false;
}
// 其他状态码可能表示token无效清除token
AuthManager.clearToken();
} else {
// 网络错误等不清除token
console.error("管理员权限验证失败:", error);
}
return false;
}
}
// 检查客户端是否已配置认证
public static isClientAuthenticated(client: SupportedClient): boolean {
const token = AuthManager.getToken();
return !!token;
}
}

View File

@@ -17,7 +17,7 @@ export interface BoardData extends Board {
const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
// 远程升级相关参数
const devPort = 1234;
const remoteUpdater = AuthManager.createAuthenticatedRemoteUpdateClient();
const remoteUpdater = AuthManager.createClient(RemoteUpdateClient);
// 统一的板卡数据
const boards = ref<BoardData[]>([]);
@@ -35,13 +35,13 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
async function getAllBoards(): Promise<{ success: boolean; error?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const result = await client.getAllBoards();
if (result) {
@@ -77,7 +77,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
): Promise<{ success: boolean; error?: string; boardId?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
@@ -89,11 +89,11 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
return { success: false, error: "参数不完整" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const boardId = await client.addBoard(name);
if (boardId) {
console.log("新增板卡成功", { boardId, name});
console.log("新增板卡成功", { boardId, name });
// 刷新板卡列表
await getAllBoards();
return { success: true };
@@ -119,7 +119,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
): Promise<{ success: boolean; error?: string }> {
try {
// 验证管理员权限
const hasAdminAuth = await AuthManager.verifyAdminAuth();
const hasAdminAuth = await AuthManager.isAdminAuthenticated();
if (!hasAdminAuth) {
console.error("权限验证失败");
return { success: false, error: "权限不足" };
@@ -130,7 +130,7 @@ const [useProvideBoardManager, useBoardManager] = createInjectionState(() => {
return { success: false, error: "板卡ID不能为空" };
}
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const result = await client.deleteBoard(boardId);
if (result > 0) {

View File

@@ -48,3 +48,23 @@ export function useOptionalInjection<T>(
const value = useFn();
return value ?? defaultValue;
}
export function formatDate(date: Date | string) {
const dateObj = typeof date === "string" ? new Date(date) : date;
return dateObj.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function base64ToArrayBuffer(base64: string) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}

View File

@@ -3,8 +3,8 @@
/* tslint:disable */
// @ts-nocheck
import type { HubConnection, IStreamResult, Subject } from '@microsoft/signalr';
import type { IJtagHub, IProgressHub, IJtagReceiver, IProgressReceiver } from './server.Hubs';
import type { ProgressInfo } from '../server.Hubs';
import type { IDigitalTubesHub, IJtagHub, IProgressHub, IDigitalTubesReceiver, IJtagReceiver, IProgressReceiver } from './server.Hubs';
import type { DigitalTubeTaskStatus, ProgressInfo } from '../server.Hubs';
// components
@@ -43,11 +43,15 @@ class ReceiverMethodSubscription implements Disposable {
// API
export type HubProxyFactoryProvider = {
(hubType: "IDigitalTubesHub"): HubProxyFactory<IDigitalTubesHub>;
(hubType: "IJtagHub"): HubProxyFactory<IJtagHub>;
(hubType: "IProgressHub"): HubProxyFactory<IProgressHub>;
}
export const getHubProxyFactory = ((hubType: string) => {
if(hubType === "IDigitalTubesHub") {
return IDigitalTubesHub_HubProxyFactory.Instance;
}
if(hubType === "IJtagHub") {
return IJtagHub_HubProxyFactory.Instance;
}
@@ -57,11 +61,15 @@ export const getHubProxyFactory = ((hubType: string) => {
}) as HubProxyFactoryProvider;
export type ReceiverRegisterProvider = {
(receiverType: "IDigitalTubesReceiver"): ReceiverRegister<IDigitalTubesReceiver>;
(receiverType: "IJtagReceiver"): ReceiverRegister<IJtagReceiver>;
(receiverType: "IProgressReceiver"): ReceiverRegister<IProgressReceiver>;
}
export const getReceiverRegister = ((receiverType: string) => {
if(receiverType === "IDigitalTubesReceiver") {
return IDigitalTubesReceiver_Binder.Instance;
}
if(receiverType === "IJtagReceiver") {
return IJtagReceiver_Binder.Instance;
}
@@ -72,6 +80,39 @@ export const getReceiverRegister = ((receiverType: string) => {
// HubProxy
class IDigitalTubesHub_HubProxyFactory implements HubProxyFactory<IDigitalTubesHub> {
public static Instance = new IDigitalTubesHub_HubProxyFactory();
private constructor() {
}
public readonly createHubProxy = (connection: HubConnection): IDigitalTubesHub => {
return new IDigitalTubesHub_HubProxy(connection);
}
}
class IDigitalTubesHub_HubProxy implements IDigitalTubesHub {
public constructor(private connection: HubConnection) {
}
public readonly startScan = async (): Promise<boolean> => {
return await this.connection.invoke("StartScan");
}
public readonly stopScan = async (): Promise<boolean> => {
return await this.connection.invoke("StopScan");
}
public readonly setFrequency = async (frequency: number): Promise<boolean> => {
return await this.connection.invoke("SetFrequency", frequency);
}
public readonly getStatus = async (): Promise<DigitalTubeTaskStatus> => {
return await this.connection.invoke("GetStatus");
}
}
class IJtagHub_HubProxyFactory implements HubProxyFactory<IJtagHub> {
public static Instance = new IJtagHub_HubProxyFactory();
@@ -120,11 +161,40 @@ class IProgressHub_HubProxy implements IProgressHub {
public readonly join = async (taskId: string): Promise<boolean> => {
return await this.connection.invoke("Join", taskId);
}
public readonly leave = async (taskId: string): Promise<boolean> => {
return await this.connection.invoke("Leave", taskId);
}
public readonly getProgress = async (taskId: string): Promise<ProgressInfo> => {
return await this.connection.invoke("GetProgress", taskId);
}
}
// Receiver
class IDigitalTubesReceiver_Binder implements ReceiverRegister<IDigitalTubesReceiver> {
public static Instance = new IDigitalTubesReceiver_Binder();
private constructor() {
}
public readonly register = (connection: HubConnection, receiver: IDigitalTubesReceiver): Disposable => {
const __onReceive = (...args: [string]) => receiver.onReceive(...args);
connection.on("OnReceive", __onReceive);
const methodList: ReceiverMethod[] = [
{ methodName: "OnReceive", method: __onReceive }
]
return new ReceiverMethodSubscription(connection, methodList);
}
}
class IJtagReceiver_Binder implements ReceiverRegister<IJtagReceiver> {
public static Instance = new IJtagReceiver_Binder();

View File

@@ -3,7 +3,27 @@
/* tslint:disable */
// @ts-nocheck
import type { IStreamResult, Subject } from '@microsoft/signalr';
import type { ProgressInfo } from '../server.Hubs';
import type { DigitalTubeTaskStatus, ProgressInfo } from '../server.Hubs';
export type IDigitalTubesHub = {
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
startScan(): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
stopScan(): Promise<boolean>;
/**
* @param frequency Transpiled from int
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
setFrequency(frequency: number): Promise<boolean>;
/**
* @returns Transpiled from System.Threading.Tasks.Task<server.Hubs.DigitalTubeTaskStatus?>
*/
getStatus(): Promise<DigitalTubeTaskStatus>;
}
export type IJtagHub = {
/**
@@ -28,6 +48,24 @@ export type IProgressHub = {
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
join(taskId: string): Promise<boolean>;
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<bool>
*/
leave(taskId: string): Promise<boolean>;
/**
* @param taskId Transpiled from string
* @returns Transpiled from System.Threading.Tasks.Task<server.Hubs.ProgressInfo?>
*/
getProgress(taskId: string): Promise<ProgressInfo>;
}
export type IDigitalTubesReceiver = {
/**
* @param data Transpiled from byte[]
* @returns Transpiled from System.Threading.Tasks.Task
*/
onReceive(data: string): Promise<void>;
}
export type IJtagReceiver = {

View File

@@ -2,13 +2,20 @@
/* eslint-disable */
/* tslint:disable */
/** Transpiled from server.Hubs.DigitalTubeTaskStatus */
export type DigitalTubeTaskStatus = {
/** Transpiled from int */
frequency: number;
/** Transpiled from bool */
isRunning: boolean;
}
/** Transpiled from server.Hubs.ProgressStatus */
export enum ProgressStatus {
Pending = 0,
InProgress = 1,
Completed = 2,
Canceled = 3,
Failed = 4,
Running = 0,
Completed = 1,
Canceled = 2,
Failed = 3,
}
/** Transpiled from server.Hubs.ProgressInfo */
@@ -17,7 +24,7 @@ export type ProgressInfo = {
taskId: string;
/** Transpiled from server.Hubs.ProgressStatus */
status: ProgressStatus;
/** Transpiled from int */
/** Transpiled from double */
progressPercent: number;
/** Transpiled from string */
errorMessage: string;

View File

@@ -2,7 +2,10 @@
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="relative w-full max-w-md">
<!-- Login Card -->
<div v-if="!showSignUp" class="card card-dash h-80 w-100 shadow-xl bg-base-100">
<div
v-if="!showSignUp"
class="card card-dash h-80 w-100 shadow-xl bg-base-100"
>
<div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户登录</h1>
<div class="flex flex-col w-full h-full">
@@ -44,7 +47,10 @@
</div>
<!-- Sign Up Card -->
<div v-if="showSignUp" class="card card-dash h-96 w-100 shadow-xl bg-base-100">
<div
v-if="showSignUp"
class="card card-dash h-96 w-100 shadow-xl bg-base-100"
>
<div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户注册</h1>
<div class="flex flex-col w-full h-full">
@@ -122,7 +128,7 @@ const isSignUpLoading = ref(false);
const signUpData = ref({
username: "",
email: "",
password: ""
password: "",
});
// 登录处理函数
@@ -149,7 +155,7 @@ const handleLogin = async () => {
// 短暂延迟后跳转到project页面
setTimeout(async () => {
await router.push("/project");
router.go(-1);
}, 1000);
} catch (error: any) {
console.error("Login error:", error);
@@ -180,7 +186,7 @@ const handleRegister = () => {
signUpData.value = {
username: "",
email: "",
password: ""
password: "",
};
};
@@ -227,7 +233,7 @@ const handleSignUp = async () => {
const result = await dataClient.signUpUser(
signUpData.value.username.trim(),
signUpData.value.email.trim(),
signUpData.value.password.trim()
signUpData.value.password.trim(),
);
if (result) {
@@ -268,10 +274,10 @@ const handleSignUp = async () => {
// 页面初始化时检查是否已有有效token
const checkExistingToken = async () => {
try {
const isValid = await AuthManager.verifyToken();
const isValid = await AuthManager.isAuthenticated();
if (isValid) {
// 如果token仍然有效直接跳转到project页面
await router.push("/project");
router.go(-1);
}
} catch (error) {
// token无效或验证失败继续显示登录页面

View File

@@ -0,0 +1,781 @@
<template>
<div v-if="isShowModal" class="modal modal-open overflow-hidden">
<div class="modal-box w-full max-w-7xl max-h-[90vh] p-0 overflow-hidden">
<div
class="flex justify-between items-center p-6 border-b border-base-300"
>
<h2 class="text-2xl font-bold text-base-content">
{{ mode === "create" ? "新建实验" : "编辑实验" }}
</h2>
<button @click="close" class="btn btn-sm btn-circle btn-ghost">
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form @submit.prevent="submitCreateExam" class="flex h-[calc(90vh-5rem)]">
<!-- 左侧基本信息 -->
<div class="w-110 p-6 overflow-y-auto border-r border-base-300">
<div class="space-y-6">
<h3 class="text-xl font-semibold text-base-content mb-4">
基本信息
</h3>
<!-- 实验ID -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验ID *</span>
</label>
<input
type="text"
v-model="editExamInfo.id"
class="input input-bordered w-full"
placeholder="例如: EXP001"
required
/>
</div>
<!-- 实验名称 -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验名称 *</span>
</label>
<input
type="text"
v-model="editExamInfo.name"
class="input input-bordered w-full"
placeholder="实验名称"
required
/>
</div>
<!-- 实验描述 -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验描述 *</span>
</label>
<textarea
v-model="editExamInfo.description"
class="textarea textarea-bordered w-full h-32"
placeholder="详细描述实验内容、目标和要求..."
required
></textarea>
</div>
<!-- 标签 -->
<div class="form-control">
<div class="flex flex-wrap gap-2 mb-3 min-h-[2rem]">
<span
v-for="(tag, index) in editExamInfo.tags"
:key="index"
class="badge badge-primary gap-2"
>
{{ tag }}
<button
type="button"
@click="removeTag(index)"
class="text-primary-content hover:text-error"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</div>
<div class="flex gap-2">
<input
type="text"
v-model="newTagInput"
@keydown.enter.prevent="addTag"
class="input input-bordered flex-1"
placeholder="输入标签按回车添加"
/>
</div>
</div>
<!-- 难度等级 -->
<div class="form-control">
<div class="flex items-center justify-between p-4 rounded-lg">
<span class="label-text font-medium">难度等级 *</span>
<div class="flex items-center gap-4">
<div class="rating rating-lg">
<input
v-for="i in 5"
:key="i"
type="radio"
:value="i"
v-model="editExamInfo.difficulty"
class="mask mask-star-2 bg-orange-400"
/>
</div>
<span class="text-lg font-medium text-base-content"
>({{ editExamInfo.difficulty }}/5)</span
>
</div>
</div>
</div>
<!-- 可见性 -->
<div class="form-control">
<div class="p-4 rounded-lg">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
v-model="editExamInfo.isVisibleToUsers"
class="checkbox checkbox-primary"
/>
<div>
<span class="label-text font-medium">对学生可见</span>
<div class="text-sm text-base-content/70">
开启后学生可以在实验列表中看到此实验
</div>
</div>
</label>
</div>
</div>
<!-- 提交按钮 -->
<div class="pt-4 border-t border-base-300">
<div class="space-y-3">
<button
type="submit"
:disabled="isUpdating || !canCreateExam"
class="btn btn-primary w-full"
>
<span
v-if="isUpdating"
class="loading loading-spinner loading-sm mr-2"
></span>
{{
mode === "create"
? isUpdating
? "创建中..."
: "创建实验"
: isUpdating
? "更新中..."
: "更新实验"
}}
</button>
</div>
</div>
</div>
</div>
<!-- 右侧文件上传 -->
<div class="flex-1 p-6 overflow-y-auto">
<div class="space-y-6">
<h3 class="text-xl font-semibold text-base-content mb-4">
资源文件
</h3>
<!-- 第一行MD文档 图片资源 -->
<div class="grid grid-cols-2 gap-4">
<!-- MD文档 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>MD文档 (必需)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="mdFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="(e) => handleFileDrop(e, 'md')"
>
<div
v-if="!uploadFiles.mdFile"
class="flex flex-col items-center gap-3"
>
<FileTextIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .md 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<FileTextIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.mdFile.name }}
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="mdFileInput"
@change="(e) => handleFileChange(e, 'md')"
accept=".md"
class="hidden"
/>
</div>
<!-- 图片资源 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>图片资源 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="imageFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="(e) => handleFileDrop(e, 'image')"
>
<div
v-if="uploadFiles.imageFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<ImageIcon class="w-12 h-12 text-base-content opacity-40" />
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 PNG, JPG, GIF 等图片格式</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<ImageIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.imageFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="imageFilesInput"
@change="(e) => handleFileChange(e, 'image')"
accept="image/*"
multiple
class="hidden"
/>
</div>
</div>
<!-- 第二行示例比特流 画布模板 -->
<div class="grid grid-cols-2 gap-4">
<!-- 示例比特流 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>示例比特流 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="bitstreamFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="(e) => handleFileDrop(e, 'bitstream')"
>
<div
v-if="uploadFiles.bitstreamFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<BinaryIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .sbit, .bit, .bin 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<BinaryIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.bitstreamFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="bitstreamFilesInput"
@change="(e) => handleFileChange(e, 'bitstream')"
accept=".sbit,.bit,.bin"
multiple
class="hidden"
/>
</div>
<!-- 画布模板 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>画布模板 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="canvasFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="(e) => handleFileDrop(e, 'canvas')"
>
<div
v-if="uploadFiles.canvasFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<FileJsonIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .json 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<FileJsonIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.canvasFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="canvasFilesInput"
@change="(e) => handleFileChange(e, 'canvas')"
accept=".json"
multiple
class="hidden"
/>
</div>
</div>
<!-- 第三行资源包 (单独一个居中) -->
<div class="flex justify-center">
<div class="w-1/2 space-y-2">
<label class="text-sm font-medium text-base-content"
>资源包 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="resourceFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="(e) => handleFileDrop(e, 'resource')"
>
<div
v-if="!uploadFiles.resourceFile"
class="flex flex-col items-center gap-3"
>
<FileArchiveIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .zip, .rar, .7z 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<FileArchiveIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.resourceFile.name }}
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="resourceFileInput"
@change="(e) => handleFileChange(e, 'resource')"
accept=".zip,.rar,.7z"
class="hidden"
/>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-backdrop" @click="close"></div>
</div>
</template>
<script setup lang="ts">
import {
FileTextIcon,
ImageIcon,
BinaryIcon,
FileArchiveIcon,
FileJsonIcon,
} from "lucide-vue-next";
import {
ExamClient,
ExamDto,
ResourceClient,
type FileParameter,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common";
import { defineModel, ref, computed } from "vue";
import { mod } from "mathjs";
import type { ExamInfo } from "@/APIClient";
type Mode = "create" | "edit";
const isShowModal = defineModel<boolean>("isShowModal", {
default: false,
});
const emits = defineEmits<{
editFinished: [examId: string];
}>();
const alert = useRequiredInjection(useAlertStore);
const editExamInfo = ref({
id: "",
name: "",
description: "",
tags: [] as string[],
difficulty: 1,
isVisibleToUsers: true,
});
const isUpdating = ref(false);
const mode = ref<Mode>("create");
const newTagInput = ref("");
// 文件上传相关
const uploadFiles = ref({
mdFile: null as File | null,
imageFiles: [] as File[],
bitstreamFiles: [] as File[],
canvasFiles: [] as File[],
resourceFile: null as File | null,
});
// 文件输入引用
const mdFileInput = ref<HTMLInputElement>();
const imageFilesInput = ref<HTMLInputElement>();
const bitstreamFilesInput = ref<HTMLInputElement>();
const canvasFilesInput = ref<HTMLInputElement>();
const resourceFileInput = ref<HTMLInputElement>();
// 计算属性
const canCreateExam = computed(() => {
return (
editExamInfo.value.id.trim() !== "" &&
editExamInfo.value.name.trim() !== "" &&
editExamInfo.value.description.trim() !== "" &&
(uploadFiles.value.mdFile !== null || mode.value === "edit")
);
});
// 文件类型定义
type FileType = "md" | "image" | "bitstream" | "canvas" | "resource";
// 统一文件处理方法
const handleFileChange = (event: Event, fileType: FileType) => {
const target = event.target as HTMLInputElement;
if (!target.files) return;
switch (fileType) {
case "md":
if (target.files.length > 0) {
uploadFiles.value.mdFile = target.files[0];
}
break;
case "image":
uploadFiles.value.imageFiles = Array.from(target.files);
break;
case "bitstream":
uploadFiles.value.bitstreamFiles = Array.from(target.files);
break;
case "canvas":
uploadFiles.value.canvasFiles = Array.from(target.files);
break;
case "resource":
if (target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
}
break;
}
};
const handleFileDrop = (event: DragEvent, fileType: FileType) => {
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
switch (fileType) {
case "md":
const mdFile = files[0];
if (mdFile.name.endsWith(".md")) {
uploadFiles.value.mdFile = mdFile;
}
break;
case "image":
const imageFiles = Array.from(files).filter((file) =>
file.type.startsWith("image/"),
);
uploadFiles.value.imageFiles = imageFiles;
break;
case "bitstream":
const bitstreamFiles = Array.from(files).filter(
(file) =>
file.name.endsWith(".sbit") ||
file.name.endsWith(".bit") ||
file.name.endsWith(".bin"),
);
uploadFiles.value.bitstreamFiles = bitstreamFiles;
break;
case "canvas":
const canvasFiles = Array.from(files).filter((file) =>
file.name.endsWith(".json"),
);
uploadFiles.value.canvasFiles = canvasFiles;
break;
case "resource":
const resourceFile = files[0];
if (
resourceFile.name.endsWith(".zip") ||
resourceFile.name.endsWith(".rar") ||
resourceFile.name.endsWith(".7z")
) {
uploadFiles.value.resourceFile = resourceFile;
}
break;
}
};
// 标签管理
const addTag = (event?: Event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const tag = newTagInput.value.trim();
if (tag && !editExamInfo.value.tags.includes(tag)) {
editExamInfo.value.tags.push(tag);
newTagInput.value = "";
}
};
const removeTag = (index: number) => {
editExamInfo.value.tags.splice(index, 1);
};
const resetCreateForm = () => {
editExamInfo.value = {
id: "",
name: "",
description: "",
tags: [],
difficulty: 1,
isVisibleToUsers: true,
};
newTagInput.value = "";
uploadFiles.value = {
mdFile: null,
imageFiles: [],
bitstreamFiles: [],
canvasFiles: [],
resourceFile: null,
};
// 重置文件输入
if (mdFileInput.value) mdFileInput.value.value = "";
if (imageFilesInput.value) imageFilesInput.value.value = "";
if (bitstreamFilesInput.value) bitstreamFilesInput.value.value = "";
if (canvasFilesInput.value) canvasFilesInput.value.value = "";
if (resourceFileInput.value) resourceFileInput.value.value = "";
};
// 提交创建实验
const submitCreateExam = async () => {
if (isUpdating.value) return;
// 验证必填字段
if (
!editExamInfo.value.id ||
!editExamInfo.value.name ||
!editExamInfo.value.description
) {
alert?.error("请填写所有必填字段");
return;
}
if (!uploadFiles.value.mdFile) {
alert.error("请上传MD文档");
return;
}
isUpdating.value = true;
try {
const client = AuthManager.createClient(ExamClient);
let exam: ExamInfo;
if (mode.value === "create") {
// 创建实验请求
const createRequest = new ExamDto({
id: editExamInfo.value.id,
name: editExamInfo.value.name,
description: editExamInfo.value.description,
tags: editExamInfo.value.tags,
difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
});
// 创建实验
exam = await client.createExam(createRequest);
console.log("实验创建成功:", exam);
} else if (mode.value === "edit") {
// 编辑实验请求
const editRequest = new ExamDto({
id: editExamInfo.value.id,
name: editExamInfo.value.name,
description: editExamInfo.value.description,
tags: editExamInfo.value.tags,
difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
});
// 编辑实验
exam = await client.updateExam(editRequest);
console.log("实验编辑成功:", exam);
} else {
// 处理其他模式
console.error("未知的模式:", mode.value);
throw new Error("未知的模式");
}
// 上传文件
await uploadExamResources(exam.id);
alert.success("实验创建成功");
close();
emits("editFinished", exam.id);
} catch (err: any) {
console.error("创建实验失败:", err);
alert.error(err.message || "创建实验失败");
} finally {
isUpdating.value = false;
}
};
// 上传实验资源
async function uploadExamResources(examId: string) {
const client = AuthManager.createClient(ResourceClient);
try {
// 上传MD文档
if (uploadFiles.value.mdFile) {
const mdFileParam: FileParameter = {
data: uploadFiles.value.mdFile,
fileName: uploadFiles.value.mdFile.name,
};
await client.addResource("doc", "template", examId, mdFileParam);
console.log("MD文档上传成功");
}
// 上传图片资源
for (const imageFile of uploadFiles.value.imageFiles) {
const imageFileParam: FileParameter = {
data: imageFile,
fileName: imageFile.name,
};
await client.addResource("image", "template", examId, imageFileParam);
console.log("图片上传成功:", imageFile.name);
}
// 上传比特流文件
for (const bitstreamFile of uploadFiles.value.bitstreamFiles) {
const bitstreamFileParam: FileParameter = {
data: bitstreamFile,
fileName: bitstreamFile.name,
};
await client.addResource(
"bitstream",
"template",
examId,
bitstreamFileParam,
);
console.log("比特流文件上传成功:", bitstreamFile.name);
}
// 上传画布模板
for (const canvasFile of uploadFiles.value.canvasFiles) {
const canvasFileParam: FileParameter = {
data: canvasFile,
fileName: canvasFile.name,
};
await client.addResource("canvas", "template", examId, canvasFileParam);
console.log("画布模板上传成功:", canvasFile.name);
}
// 上传资源包
if (uploadFiles.value.resourceFile) {
const resourceFileParam: FileParameter = {
data: uploadFiles.value.resourceFile,
fileName: uploadFiles.value.resourceFile.name,
};
await client.addResource(
"resource",
"template",
examId,
resourceFileParam,
);
console.log("资源包上传成功");
}
} catch (err: any) {
console.error("资源上传失败:", err);
alert?.error("部分资源上传失败: " + (err.message || "未知错误"));
}
}
function show() {
isShowModal.value = true;
}
function close() {
isShowModal.value = false;
mode.value = "create";
resetCreateForm();
}
async function editExam(examId: string) {
const client = AuthManager.createClient(ExamClient);
const examInfo = await client.getExam(examId);
editExamInfo.value = {
id: examInfo.id,
name: examInfo.name,
description: examInfo.description,
tags: examInfo.tags,
difficulty: examInfo.difficulty,
isVisibleToUsers: examInfo.isVisibleToUsers,
};
mode.value = "edit";
show();
}
defineExpose({
show,
close,
editExam,
});
</script>
<style lang="postcss" scoped></style>

View File

@@ -0,0 +1,358 @@
<template>
<div v-if="show" class="modal modal-open overflow-hidden">
<div
class="modal-box w-full max-w-6xl h-[90vh] max-h-[90vh] p-0 overflow-hidden"
>
<div
class="flex justify-between items-center p-6 border-b border-base-300"
>
<h2 class="text-2xl font-bold text-base-content">
{{ selectedExam.id }} - {{ selectedExam.name }}
</h2>
<button
@click="closeExamDetail"
class="btn btn-sm btn-circle btn-ghost"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="flex h-[calc(90vh-5rem)]">
<!-- 左侧实验信息和描述 -->
<div class="flex-1 p-6 overflow-y-auto border-r border-base-300">
<div class="space-y-6">
<!-- 实验信息 -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg mb-4">实验信息</h3>
<div class="space-y-3">
<div class="flex">
<span class="font-medium text-base-content w-24"
>实验ID</span
>
<span class="text-base-content/70">{{
selectedExam.id
}}</span>
</div>
<div class="flex">
<span class="font-medium text-base-content w-24"
>实验名称</span
>
<span class="text-base-content/70">{{
selectedExam.name
}}</span>
</div>
<div class="flex">
<span class="font-medium text-base-content w-24"
>难度等级</span
>
<div class="flex items-center gap-2">
<div class="rating rating-sm">
<span
v-for="i in 5"
:key="i"
class="mask mask-star-2"
:class="
i <= selectedExam.difficulty
? 'bg-orange-400'
: 'bg-base-300'
"
></span>
</div>
<span class="text-sm text-base-content/50"
>({{ selectedExam.difficulty }}/5)</span
>
</div>
</div>
<div
v-if="selectedExam.tags && selectedExam.tags.length > 0"
class="flex"
>
<span class="font-medium text-base-content w-24"
>标签</span
>
<div class="flex flex-wrap gap-1">
<span
v-for="tag in selectedExam.tags"
:key="tag"
class="badge badge-outline badge-sm"
>{{ tag }}</span
>
</div>
</div>
<div class="flex">
<span class="font-medium text-base-content w-24"
>创建时间</span
>
<span class="text-base-content/70">{{
formatDate(selectedExam.createdTime)
}}</span>
</div>
<div class="flex">
<span class="font-medium text-base-content w-24"
>更新时间</span
>
<span class="text-base-content/70">{{
formatDate(selectedExam.updatedTime)
}}</span>
</div>
<div class="flex">
<span class="font-medium text-base-content w-24"
>可见性</span
>
<span class="text-base-content/70">{{
selectedExam.isVisibleToUsers
? "对学生可见"
: "仅管理员可见"
}}</span>
</div>
</div>
</div>
</div>
<!-- 实验描述 -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg mb-4">实验描述</h3>
<div class="prose prose-sm max-w-none">
<p class="text-base-content/70">
{{ selectedExam.description }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧完成情况和控制 -->
<div class="w-80 p-6 bg-base-200 overflow-y-auto">
<div class="space-y-6">
<!-- 完成情况 -->
<div class="card bg-base-100">
<div class="card-body">
<h3 class="card-title text-lg mb-4">完成情况</h3>
<div class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-base-content/70">当前状态</span>
<div class="badge badge-error">未完成</div>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/70">批阅状态</span>
<div class="badge badge-ghost">待提交</div>
</div>
<div class="flex justify-between items-center">
<span class="text-base-content/70">成绩</span>
<span class="text-base-content/50">未评分</span>
</div>
</div>
<div class="divider"></div>
<!-- 提交历史 -->
<div class="space-y-3">
<h4 class="font-medium text-base-content">提交历史</h4>
<div
v-if="isUndefined(commitsList)"
class="text-sm text-base-content/50 text-center py-4"
>
暂无提交记录
</div>
<div v-else class="overflow-y-auto">
<ul class="steps steps-vertical">
<li class="step step-primary">Register</li>
<li class="step step-primary">Choose plan</li>
<li class="step">Purchase</li>
<li class="step">Receive Product</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="space-y-3">
<button @click="startExam" class="btn btn-primary w-full">
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
开始实验
</button>
<button
@click="downloadResources"
class="btn btn-outline w-full"
:disabled="downloadingResources"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span v-if="downloadingResources">下载中...</span>
<span v-else>下载资源包</span>
</button>
<button class="btn btn-outline w-full">
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
查看记录
</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop" @click="closeExamDetail"></div>
</div>
</template>
<script setup lang="ts">
import {
ExamClient,
ResourceClient,
ResourcePurpose,
type ExamInfo,
type ResourceInfo,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common";
import { defineModel, ref } from "vue";
import { useRouter } from "vue-router";
import { formatDate } from "@/utils/Common";
import { computed } from "vue";
import { watch } from "vue";
import { isNull, isUndefined } from "lodash";
const alertStore = useRequiredInjection(useAlertStore);
const router = useRouter();
const show = defineModel<boolean>("show", {
default: false,
});
const props = defineProps<{
selectedExam: ExamInfo;
}>();
const commitsList = ref<ResourceInfo[]>();
async function updateCommits() {
const client = AuthManager.createClient(ExamClient);
const list = await client.getCommitsByExamId(props.selectedExam.id);
commitsList.value = list;
}
watch(() => props.selectedExam, updateCommits);
// Download resources
const downloadingResources = ref(false);
const downloadResources = async () => {
if (!props.selectedExam || downloadingResources.value) return;
downloadingResources.value = true;
try {
const resourceClient = AuthManager.createClient(ResourceClient);
// 获取资源包列表(模板资源)
const resourceList = await resourceClient.getResourceList(
props.selectedExam.id,
"resource",
ResourcePurpose.Template,
);
if (resourceList && resourceList.length > 0) {
// 使用新的ResourceClient API获取第一个资源包
const resourceId = resourceList[0].id;
const fileResponse = await resourceClient.getResourceById(resourceId);
// 创建Blob URL
const blobUrl = URL.createObjectURL(fileResponse.data);
// 创建下载链接
const link = document.createElement("a");
link.href = blobUrl;
link.download =
fileResponse.fileName ||
resourceList[0].name ||
`${props.selectedExam.name}_资源包`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 清理Blob URL
URL.revokeObjectURL(blobUrl);
alertStore.success("资料下载成功");
console.log("资料下载成功:", props.selectedExam.id);
} else {
alertStore.error("该实验暂无资料包");
}
} catch (err: any) {
alertStore.error(err.message || "下载资料失败");
console.error("下载资料失败:", err);
} finally {
downloadingResources.value = false;
}
};
// 开始实验
const startExam = () => {
if (props.selectedExam) {
// 跳转到项目页面传递实验ID
console.log("开始实验:", props.selectedExam.id);
router.push({
name: "project",
query: { examId: props.selectedExam.id },
});
}
};
const closeExamDetail = () => {
show.value = false;
};
</script>
<style lang="postcss" scoped></style>

312
src/views/Exam/Index.vue Normal file
View File

@@ -0,0 +1,312 @@
<template>
<div class="min-h-screen bg-base-100 p-5">
<div class="max-w-7xl mx-auto">
<div
class="flex justify-between items-center mb-8 pb-6 border-b border-base-300"
>
<h1 class="text-3xl font-bold text-base-content">实验列表</h1>
</div>
<div
v-if="loading"
class="flex flex-col items-center justify-center min-h-[300px]"
>
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
<p class="text-base-content/70">正在加载实验列表...</p>
</div>
<div
v-else-if="error"
class="flex flex-col items-center justify-center min-h-[300px]"
>
<div class="alert alert-error max-w-md">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">加载失败</h3>
<div class="text-xs">{{ error }}</div>
</div>
</div>
<button @click="refreshExams" class="btn btn-primary mt-4">重试</button>
</div>
<div v-else class="space-y-6">
<div
v-if="exams.length === 0 && !isAdmin"
class="flex flex-col items-center justify-center min-h-[300px] text-center"
>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
暂无实验
</h3>
<p class="text-base-content/50">
当前没有可用的实验请联系管理员添加实验内容
</p>
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<!-- 管理员添加实验卡片 -->
<div
v-if="isAdmin"
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02]"
@click="() => examEditModalRef?.show()"
>
<div class="card-body flex items-center justify-center text-center">
<div class="text-primary text-6xl mb-4">+</div>
<h3 class="text-lg font-semibold text-primary">添加新实验</h3>
<p class="text-sm text-primary/70">点击创建新的实验</p>
</div>
</div>
<div
v-for="exam in exams"
:key="exam.id"
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02] relative overflow-hidden"
@click="handleCardClicked($event, exam.id)"
>
<div class="card-body">
<div class="flex justify-between items-start mb-4">
<h3 class="card-title text-base-content">{{ exam.name }}</h3>
<div class="flex flex-row items-center gap-2">
<button
class="btn btn-ghost text-error hover:underline group"
@click="handleEditExamClicked($event, exam.id)"
>
<EditIcon
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
编辑
</button>
<span
class="card-title text-sm text-blue-600/50 dark:text-sky-400/50"
>{{ exam.id }}</span
>
</div>
</div>
<!-- 实验标签 -->
<div
v-if="exam.tags && exam.tags.length > 0"
class="flex flex-wrap gap-1 mb-3"
>
<span
v-for="tag in exam.tags"
:key="tag"
class="badge badge-outline badge-sm"
>{{ tag }}</span
>
</div>
<div class="space-y-2 text-sm text-base-content/70">
<div class="flex items-center gap-2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>创建{{ formatDate(exam.createdTime) }}</span>
</div>
<div class="flex items-center gap-2">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>更新{{ formatDate(exam.updatedTime) }}</span>
</div>
</div>
</div>
<!-- 难度书角标识 -->
<div
class="difficulty-corner"
:class="{
'difficulty-1': exam.difficulty === 1,
'difficulty-2': exam.difficulty === 2,
'difficulty-3': exam.difficulty === 3,
'difficulty-4': exam.difficulty === 4,
'difficulty-5': exam.difficulty === 5,
}"
></div>
</div>
</div>
</div>
</div>
<!-- 实验详情模态框 -->
<ExamInfoModal
v-if="selectedExam"
v-model:show="showInfoModal"
:selectedExam="selectedExam"
/>
<!-- 创建实验模态框 -->
<ExamEditModal
ref="examEditModalRef"
@edit-finished="handleEditExamFinished"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import { ExamClient, type ExamInfo } from "@/APIClient";
import { formatDate } from "@/utils/Common";
import ExamInfoModal from "./ExamInfoModal.vue";
import ExamEditModal from "./ExamEditModal.vue";
import router from "@/router";
import { EditIcon } from "lucide-vue-next";
import { templateRef } from "@vueuse/core";
// 响应式数据
const route = useRoute();
const exams = ref<ExamInfo[]>([]);
const selectedExam = ref<ExamInfo | null>(null);
const loading = ref(false);
const error = ref<string>("");
const isAdmin = ref(false);
// Modal
const examEditModalRef = templateRef("examEditModalRef");
const showInfoModal = ref(false);
async function refreshExams() {
loading.value = true;
error.value = "";
try {
const client = AuthManager.createClient(ExamClient);
exams.value = await client.getExamList();
} catch (err: any) {
error.value = err.message || "获取实验列表失败";
console.error("获取实验列表失败:", err);
} finally {
loading.value = false;
}
}
async function viewExam(examId: string) {
try {
const client = AuthManager.createClient(ExamClient);
selectedExam.value = await client.getExam(examId);
showInfoModal.value = true;
} catch (err: any) {
error.value = err.message || "获取实验详情失败";
console.error("获取实验详情失败:", err);
showInfoModal.value = false;
}
}
async function handleEditExamFinished() {
await refreshExams();
}
async function handleCardClicked(event: MouseEvent, examId: string) {
if (event.target instanceof HTMLButtonElement) return;
await viewExam(examId);
}
async function handleEditExamClicked(event: MouseEvent, examId: string) {
examEditModalRef?.value?.editExam(examId);
}
// 生命周期
onMounted(async () => {
const isAuthenticated = await AuthManager.isAuthenticated();
if (!isAuthenticated) {
router.push("/login");
}
isAdmin.value = await AuthManager.isAdminAuthenticated();
await refreshExams();
// 处理路由参数如果有examId则自动打开该实验的详情模态框
const examId = route.query.examId as string;
if (examId) {
await viewExam(examId);
}
});
</script>
<style scoped>
/* 难度书角样式 */
.difficulty-corner {
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
z-index: 10;
}
.difficulty-corner::before {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 48px 48px 0;
transition: all 0.3s ease;
}
/* 难度颜色渐变:绿色到红色 */
.difficulty-1::before {
border-color: transparent transparent rgba(6, 199, 77, 0.6) transparent; /* 绿色 80% 透明度 */
}
.difficulty-2::before {
border-color: transparent transparent rgba(127, 204, 11, 0.6) transparent; /* 黄绿色 80% 透明度 */
}
.difficulty-3::before {
border-color: transparent transparent rgba(255, 191, 0, 0.6) transparent; /* 黄色 80% 透明度 */
}
.difficulty-4::before {
border-color: transparent transparent rgba(255, 106, 0, 0.6) transparent; /* 橙色 80% 透明度 */
}
.difficulty-5::before {
border-color: transparent transparent rgba(245, 35, 35, 0.6) transparent; /* 红色 80% 透明度 */
}
/* 悬停效果 */
.card:hover .difficulty-corner::before {
filter: brightness(1.1);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -266,7 +266,12 @@
</template>
<script setup lang="ts">
import { CaptureMode, ChannelConfig, DebuggerConfig } from "@/APIClient";
import {
CaptureMode,
ChannelConfig,
DebuggerClient,
DebuggerConfig,
} from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import BaseInputField from "@/components/InputField/BaseInputField.vue";
import type { LogicDataType } from "@/components/WaveformDisplay";
@@ -421,7 +426,7 @@ async function startCapture() {
}
isCapturing.value = true;
const client = AuthManager.createAuthenticatedDebuggerClient();
const client = AuthManager.createClient(DebuggerClient);
// 构造API配置
const channelConfigs = channels.value

View File

@@ -13,13 +13,22 @@
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-primary">
<div class="badge" :class="endpoint ? 'badge-success' : 'badge-warning'">
<div
class="badge"
:class="endpoint ? 'badge-success' : 'badge-warning'"
>
{{ endpoint ? "已连接" : "未配置" }}
</div>
</div>
<div class="stat-title">板卡状态</div>
<div class="stat-value text-primary">HDMI</div>
<div class="stat-desc">{{ endpoint ? `板卡: ${endpoint.boardId.substring(0, 8)}...` : "请先连接板卡" }}</div>
<div class="stat-desc">
{{
endpoint
? `板卡: ${endpoint.boardId.substring(0, 8)}...`
: "请先连接板卡"
}}
</div>
</div>
</div>
@@ -40,12 +49,20 @@
<!-- 操作按钮 -->
<div class="card-actions justify-end mt-4">
<button class="btn btn-outline btn-primary" @click="refreshEndpoint" :disabled="loading">
<button
class="btn btn-outline btn-primary"
@click="refreshEndpoint"
:disabled="loading"
>
<RefreshCw v-if="loading" class="animate-spin h-4 w-4 mr-2" />
<RefreshCw v-else class="h-4 w-4 mr-2" />
{{ loading ? "刷新中..." : "刷新连接" }}
</button>
<button class="btn btn-primary" @click="testConnection" :disabled="testing || !endpoint">
<button
class="btn btn-primary"
@click="testConnection"
:disabled="testing || !endpoint"
>
<RefreshCw v-if="testing" class="animate-spin h-4 w-4 mr-2" />
<TestTube v-else class="h-4 w-4 mr-2" />
{{ testing ? "测试中..." : "测试连接" }}
@@ -62,17 +79,33 @@
HDMI视频预览
</h2>
<div class="relative bg-black rounded-lg overflow-hidden cursor-pointer" :class="[
{ 'cursor-not-allowed': !isPlaying || hasVideoError || !endpoint }
]" style="aspect-ratio: 16/9" @click="handleVideoClick">
<div
class="relative bg-black rounded-lg overflow-hidden cursor-pointer"
:class="[
{ 'cursor-not-allowed': !isPlaying || hasVideoError || !endpoint },
]"
style="aspect-ratio: 16/9"
@click="handleVideoClick"
>
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div v-show="isPlaying && endpoint" class="w-full h-full flex items-center justify-center">
<img :src="currentVideoSource" alt="HDMI视频流" class="max-w-full max-h-full object-contain"
@error="handleVideoError" @load="handleVideoLoad" />
<div
v-show="isPlaying && endpoint"
class="w-full h-full flex items-center justify-center"
>
<img
:src="currentVideoSource"
alt="HDMI视频流"
class="max-w-full max-h-full object-contain"
@error="handleVideoError"
@load="handleVideoLoad"
/>
</div>
<!-- 错误信息显示 -->
<div v-if="hasVideoError" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70">
<div
v-if="hasVideoError"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70"
>
<div class="card bg-error text-white shadow-lg w-full max-w-lg">
<div class="card-body">
<h3 class="card-title flex items-center gap-2">
@@ -87,7 +120,10 @@
<li>HDMI视频流服务是否已启动</li>
</ul>
<div class="card-actions justify-end mt-2">
<button class="btn btn-sm btn-outline btn-primary" @click="tryReconnect">
<button
class="btn btn-sm btn-outline btn-primary"
@click="tryReconnect"
>
重试连接
</button>
</div>
@@ -96,13 +132,19 @@
</div>
<!-- 占位符 -->
<div v-show="(!isPlaying && !hasVideoError) || !endpoint"
class="absolute inset-0 flex items-center justify-center text-white">
<div
v-show="(!isPlaying && !hasVideoError) || !endpoint"
class="absolute inset-0 flex items-center justify-center text-white"
>
<div class="text-center">
<Video class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg opacity-75">{{ videoStatus }}</p>
<p class="text-sm opacity-60 mt-2">
{{ endpoint ? '点击"播放HDMI视频流"按钮开始查看实时视频' : '请先刷新连接以获取板卡信息' }}
{{
endpoint
? '点击"播放HDMI视频流"按钮开始查看实时视频'
: "请先刷新连接以获取板卡信息"
}}
</p>
</div>
</div>
@@ -118,11 +160,18 @@
</div>
<div class="space-x-2">
<div class="dropdown dropdown-hover dropdown-top dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-outline btn-accent">
<div
tabindex="0"
role="button"
class="btn btn-sm btn-outline btn-accent"
>
<MoreHorizontal class="w-4 h-4 mr-1" />
更多功能
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"
>
<li>
<a @click="openInNewTab(endpoint.videoUrl)">
<ExternalLink class="w-4 h-4" />
@@ -143,11 +192,19 @@
</li>
</ul>
</div>
<button class="btn btn-success btn-sm" @click="startStream" :disabled="isPlaying || !endpoint">
<button
class="btn btn-success btn-sm"
@click="startStream"
:disabled="isPlaying || !endpoint"
>
<Play class="w-4 h-4 mr-1" />
播放HDMI视频流
</button>
<button class="btn btn-error btn-sm" @click="stopStream" :disabled="!isPlaying">
<button
class="btn btn-error btn-sm"
@click="stopStream"
:disabled="!isPlaying"
>
<Square class="w-4 h-4 mr-1" />
停止视频流
</button>
@@ -165,11 +222,20 @@
</h2>
<div class="bg-base-300 rounded-lg p-4 h-48 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="text-sm font-mono mb-1">
<span class="text-base-content/50">[{{ formatTime(log.time) }}]</span>
<div
v-for="(log, index) in logs"
:key="index"
class="text-sm font-mono mb-1"
>
<span class="text-base-content/50"
>[{{ formatTime(log.time) }}]</span
>
<span :class="getLogClass(log.level)">{{ log.message }}</span>
</div>
<div v-if="logs.length === 0" class="text-base-content/50 text-center py-8">
<div
v-if="logs.length === 0"
class="text-base-content/50 text-center py-8"
>
暂无日志记录
</div>
</div>
@@ -200,7 +266,10 @@ import {
AlertTriangle,
MoreHorizontal,
} from "lucide-vue-next";
import { HdmiVideoStreamClient, type HdmiVideoStreamEndpoint } from "@/APIClient";
import {
HdmiVideoStreamClient,
type HdmiVideoStreamEndpoint,
} from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
@@ -212,27 +281,27 @@ const loading = ref(false);
const testing = ref(false);
const isPlaying = ref(false);
const hasVideoError = ref(false);
const videoStatus = ref('未连接');
const videoStatus = ref("未连接");
// HDMI视频流数据
const endpoint = ref<HdmiVideoStreamEndpoint | null>(null);
const currentVideoSource = ref('');
const currentVideoSource = ref("");
// 日志系统
interface LogEntry {
time: Date;
level: 'info' | 'success' | 'warning' | 'error';
level: "info" | "success" | "warning" | "error";
message: string;
}
const logs = ref<LogEntry[]>([]);
// 添加日志
function addLog(level: LogEntry['level'], message: string) {
function addLog(level: LogEntry["level"], message: string) {
logs.value.unshift({
time: new Date(),
level,
message
message,
});
// 保持最近100条日志
@@ -247,51 +316,54 @@ function formatTime(date: Date): string {
}
// 获取日志样式类
function getLogClass(level: LogEntry['level']): string {
function getLogClass(level: LogEntry["level"]): string {
switch (level) {
case 'success':
return 'text-success';
case 'warning':
return 'text-warning';
case 'error':
return 'text-error';
case "success":
return "text-success";
case "warning":
return "text-warning";
case "error":
return "text-error";
default:
return 'text-base-content';
return "text-base-content";
}
}
// 清空日志
function clearLogs() {
logs.value = [];
addLog('info', '日志已清空');
addLog("info", "日志已清空");
}
// 刷新HDMI视频流端点
async function refreshEndpoint() {
loading.value = true;
try {
addLog('info', '正在获取HDMI视频流端点...');
addLog("info", "正在获取HDMI视频流端点...");
const client = AuthManager.createAuthenticatedHdmiVideoStreamClient();
const client = AuthManager.createClient(HdmiVideoStreamClient);
const result = await client.getMyEndpoint();
if (result) {
endpoint.value = result;
videoStatus.value = '已连接板卡,可以播放视频流';
addLog('success', `成功获取HDMI视频流端点板卡ID: ${result.boardId.substring(0, 8)}...`);
alert?.success('HDMI视频流连接成功');
videoStatus.value = "已连接板卡,可以播放视频流";
addLog(
"success",
`成功获取HDMI视频流端点板卡ID: ${result.boardId.substring(0, 8)}...`,
);
alert?.success("HDMI视频流连接成功");
} else {
endpoint.value = null;
videoStatus.value = '无法获取板卡信息';
addLog('error', '未找到绑定的板卡或板卡未配置HDMI输入');
alert?.error('未找到绑定的板卡');
videoStatus.value = "无法获取板卡信息";
addLog("error", "未找到绑定的板卡或板卡未配置HDMI输入");
alert?.error("未找到绑定的板卡");
}
} catch (error) {
console.error('获取HDMI视频流端点失败:', error);
console.error("获取HDMI视频流端点失败:", error);
endpoint.value = null;
videoStatus.value = '连接失败';
addLog('error', `获取HDMI视频流端点失败: ${error}`);
alert?.error('获取HDMI视频流信息失败');
videoStatus.value = "连接失败";
addLog("error", `获取HDMI视频流端点失败: ${error}`);
alert?.error("获取HDMI视频流信息失败");
} finally {
loading.value = false;
}
@@ -300,34 +372,34 @@ async function refreshEndpoint() {
// 测试连接
async function testConnection() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
alert?.warn("请先刷新连接获取板卡信息");
return;
}
testing.value = true;
try {
addLog('info', '正在测试HDMI视频流连接...');
addLog("info", "正在测试HDMI视频流连接...");
// 尝试获取快照来测试连接
const response = await fetch(endpoint.value.snapshotUrl, {
method: 'GET',
method: "GET",
headers: {
'Cache-Control': 'no-cache'
}
"Cache-Control": "no-cache",
},
});
if (response.ok) {
addLog('success', 'HDMI视频流连接测试成功');
alert?.success('HDMI连接测试成功');
videoStatus.value = '连接正常,可以播放视频流';
addLog("success", "HDMI视频流连接测试成功");
alert?.success("HDMI连接测试成功");
videoStatus.value = "连接正常,可以播放视频流";
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('HDMI视频流连接测试失败:', error);
addLog('error', `连接测试失败: ${error}`);
alert?.error('HDMI连接测试失败');
videoStatus.value = '连接测试失败';
console.error("HDMI视频流连接测试失败:", error);
addLog("error", `连接测试失败: ${error}`);
alert?.error("HDMI连接测试失败");
videoStatus.value = "连接测试失败";
} finally {
testing.value = false;
}
@@ -336,7 +408,7 @@ async function testConnection() {
// 开始播放视频流
function startStream() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
alert?.warn("请先刷新连接获取板卡信息");
return;
}
@@ -346,42 +418,42 @@ function startStream() {
currentVideoSource.value = `${endpoint.value.mjpegUrl}&t=${timestamp}`;
isPlaying.value = true;
hasVideoError.value = false;
videoStatus.value = '正在加载视频流...';
videoStatus.value = "正在加载视频流...";
addLog('info', '开始播放HDMI视频流');
alert?.success('开始播放HDMI视频流');
addLog("info", "开始播放HDMI视频流");
alert?.success("开始播放HDMI视频流");
} catch (error) {
console.error('启动HDMI视频流失败:', error);
addLog('error', `启动视频流失败: ${error}`);
alert?.error('启动HDMI视频流失败');
console.error("启动HDMI视频流失败:", error);
addLog("error", `启动视频流失败: ${error}`);
alert?.error("启动HDMI视频流失败");
}
}
// 停止播放视频流
function stopStream() {
isPlaying.value = false;
currentVideoSource.value = '';
videoStatus.value = '已停止播放';
currentVideoSource.value = "";
videoStatus.value = "已停止播放";
const client = AuthManager.createAuthenticatedHdmiVideoStreamClient();
const client = AuthManager.createClient(HdmiVideoStreamClient);
client.disableHdmiTransmission();
addLog('info', '停止播放HDMI视频流');
alert?.info('已停止播放HDMI视频流');
addLog("info", "停止播放HDMI视频流");
alert?.info("已停止播放HDMI视频流");
}
// 处理视频加载错误
function handleVideoError() {
hasVideoError.value = true;
videoStatus.value = '视频流加载失败';
addLog('error', 'HDMI视频流加载失败');
videoStatus.value = "视频流加载失败";
addLog("error", "HDMI视频流加载失败");
}
// 处理视频加载成功
function handleVideoLoad() {
hasVideoError.value = false;
videoStatus.value = '视频流播放中';
addLog('success', 'HDMI视频流加载成功');
videoStatus.value = "视频流播放中";
addLog("success", "HDMI视频流加载成功");
}
// 处理视频点击
@@ -391,7 +463,7 @@ function handleVideoClick() {
}
// 可以在这里添加点击视频的交互逻辑
addLog('info', '视频画面被点击');
addLog("info", "视频画面被点击");
}
// 重试连接
@@ -404,47 +476,47 @@ function tryReconnect() {
// 在新标签页打开视频
function openInNewTab(url: string) {
window.open(url, '_blank');
addLog('info', '在新标签页打开HDMI视频页面');
window.open(url, "_blank");
addLog("info", "在新标签页打开HDMI视频页面");
}
// 获取快照
async function takeSnapshot() {
if (!endpoint.value) {
alert?.warn('请先刷新连接获取板卡信息');
alert?.warn("请先刷新连接获取板卡信息");
return;
}
try {
addLog('info', '正在获取HDMI视频快照...');
addLog("info", "正在获取HDMI视频快照...");
const response = await fetch(endpoint.value.snapshotUrl, {
method: 'GET',
method: "GET",
headers: {
'Cache-Control': 'no-cache'
}
"Cache-Control": "no-cache",
},
});
if (response.ok) {
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `hdmi_snapshot_${new Date().toISOString().replace(/:/g, '-')}.jpg`;
a.download = `hdmi_snapshot_${new Date().toISOString().replace(/:/g, "-")}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
addLog('success', '快照下载成功');
alert?.success('HDMI快照下载成功');
addLog("success", "快照下载成功");
alert?.success("HDMI快照下载成功");
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('获取HDMI快照失败:', error);
addLog('error', `获取快照失败: ${error}`);
alert?.error('获取HDMI快照失败');
console.error("获取HDMI快照失败:", error);
addLog("error", `获取快照失败: ${error}`);
alert?.error("获取HDMI快照失败");
}
}
@@ -452,18 +524,18 @@ async function takeSnapshot() {
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
addLog('success', '地址已复制到剪贴板');
alert?.success('地址已复制到剪贴板');
addLog("success", "地址已复制到剪贴板");
alert?.success("地址已复制到剪贴板");
} catch (error) {
console.error('复制到剪贴板失败:', error);
addLog('error', '复制到剪贴板失败');
alert?.error('复制到剪贴板失败');
console.error("复制到剪贴板失败:", error);
addLog("error", "复制到剪贴板失败");
alert?.error("复制到剪贴板失败");
}
}
// 组件挂载时初始化
onMounted(() => {
addLog('info', 'HDMI视频流界面已初始化');
addLog("info", "HDMI视频流界面已初始化");
refreshEndpoint();
});
@@ -476,7 +548,8 @@ onUnmounted(() => {
<style scoped>
/* 对焦动画效果 */
@keyframes focus-pulse {
0%, 100% {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
}
50% {

View File

@@ -80,7 +80,9 @@
<!-- 功能底栏 -->
<SplitterPanel
id="splitter-group-v-panel-bar"
:default-size="isBottomBarFullscreen ? 100 : (100 - verticalSplitterSize)"
:default-size="
isBottomBarFullscreen ? 100 : 100 - verticalSplitterSize
"
:min-size="isBottomBarFullscreen ? 100 : 15"
class="w-full overflow-hidden pt-3"
>
@@ -114,14 +116,40 @@
@click="navbarControl.toggleNavbar"
class="btn btn-circle btn-primary shadow-lg hover:shadow-xl transition-all duration-300"
:class="{ 'btn-outline': navbarControl.showNavbar.value }"
:title="navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'"
:title="
navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'
"
>
<!-- 使用SVG图标表示菜单/关闭状态 -->
<svg v-if="navbarControl.showNavbar.value" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<svg
v-if="navbarControl.showNavbar.value"
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
@@ -131,7 +159,7 @@
<script setup lang="ts">
import { ref, onMounted, watch, inject, type Ref } from "vue";
import { useRouter } from "vue-router";
import { useLocalStorage } from '@vueuse/core'; // 添加VueUse导入
import { useLocalStorage } from "@vueuse/core"; // 添加VueUse导入
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
import DiagramCanvas from "@/components/LabCanvas/DiagramCanvas.vue";
import ComponentSelector from "@/components/LabCanvas/ComponentSelector.vue";
@@ -143,7 +171,7 @@ import { useProvideComponentManager } from "@/components/LabCanvas";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useEquipments } from "@/stores/equipments";
import type { Board } from "@/APIClient";
import { DataClient, ResourceClient, type Board } from "@/APIClient";
import { useRoute } from "vue-router";
const route = useRoute();
@@ -158,20 +186,29 @@ const equipments = useEquipments();
const alert = useAlertStore();
// --- Navbar控制 ---
const navbarControl = inject('navbar') as {
const navbarControl = inject("navbar") as {
showNavbar: Ref<boolean>;
toggleNavbar: () => void;
};
// --- 使用VueUse保存分栏状态 ---
// 左右分栏比例默认60%
const horizontalSplitterSize = useLocalStorage('project-horizontal-splitter-size', 60);
const horizontalSplitterSize = useLocalStorage(
"project-horizontal-splitter-size",
60,
);
// 上下分栏比例默认80%
const verticalSplitterSize = useLocalStorage('project-vertical-splitter-size', 80);
const verticalSplitterSize = useLocalStorage(
"project-vertical-splitter-size",
80,
);
// 底栏全屏状态
const isBottomBarFullscreen = useLocalStorage('project-bottom-bar-fullscreen', false);
const isBottomBarFullscreen = useLocalStorage(
"project-bottom-bar-fullscreen",
false,
);
// 文档面板显示状态
const showDocPanel = useLocalStorage('project-show-doc-panel', false);
const showDocPanel = useLocalStorage("project-show-doc-panel", false);
function handleToggleBottomBarFullscreen() {
isBottomBarFullscreen.value = !isBottomBarFullscreen.value;
@@ -216,11 +253,11 @@ async function loadDocumentContent() {
const examId = route.query.examId as string;
if (examId) {
// 如果有实验ID从API加载实验文档
console.log('加载实验文档:', examId);
const client = AuthManager.createAuthenticatedResourceClient();
console.log("加载实验文档:", examId);
const client = AuthManager.createClient(ResourceClient);
// 获取markdown类型的模板资源列表
const resources = await client.getResourceList(examId, 'doc', 'template');
const resources = await client.getResourceList(examId, "doc", "template");
if (resources && resources.length > 0) {
// 获取第一个markdown资源
@@ -230,7 +267,7 @@ async function loadDocumentContent() {
const response = await client.getResourceById(markdownResource.id);
if (!response || !response.data) {
throw new Error('获取markdown文件失败');
throw new Error("获取markdown文件失败");
}
const content = await response.data.text();
@@ -279,17 +316,17 @@ function updateComponentDirectProp(
// 检查并初始化用户实验板
async function checkAndInitializeBoard() {
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
if (userInfo.boardID && userInfo.boardID.trim() !== '') {
if (userInfo.boardID && userInfo.boardID.trim() !== "") {
// 用户已绑定实验板获取实验板信息并更新到equipment
try {
const board = await client.getBoardByID(userInfo.boardID);
updateEquipmentFromBoard(board);
alert?.show(`实验板 ${board.boardName} 已连接`, "success");
} catch (boardError) {
console.error('获取实验板信息失败:', boardError);
console.error("获取实验板信息失败:", boardError);
alert?.show("获取实验板信息失败", "error");
showRequestBoardDialog.value = true;
}
@@ -298,7 +335,7 @@ async function checkAndInitializeBoard() {
showRequestBoardDialog.value = true;
}
} catch (error) {
console.error('检查用户实验板失败:', error);
console.error("检查用户实验板失败:", error);
alert?.show("检查用户信息失败", "error");
showRequestBoardDialog.value = true;
}
@@ -313,7 +350,7 @@ function updateEquipmentFromBoard(board: Board) {
address: board.ipAddr,
port: board.port,
boardName: board.boardName,
boardId: board.id
boardId: board.id,
});
}
@@ -321,7 +358,7 @@ function updateEquipmentFromBoard(board: Board) {
function handleRequestBoardClose() {
showRequestBoardDialog.value = false;
// 如果用户取消申请,可以选择返回上一页或显示警告
router.push('/');
router.push("/");
}
// 处理申请实验板成功
@@ -338,12 +375,12 @@ onMounted(async () => {
const isAuthenticated = await AuthManager.isAuthenticated();
if (!isAuthenticated) {
// 验证失败,跳转到登录页面
router.push('/login');
router.push("/login");
return;
}
} catch (error) {
console.error('身份验证失败:', error);
router.push('/login');
console.error("身份验证失败:", error);
router.push("/login");
return;
}

View File

@@ -75,7 +75,7 @@ import { ref, watch } from "vue";
import { CheckCircle } from "lucide-vue-next";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
import type { Board } from "@/APIClient";
import { DataClient, type Board } from "@/APIClient";
interface Props {
open: boolean;
@@ -113,7 +113,7 @@ async function checkUserBoard() {
boardInfo.value = null;
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const userInfo = await client.getUserInfo();
if (userInfo.boardID && userInfo.boardID.trim() !== "") {
@@ -140,7 +140,7 @@ async function requestBoard() {
requesting.value = true;
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const board = await client.getAvailableBoard(undefined);
if (board) {

View File

@@ -8,20 +8,31 @@
控制面板
</h2>
<div class="grid grid-cols-1 gap-4"
:class="{ 'md:grid-cols-3': streamType === 'usbCamera', 'md:grid-cols-4': streamType === 'videoStream' }">
<div
class="grid grid-cols-1 gap-4"
:class="{
'md:grid-cols-3': streamType === 'usbCamera',
'md:grid-cols-4': streamType === 'videoStream',
}"
>
<!-- 服务状态 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-primary">
<div class="badge" :class="statusInfo.isRunning ? 'badge-success' : 'badge-error'
">
{{ statusInfo.isRunning ? "运行中" : "已停止" }}
<div
class="badge"
:class="
videoStreamInfo.isRunning ? 'badge-success' : 'badge-error'
"
>
{{ videoStreamInfo.isRunning ? "运行中" : "已停止" }}
</div>
</div>
<div class="stat-title">服务状态</div>
<div class="stat-value text-primary">HTTP</div>
<div class="stat-desc">端口: {{ statusInfo.serverPort }}</div>
<div class="stat-desc">
端口: {{ videoStreamInfo.serverPort }}
</div>
</div>
</div>
@@ -33,9 +44,11 @@
</div>
<div class="stat-title">视频规格</div>
<div class="stat-value text-secondary">
{{ streamInfo.frameWidth }}×{{ streamInfo.frameHeight }}
{{ videoStreamInfo.frameWidth }}×{{
videoStreamInfo.frameHeight
}}
</div>
<div class="stat-desc">{{ streamInfo.frameRate }} FPS</div>
<div class="stat-desc">{{ videoStreamInfo.frameRate }} FPS</div>
</div>
</div>
@@ -47,17 +60,31 @@
</div>
<div class="stat-title">分辨率设置</div>
<div class="stat-value text-sm">
<select class="select select-sm select-bordered max-w-xs" v-model="selectedResolution"
@change="changeResolution" :disabled="changingResolution">
<option v-for="res in supportedResolutions" :key="`${res.width}x${res.height}`" :value="res">
<select
class="select select-sm select-bordered max-w-xs"
v-model="selectedResolution"
@change="changeResolution"
:disabled="changingResolution"
>
<option
v-for="res in supportedResolutions"
:key="`${res.width}x${res.height}`"
:value="res"
>
{{ res.width }}×{{ res.height }}
</option>
</select>
</div>
<div class="stat-desc">
<button class="btn btn-xs btn-outline btn-info mt-1" @click="refreshResolutions"
:disabled="loadingResolutions">
<RefreshCw v-if="loadingResolutions" class="animate-spin h-3 w-3" />
<button
class="btn btn-xs btn-outline btn-info mt-1"
@click="refreshResolutions"
:disabled="loadingResolutions"
>
<RefreshCw
v-if="loadingResolutions"
class="animate-spin h-3 w-3"
/>
{{ loadingResolutions ? "刷新中..." : "刷新" }}
</button>
</div>
@@ -72,22 +99,34 @@
</div>
<div class="stat-title">连接数</div>
<div class="stat-value text-accent">
{{ statusInfo.connectedClients }}
{{ videoStreamInfo.connectedClients }}
</div>
<div class="stat-desc">
<div class="dropdown dropdown-hover dropdown-top">
<div tabindex="0" role="button" class="text-xs underline cursor-help">
<div
tabindex="0"
role="button"
class="text-xs underline cursor-help"
>
查看客户端
</div>
<ul tabindex="0"
class="dropdown-content z-20 menu p-2 shadow bg-base-200 rounded-box w-64 max-h-48 overflow-y-auto">
<li v-for="(client, index) in statusInfo.clientEndpoints" :key="index" class="text-xs">
<ul
tabindex="0"
class="dropdown-content z-20 menu p-2 shadow bg-base-200 rounded-box w-64 max-h-48 overflow-y-auto"
>
<li
v-for="(client, index) in videoStreamInfo.clientEndpoints"
:key="index"
class="text-xs"
>
<a class="break-all">{{ client }}</a>
</li>
<li v-if="
!statusInfo.clientEndpoints ||
statusInfo.clientEndpoints.length === 0
">
<li
v-if="
!videoStreamInfo.clientEndpoints ||
videoStreamInfo.clientEndpoints.length === 0
"
>
<a class="text-xs opacity-50">无活跃连接</a>
</li>
</ul>
@@ -99,21 +138,41 @@
<!-- 操作按钮 -->
<div class="card-actions justify-end mt-4">
<button class="btn btn-outline btn-warning mr-2" @click="toggleStreamType" :disabled="isSwitchingStreamType">
<button
class="btn btn-outline btn-warning mr-2"
@click="toggleStreamType"
:disabled="isSwitchingStreamType"
>
<SwitchCamera class="h-4 w-4 mr-2" />
{{ streamType === 'usbCamera' ? '切换到视频流' : '切换到USB摄像头' }}
{{
streamType === "usbCamera" ? "切换到视频流" : "切换到USB摄像头"
}}
</button>
<button v-show="streamType === 'videoStream'" class="btn btn-outline btn-primary" @click="configCamera" :disabled="configing">
<button
v-show="streamType === 'videoStream'"
class="btn btn-outline btn-primary"
@click="configCamera"
:disabled="configing"
>
<RefreshCw v-if="configing" class="animate-spin h-4 w-4 mr-2" />
<CogIcon v-else class="h-4 w-4 mr-2" />
{{ configing ? "配置中..." : "配置摄像头" }}
</button>
<button class="btn btn-outline btn-primary" @click="refreshStatus" :disabled="loading">
<button
class="btn btn-outline btn-primary"
@click="refreshStatus"
:disabled="loading"
>
<RefreshCw v-if="loading" class="animate-spin h-4 w-4 mr-2" />
<RefreshCw v-else class="h-4 w-4 mr-2" />
{{ loading ? "刷新中..." : "刷新状态" }}
</button>
<button v-show="streamType === 'videoStream'" class="btn btn-primary" @click="testConnection" :disabled="testing">
<button
v-show="streamType === 'videoStream'"
class="btn btn-primary"
@click="testConnection"
:disabled="testing"
>
<RefreshCw v-if="testing" class="animate-spin h-4 w-4 mr-2" />
<TestTube v-else class="h-4 w-4 mr-2" />
{{ testing ? "测试中..." : "测试连接" }}
@@ -130,24 +189,42 @@
视频预览
</h2>
<div class="relative bg-black rounded-lg overflow-hidden cursor-pointer" :class="[
<div
class="relative bg-black rounded-lg overflow-hidden cursor-pointer"
:class="[
focusAnimationClass,
{ 'cursor-not-allowed': !isPlaying || hasVideoError }
]" style="aspect-ratio: 4/3" @click="handleVideoClick">
{ 'cursor-not-allowed': !isPlaying || hasVideoError },
]"
style="aspect-ratio: 4/3"
@click="handleVideoClick"
>
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div v-show="isPlaying" class="w-full h-full flex items-center justify-center">
<img :src="currentVideoSource" alt="视频流" class="max-w-full max-h-full object-contain"
@error="handleVideoError" @load="handleVideoLoad" />
<div
v-show="isPlaying"
class="w-full h-full flex items-center justify-center"
>
<img
:src="currentVideoSource"
alt="视频流"
class="max-w-full max-h-full object-contain"
@error="handleVideoError"
@load="handleVideoLoad"
/>
</div>
<!-- 对焦提示 -->
<div v-if="isPlaying && !hasVideoError"
class="absolute top-4 right-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded">
{{ isFocusing ? '对焦中...' : '点击画面对焦' }}
<div
v-if="isPlaying && !hasVideoError"
class="absolute top-4 right-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded"
>
{{ isFocusing ? "对焦中..." : "点击画面对焦" }}
</div>
<!-- 错误信息显示 -->
<div v-if="hasVideoError" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70">
<div
v-if="hasVideoError"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70"
>
<div class="card bg-error text-white shadow-lg w-full max-w-lg">
<div class="card-body">
<h3 class="card-title flex items-center gap-2">
@@ -158,10 +235,13 @@
<ul class="list-disc list-inside">
<li>视频流服务是否已启动</li>
<li>网络连接是否正常</li>
<li>端口 {{ statusInfo.serverPort }} 是否可访问</li>
<li>端口 {{ videoStreamInfo.serverPort }} 是否可访问</li>
</ul>
<div class="card-actions justify-end mt-2">
<button class="btn btn-sm btn-outline btn-primary" @click="tryReconnect">
<button
class="btn btn-sm btn-outline btn-primary"
@click="tryReconnect"
>
重试连接
</button>
</div>
@@ -170,8 +250,10 @@
</div>
<!-- 占位符 -->
<div v-show="!isPlaying && !hasVideoError"
class="absolute inset-0 flex items-center justify-center text-white">
<div
v-show="!isPlaying && !hasVideoError"
class="absolute inset-0 flex items-center justify-center text-white"
>
<div class="text-center">
<Video class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg opacity-75">{{ videoStatus }}</p>
@@ -187,18 +269,25 @@
<div class="text-sm text-base-content/70">
流地址:
<code class="bg-base-300 px-2 py-1 rounded">{{
streamInfo.mjpegUrl
videoStreamInfo.mjpegUrl
}}</code>
</div>
<div class="space-x-2">
<div class="dropdown dropdown-hover dropdown-top dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-outline btn-accent">
<div
tabindex="0"
role="button"
class="btn btn-sm btn-outline btn-accent"
>
<MoreHorizontal class="w-4 h-4 mr-1" />
更多功能
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52">
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"
>
<li>
<a @click="openInNewTab(streamInfo.htmlUrl)">
<a @click="openInNewTab(videoStreamInfo.htmlUrl)">
<ExternalLink class="w-4 h-4" />
在新标签打开视频页面
</a>
@@ -210,18 +299,26 @@
</a>
</li>
<li>
<a @click="copyToClipboard(streamInfo.mjpegUrl)">
<a @click="copyToClipboard(videoStreamInfo.mjpegUrl)">
<Copy class="w-4 h-4" />
复制MJPEG地址
</a>
</li>
</ul>
</div>
<button class="btn btn-success btn-sm" @click="startStream" :disabled="isPlaying">
<button
class="btn btn-success btn-sm"
@click="startStream"
:disabled="isPlaying"
>
<Play class="w-4 h-4 mr-1" />
播放视频流
</button>
<button class="btn btn-error btn-sm" @click="stopStream" :disabled="!isPlaying">
<button
class="btn btn-error btn-sm"
@click="stopStream"
:disabled="!isPlaying"
>
<Square class="w-4 h-4 mr-1" />
停止视频流
</button>
@@ -239,11 +336,20 @@
</h2>
<div class="bg-base-300 rounded-lg p-4 h-48 overflow-y-auto">
<div v-for="(log, index) in logs" :key="index" class="text-sm font-mono mb-1">
<span class="text-base-content/50">[{{ formatTime(log.time) }}]</span>
<div
v-for="(log, index) in logs"
:key="index"
class="text-sm font-mono mb-1"
>
<span class="text-base-content/50"
>[{{ formatTime(log.time) }}]</span
>
<span :class="getLogClass(log.level)">{{ log.message }}</span>
</div>
<div v-if="logs.length === 0" class="text-base-content/50 text-center py-8">
<div
v-if="logs.length === 0"
class="text-base-content/50 text-center py-8"
>
暂无日志记录
</div>
</div>
@@ -277,8 +383,9 @@ import {
MoreHorizontal,
SwitchCamera,
} from "lucide-vue-next";
import { VideoStreamClient, CameraConfigRequest, ResolutionConfigRequest, StreamInfoResult } from "@/APIClient";
import { VideoStreamClient, ResolutionConfigRequest } from "@/APIClient";
import { useEquipments } from "@/stores/equipments";
import { AuthManager } from "@/utils/AuthManager";
const eqps = useEquipments();
@@ -291,12 +398,12 @@ const hasVideoError = ref(false);
const videoStatus = ref('点击"播放视频流"按钮开始查看实时视频');
// 视频流类型切换相关
const streamType = ref<'usbCamera' | 'videoStream'>('videoStream');
const streamType = ref<"usbCamera" | "videoStream">("videoStream");
const isSwitchingStreamType = ref(false);
// 对焦相关状态
const isFocusing = ref(false);
const focusAnimationClass = ref('');
const focusAnimationClass = ref("");
// 分辨率相关状态
const changingResolution = ref(false);
@@ -304,36 +411,29 @@ const loadingResolutions = ref(false);
const selectedResolution = ref({ width: 640, height: 480 });
const supportedResolutions = ref([
{ width: 640, height: 480 },
{ width: 1280, height: 720 }
{ width: 1280, height: 720 },
]);
// 数据
const statusInfo = ref({
const videoStreamInfo = ref({
frameWidth: 640,
frameHeight: 480,
frameRate: 30,
isRunning: false,
serverPort: 8080,
streamUrl: "",
mjpegUrl: "",
snapshotUrl: "",
htmlUrl: "",
usbCameraUrl: "",
connectedClients: 0,
clientEndpoints: [] as string[],
});
const streamInfo = ref<StreamInfoResult>(new StreamInfoResult({
frameRate: 30,
frameWidth: 640,
frameHeight: 480,
format: "MJPEG",
htmlUrl: "",
mjpegUrl: "",
snapshotUrl: "",
usbCameraUrl: "",
}));
const currentVideoSource = ref("");
const logs = ref<Array<{ time: Date; level: string; message: string }>>([]);
// API 客户端
const videoClient = new VideoStreamClient();
const videoClient = AuthManager.createClient(VideoStreamClient);
// 添加日志
const addLog = (level: string, message: string) => {
@@ -397,16 +497,23 @@ const toggleStreamType = async () => {
isSwitchingStreamType.value = true;
try {
// 这里假设后端有API: setStreamType(type: string)
addLog('info', `正在切换视频流类型到${streamType.value === 'usbCamera' ? '视频流' : 'USB摄像头'}...`);
addLog(
"info",
`正在切换视频流类型到${streamType.value === "usbCamera" ? "视频流" : "USB摄像头"}...`,
);
refreshStatus();
// 设置视频源
streamType.value = streamType.value === 'usbCamera' ? 'videoStream' : 'usbCamera';
addLog('success', `已切换到${streamType.value === 'usbCamera' ? 'USB摄像头' : '视频流'}`);
streamType.value =
streamType.value === "usbCamera" ? "videoStream" : "usbCamera";
addLog(
"success",
`已切换到${streamType.value === "usbCamera" ? "USB摄像头" : "视频流"}`,
);
stopStream();
} catch (error) {
addLog('error', `切换视频流类型失败: ${error}`);
console.error('切换视频流类型失败:', error);
addLog("error", `切换视频流类型失败: ${error}`);
console.error("切换视频流类型失败:", error);
} finally {
isSwitchingStreamType.value = false;
}
@@ -418,7 +525,7 @@ const takeSnapshot = async () => {
addLog("info", "正在获取快照...");
// 使用当前的快照URL
const snapshotUrl = streamInfo.value.snapshotUrl;
const snapshotUrl = videoStreamInfo.value.snapshotUrl;
if (!snapshotUrl) {
addLog("error", "快照URL不可用");
return;
@@ -446,17 +553,14 @@ async function configCamera() {
configing.value = true;
try {
addLog("info", "正在配置并初始化摄像头...");
const boardconfig = new CameraConfigRequest({
address: eqps.boardAddr,
port: eqps.boardPort,
});
await videoClient.configureCamera(boardconfig);
await videoClient.configureCamera();
const status = await videoClient.getCameraConfig();
if (status.isConfigured) {
const ret = await videoClient.testConnection();
if (ret) {
addLog("success", "摄像头已配置并初始化");
} else {
addLog("error", "摄像头配置失败,请检查地址和端口");
addLog("error", "摄像头配置失败");
}
} catch (error) {
addLog("error", `摄像头配置失败: ${error}`);
@@ -473,11 +577,23 @@ const refreshStatus = async () => {
addLog("info", "正在获取服务状态...");
// 使用新的API方法名称
const status = await videoClient.getStatus();
statusInfo.value = status;
const info = await videoClient.getStreamInfo();
streamInfo.value = info;
const serviceStatus = await videoClient.getServiceStatus();
const endpointInfo = await videoClient.myEndpoint();
videoStreamInfo.value = {
frameWidth: endpointInfo.frameWidth,
frameHeight: endpointInfo.frameHeight,
frameRate: endpointInfo.frameRate,
isRunning: serviceStatus.isRunning,
serverPort: serviceStatus.serverPort,
mjpegUrl: endpointInfo.mjpegUrl,
snapshotUrl: endpointInfo.snapshotUrl,
htmlUrl: endpointInfo.htmlUrl,
usbCameraUrl: endpointInfo.usbCameraUrl,
connectedClients: serviceStatus.connectedClientsNum,
clientEndpoints: serviceStatus.clientEndpoints.map(
(ep) => `${ep.boardId}`,
),
};
addLog("success", "服务状态获取成功");
} catch (error) {
@@ -527,9 +643,6 @@ const handleVideoLoad = () => {
const tryReconnect = () => {
addLog("info", "尝试重新连接视频流...");
hasVideoError.value = false;
// 重新设置视频源,添加时间戳避免缓存问题
currentVideoSource.value = `${streamInfo.value.mjpegUrl}?t=${new Date().getTime()}`;
};
// 执行对焦
@@ -538,41 +651,41 @@ const performFocus = async () => {
try {
isFocusing.value = true;
focusAnimationClass.value = 'focus-starting';
focusAnimationClass.value = "focus-starting";
addLog("info", "正在执行自动对焦...");
// 调用对焦API
const response = await fetch('/api/VideoStream/Focus');
const response = await fetch("/api/VideoStream/Focus");
const result = await response.json();
if (result.success) {
// 对焦成功动画
focusAnimationClass.value = 'focus-success';
focusAnimationClass.value = "focus-success";
addLog("success", "自动对焦执行成功");
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
focusAnimationClass.value = "";
}, 2000);
} else {
// 对焦失败动画
focusAnimationClass.value = 'focus-error';
addLog("error", `自动对焦执行失败: ${result.message || '未知错误'}`);
focusAnimationClass.value = "focus-error";
addLog("error", `自动对焦执行失败: ${result.message || "未知错误"}`);
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
focusAnimationClass.value = "";
}, 2000);
}
} catch (error) {
// 对焦失败动画
focusAnimationClass.value = 'focus-error';
focusAnimationClass.value = "focus-error";
addLog("error", `自动对焦执行失败: ${error}`);
console.error("自动对焦执行失败:", error);
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
focusAnimationClass.value = "";
}, 2000);
} finally {
// 1秒后重置对焦状态
@@ -598,13 +711,16 @@ const startStream = async () => {
try {
addLog("info", "正在启动视频流...");
videoStatus.value = "正在连接视频流...";
videoClient.setEnabled(true);
videoClient.setVideoStreamEnable(true);
// 刷新状态
await refreshStatus();
// 设置视频源
currentVideoSource.value = streamType.value === 'usbCamera' ? streamInfo.value.usbCameraUrl : streamInfo.value.mjpegUrl;
currentVideoSource.value =
streamType.value === "usbCamera"
? videoStreamInfo.value.usbCameraUrl
: videoStreamInfo.value.mjpegUrl;
// 设置播放状态
isPlaying.value = true;
@@ -625,12 +741,18 @@ const refreshResolutions = async () => {
try {
addLog("info", "正在获取支持的分辨率列表...");
const resolutions = await videoClient.getSupportedResolutions();
supportedResolutions.value = resolutions.resolutions;
supportedResolutions.value = resolutions.map((resolution) => ({
width: resolution.width,
height: resolution.height,
}));
console.log("支持的分辨率列表:", supportedResolutions.value);
// 获取当前分辨率
const currentRes = await videoClient.getCurrentResolution();
selectedResolution.value = currentRes;
const endpointInfo = await videoClient.myEndpoint();
selectedResolution.value = {
width: endpointInfo.frameWidth,
height: endpointInfo.frameHeight,
};
addLog("success", "分辨率列表获取成功");
} catch (error) {
@@ -649,18 +771,21 @@ const changeResolution = async () => {
const wasPlaying = isPlaying.value;
try {
addLog("info", `正在切换分辨率到 ${selectedResolution.value.width}×${selectedResolution.value.height}...`);
addLog(
"info",
`正在切换分辨率到 ${selectedResolution.value.width}×${selectedResolution.value.height}...`,
);
// 如果正在播放,先停止视频流
if (wasPlaying) {
stopStream();
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒
await new Promise((resolve) => setTimeout(resolve, 1000)); // 等待1秒
}
// 设置新分辨率
const resolutionRequest = new ResolutionConfigRequest({
width: selectedResolution.value.width,
height: selectedResolution.value.height
height: selectedResolution.value.height,
});
const success = await videoClient.setResolution(resolutionRequest);
@@ -670,11 +795,14 @@ const changeResolution = async () => {
// 如果之前在播放,重新启动视频流
if (wasPlaying) {
await new Promise(resolve => setTimeout(resolve, 500)); // 短暂延迟
await new Promise((resolve) => setTimeout(resolve, 500)); // 短暂延迟
await startStream();
}
addLog("success", `分辨率已切换到 ${selectedResolution.value.width}×${selectedResolution.value.height}`);
addLog(
"success",
`分辨率已切换到 ${selectedResolution.value.width}×${selectedResolution.value.height}`,
);
} else {
addLog("error", "分辨率切换失败");
}
@@ -690,7 +818,7 @@ const changeResolution = async () => {
const stopStream = () => {
try {
addLog("info", "正在停止视频流...");
videoClient.setEnabled(false);
videoClient.setVideoStreamEnable(false);
// 清除视频源
currentVideoSource.value = "";

View File

@@ -174,7 +174,12 @@
import { ref, reactive, watch } from "vue";
import { AuthManager } from "../../utils/AuthManager";
import { useAlertStore } from "../../components/Alert";
import { BoardStatus, type NetworkConfigDto } from "../../APIClient";
import {
BoardStatus,
DataClient,
NetConfigClient,
type NetworkConfigDto,
} from "../../APIClient";
import { useRequiredInjection } from "@/utils/Common";
import { useBoardManager } from "@/utils/BoardManager";
@@ -267,8 +272,7 @@ async function handleSubmit() {
isSubmitting.value = true;
try {
// 通过 AuthManager 获取认证的 DataClient
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
// 添加板卡到数据库
const boardId = await dataClient.addBoard(form.name.trim());
@@ -293,8 +297,7 @@ async function handleCancelPairing() {
if (!addedBoardId.value) return;
try {
// 通过 AuthManager 获取认证的 DataClient
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
// 删除添加的板卡
await dataClient.deleteBoard(addedBoardId.value);
@@ -317,8 +320,8 @@ async function handlePairingConfirm() {
try {
// 通过 AuthManager 获取认证的客户端
const dataClient = AuthManager.createAuthenticatedDataClient();
const netConfigClient = AuthManager.createAuthenticatedNetConfigClient();
const dataClient = AuthManager.createClient(DataClient);
const netConfigClient = AuthManager.createClient(NetConfigClient);
// 获取数据库中对应分配的板卡信息
const boardInfo = await dataClient.getBoardByID(addedBoardId.value);
@@ -365,7 +368,7 @@ async function handlePairingConfirm() {
// 配置失败,删除数据库中的板卡信息
try {
const dataClient = AuthManager.createAuthenticatedDataClient();
const dataClient = AuthManager.createClient(DataClient);
await dataClient.deleteBoard(addedBoardId.value);
} catch (deleteError) {
console.error("删除板卡失败:", deleteError);

View File

@@ -62,14 +62,14 @@ onMounted(async () => {
}
// 验证管理员权限
isAdmin.value = await AuthManager.verifyAdminAuth();
isAdmin.value = await AuthManager.isAdminAuthenticated();
// 如果当前页面是管理员页面但用户不是管理员,切换到用户信息页面
if (activePage.value === 100 && !isAdmin.value) {
activePage.value = 1;
}
} catch (error) {
console.error('用户认证检查失败:', error);
console.error("用户认证检查失败:", error);
// 可以在这里处理错误,比如显示错误信息或重定向到登录页面
}
});

View File

@@ -273,7 +273,13 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { AuthManager } from "@/utils/AuthManager";
import { UserInfo, Board, BoardStatus } from "@/APIClient";
import {
UserInfo,
Board,
BoardStatus,
DataClient,
JtagClient,
} from "@/APIClient";
import { Alert, useAlertStore } from "@/components/Alert";
import {
User,
@@ -319,7 +325,7 @@ const loadBoardInfo = async () => {
}
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
boardInfo.value = await client.getBoardByID(userInfo.value.boardID);
} catch (err) {
console.error("加载实验板信息失败:", err);
@@ -335,7 +341,7 @@ const loadUserInfo = async (showSuccessMessage = false) => {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
userInfo.value = await client.getUserInfo();
// 如果有绑定的实验板ID加载实验板信息
@@ -370,7 +376,7 @@ const applyBoard = async () => {
alertStore?.info("正在申请实验板...");
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
// 获取可用的实验板
const availableBoard = await client.getAvailableBoard(undefined);
@@ -407,7 +413,7 @@ const testBoardConnection = async () => {
alertStore?.info("正在测试连接...");
try {
const jtagClient = AuthManager.createAuthenticatedJtagClient();
const jtagClient = AuthManager.createClient(JtagClient);
// 使用JTAG客户端读取设备ID Code
const idCode = await jtagClient.getDeviceIDCode(
@@ -444,7 +450,7 @@ const unbindBoard = async () => {
alertStore?.info("正在解绑实验板...");
try {
const client = AuthManager.createAuthenticatedDataClient();
const client = AuthManager.createClient(DataClient);
const success = await client.unbindBoard();
if (success) {