diff --git a/flake.nix b/flake.nix index 4c86967..e37066a 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/package-lock.json b/package-lock.json index 077ddd6..506fffb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1c6a314..4323a92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/GenerateWebAPI.ts b/scripts/GenerateWebAPI.ts index 6358155..0917de3 100644 --- a/scripts/GenerateWebAPI.ts +++ b/scripts/GenerateWebAPI.ts @@ -304,7 +304,7 @@ async function generateSignalRClient(): Promise { 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); diff --git a/server/.gitignore b/server/.gitignore index 05e698d..b9f6ea7 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,5 +1,6 @@ +# Generate obj bin bitstream bsdl - +data diff --git a/server/Program.cs b/server/Program.cs index a0f9858..35d028a 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -62,8 +62,39 @@ 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") + )) + { + // 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 +102,7 @@ try options.AddPolicy("Admin", policy => { policy.RequireClaim(ClaimTypes.Role, new string[] { - Database.User.UserPermission.Admin.ToString(), + Database.UserPermission.Admin.ToString(), }); }); }); @@ -141,6 +172,11 @@ try options.OperationProcessors.Add(new OperationSecurityScopeProcessor("Bearer")); }); + // 添加数据库资源管理器服务 + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); // 添加 HTTP 视频流服务 builder.Services.AddSingleton(); @@ -209,7 +245,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(); @@ -232,7 +268,7 @@ try { 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 { diff --git a/server/src/Common/Global.cs b/server/src/Common/Global.cs index 2d8cb0e..d48215a 100644 --- a/server/src/Common/Global.cs +++ b/server/src/Common/Global.cs @@ -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() { diff --git a/server/src/Controllers/DataController.cs b/server/src/Controllers/DataController.cs index 0df9499..71e38ce 100644 --- a/server/src/Controllers/DataController.cs +++ b/server/src/Controllers/DataController.cs @@ -17,38 +17,15 @@ namespace server.Controllers; public class DataController : ControllerBase { private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly Database.UserManager _userManager; + // 固定的实验板IP,端口,MAC地址 private const string BOARD_IP = "169.254.109.0"; - /// - /// [TODO:description] - /// - public class UserInfo + public DataController(Database.UserManager userManager) { - /// - /// 用户的唯一标识符 - /// - public Guid ID { get; set; } - - /// - /// 用户的名称 - /// - public required string Name { get; set; } - - /// - /// 用户的电子邮箱 - /// - public required string EMail { get; set; } - - /// - /// 用户关联的板卡ID - /// - public Guid BoardID { get; set; } - - /// - /// 用户绑定板子的过期时间 - /// - public DateTime? BoardExpireTime { get; set; } + _userManager = userManager; } /// @@ -112,8 +89,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 +164,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 +211,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 +239,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 +282,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 +310,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 +346,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 +372,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 +394,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 +421,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 +440,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 +455,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, "新增失败,请稍后重试"); + } + } + + /// + /// [TODO:description] + /// + public class UserInfo + { + /// + /// 用户的唯一标识符 + /// + public Guid ID { get; set; } + + /// + /// 用户的名称 + /// + public required string Name { get; set; } + + /// + /// 用户的电子邮箱 + /// + public required string EMail { get; set; } + + /// + /// 用户关联的板卡ID + /// + public Guid BoardID { get; set; } + + /// + /// 用户绑定板子的过期时间 + /// + public DateTime? BoardExpireTime { get; set; } + } } diff --git a/server/src/Controllers/DebuggerController.cs b/server/src/Controllers/DebuggerController.cs index 44a3876..a47e5a6 100644 --- a/server/src/Controllers/DebuggerController.cs +++ b/server/src/Controllers/DebuggerController.cs @@ -15,77 +15,11 @@ public class DebuggerController : ControllerBase { private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - /// - /// 表示单个信号通道的配置信息 - /// - public class ChannelConfig - { - /// - /// 通道名称 - /// - required public string name; - /// - /// 通道显示颜色(如前端波形显示用) - /// - required public string color; - /// - /// 通道信号线宽度(位数) - /// - required public UInt32 wireWidth; - /// - /// 信号线在父端口中的起始索引(bit) - /// - required public UInt32 wireStartIndex; - /// - /// 父端口编号 - /// - required public UInt32 parentPort; - /// - /// 捕获模式(如上升沿、下降沿等) - /// - required public CaptureMode mode; - } + private readonly Database.UserManager _userManager; - /// - /// 调试器整体配置信息 - /// - public class DebuggerConfig + public DebuggerController(Database.UserManager userManager) { - /// - /// 时钟频率 - /// - required public UInt32 clkFreq; - /// - /// 总端口数量 - /// - required public UInt32 totalPortNum; - /// - /// 捕获深度(采样点数) - /// - required public UInt32 captureDepth; - /// - /// 触发器数量 - /// - required public UInt32 triggerNum; - /// - /// 所有信号通道的配置信息 - /// - required public ChannelConfig[] channelConfigs; - } - - /// - /// 单个通道的捕获数据 - /// - public class ChannelCaptureData - { - /// - /// 通道名称 - /// - required public string name; - /// - /// 通道捕获到的数据(Base64编码的UInt32数组) - /// - required public string data; + this._userManager = userManager; } /// @@ -99,8 +33,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 +41,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 +397,77 @@ public class DebuggerController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试"); } } + + /// + /// 表示单个信号通道的配置信息 + /// + public class ChannelConfig + { + /// + /// 通道名称 + /// + required public string name; + /// + /// 通道显示颜色(如前端波形显示用) + /// + required public string color; + /// + /// 通道信号线宽度(位数) + /// + required public UInt32 wireWidth; + /// + /// 信号线在父端口中的起始索引(bit) + /// + required public UInt32 wireStartIndex; + /// + /// 父端口编号 + /// + required public UInt32 parentPort; + /// + /// 捕获模式(如上升沿、下降沿等) + /// + required public CaptureMode mode; + } + + /// + /// 调试器整体配置信息 + /// + public class DebuggerConfig + { + /// + /// 时钟频率 + /// + required public UInt32 clkFreq; + /// + /// 总端口数量 + /// + required public UInt32 totalPortNum; + /// + /// 捕获深度(采样点数) + /// + required public UInt32 captureDepth; + /// + /// 触发器数量 + /// + required public UInt32 triggerNum; + /// + /// 所有信号通道的配置信息 + /// + required public ChannelConfig[] channelConfigs; + } + + /// + /// 单个通道的捕获数据 + /// + public class ChannelCaptureData + { + /// + /// 通道名称 + /// + required public string name; + /// + /// 通道捕获到的数据(Base64编码的UInt32数组) + /// + required public string data; + } } diff --git a/server/src/Controllers/ExamController.cs b/server/src/Controllers/ExamController.cs index 178907f..071f7e2 100644 --- a/server/src/Controllers/ExamController.cs +++ b/server/src/Controllers/ExamController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using DotNext; +using Database; namespace server.Controllers; @@ -14,127 +15,18 @@ public class ExamController : ControllerBase { private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - /// - /// 实验信息类 - /// - public class ExamInfo + private readonly ExamManager _examManager; + private readonly ResourceManager _resourceManager; + private readonly UserManager _userManager; + + public ExamController( + ExamManager examManager, + ResourceManager resourceManager, + UserManager userManager) { - /// - /// 实验的唯一标识符 - /// - public required string ID { get; set; } - - /// - /// 实验名称 - /// - public required string Name { get; set; } - - /// - /// 实验描述 - /// - public required string Description { get; set; } - - /// - /// 实验创建时间 - /// - public DateTime CreatedTime { get; set; } - - /// - /// 实验最后更新时间 - /// - public DateTime UpdatedTime { get; set; } - - /// - /// 实验标签 - /// - public string[] Tags { get; set; } = Array.Empty(); - - /// - /// 实验难度(1-5) - /// - public int Difficulty { get; set; } = 1; - - /// - /// 普通用户是否可见 - /// - public bool IsVisibleToUsers { get; set; } = true; - } - - /// - /// 实验简要信息类(用于列表显示) - /// - public class ExamSummary - { - /// - /// 实验的唯一标识符 - /// - public required string ID { get; set; } - - /// - /// 实验名称 - /// - public required string Name { get; set; } - - /// - /// 实验创建时间 - /// - public DateTime CreatedTime { get; set; } - - /// - /// 实验最后更新时间 - /// - public DateTime UpdatedTime { get; set; } - - /// - /// 实验标签 - /// - public string[] Tags { get; set; } = Array.Empty(); - - /// - /// 实验难度(1-5) - /// - public int Difficulty { get; set; } = 1; - - /// - /// 普通用户是否可见 - /// - public bool IsVisibleToUsers { get; set; } = true; - } - - /// - /// 创建实验请求类 - /// - public class CreateExamRequest - { - /// - /// 实验ID - /// - public required string ID { get; set; } - - /// - /// 实验名称 - /// - public required string Name { get; set; } - - /// - /// 实验描述 - /// - public required string Description { get; set; } - - /// - /// 实验标签 - /// - public string[] Tags { get; set; } = Array.Empty(); - - /// - /// 实验难度(1-5) - /// - public int Difficulty { get; set; } = 1; - - /// - /// 普通用户是否可见 - /// - public bool IsVisibleToUsers { get; set; } = true; + _examManager = examManager; + _resourceManager = resourceManager; + _userManager = userManager; } /// @@ -144,29 +36,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 +77,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 +92,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 +110,7 @@ public class ExamController : ControllerBase /// 创建实验请求 /// 创建结果 [Authorize("Admin")] - [HttpPost] + [HttpPost("create")] [EnableCors("Users")] [ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] @@ -247,37 +118,26 @@ 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) { if (result.Error.Message.Contains("已存在")) return Conflict(result.Error.Message); - + logger.Error($"创建实验时出错: {result.Error.Message}"); return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {result.Error.Message}"); } 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 +148,385 @@ public class ExamController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}"); } } + + /// + /// 更新实验信息 + /// + /// 更新实验请求 + /// 更新结果 + [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}"); + } + } + + /// + /// 提交作业 + /// + /// 实验ID + /// 提交的文件 + /// 提交结果 + [Authorize] + [HttpPost("commit/{examId}")] + [EnableCors("Users")] + [ProducesResponseType(typeof(Resource), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task SubmitHomework(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 = 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}"); + } + } + + /// + /// 获取用户在指定实验中的提交记录 + /// + /// 实验ID + /// 提交记录列表 + [Authorize] + [HttpGet("commits/{examId}")] + [EnableCors("Users")] + [ProducesResponseType(typeof(Resource[]), 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; + + logger.Info($"成功获取用户 {userName} 在实验 {examId} 中的提交记录,共 {commits.Length} 条"); + return Ok(commits); + } + catch (Exception ex) + { + logger.Error($"获取实验 {examId} 提交记录时出错: {ex.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"获取提交记录失败: {ex.Message}"); + } + } + + /// + /// 删除提交记录 + /// + /// 提交记录ID + /// 删除结果 + [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(string commitId) + { + if (!Guid.TryParse(commitId, out _)) + 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 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}"); + } + } +} + +/// +/// 实验信息 +/// +public class ExamInfo +{ + /// + /// 实验的唯一标识符 + /// + public string ID { get; set; } + + /// + /// 实验名称 + /// + public string Name { get; set; } + + /// + /// 实验描述 + /// + public string Description { get; set; } + + /// + /// 实验创建时间 + /// + public DateTime CreatedTime { get; set; } + + /// + /// 实验最后更新时间 + /// + public DateTime UpdatedTime { get; set; } + + /// + /// 实验标签 + /// + public string[] Tags { get; set; } = Array.Empty(); + + /// + /// 实验难度(1-5) + /// + public int Difficulty { get; set; } = 1; + + /// + /// 普通用户是否可见 + /// + 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; + } +} + +/// +/// 统一的实验数据传输对象 +/// +public class ExamDto +{ + /// + /// 实验的唯一标识符 + /// + public required string ID { get; set; } + + /// + /// 实验名称 + /// + public required string Name { get; set; } + + /// + /// 实验描述 + /// + public required string Description { get; set; } + + /// + /// 实验标签 + /// + public string[] Tags { get; set; } = Array.Empty(); + + /// + /// 实验难度(1-5) + /// + public int Difficulty { get; set; } = 1; + + /// + /// 普通用户是否可见 + /// + public bool IsVisibleToUsers { get; set; } = true; } diff --git a/server/src/Controllers/HdmiVideoStreamController.cs b/server/src/Controllers/HdmiVideoStreamController.cs index 396af2d..cdcbb92 100644 --- a/server/src/Controllers/HdmiVideoStreamController.cs +++ b/server/src/Controllers/HdmiVideoStreamController.cs @@ -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,12 +11,15 @@ namespace server.Controllers; [EnableCors("Users")] public class HdmiVideoStreamController : ControllerBase { - private readonly HttpHdmiVideoStreamService _videoStreamService; private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService) + private readonly HttpHdmiVideoStreamService _videoStreamService; + private readonly Database.UserManager _userManager; + + public HdmiVideoStreamController(HttpHdmiVideoStreamService videoStreamService, Database.UserManager userManager) { _videoStreamService = videoStreamService; + _userManager = userManager; } // 管理员获取所有板子的 endpoints @@ -40,11 +42,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 +51,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 +68,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."); diff --git a/server/src/Controllers/JtagController.cs b/server/src/Controllers/JtagController.cs index 349442e..a992eb0 100644 --- a/server/src/Controllers/JtagController.cs +++ b/server/src/Controllers/JtagController.cs @@ -17,12 +17,17 @@ public class JtagController : ControllerBase private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); private readonly ProgressTrackerService _tracker; + private readonly UserManager _userManager; + private readonly ResourceManager _resourceManager; private const string BITSTREAM_PATH = "bitstream/Jtag"; - public JtagController(ProgressTrackerService tracker) + public JtagController( + ProgressTrackerService tracker, UserManager userManager, ResourceManager resourceManager) { _tracker = tracker; + _userManager = userManager; + _resourceManager = resourceManager; } /// @@ -127,6 +132,7 @@ public class JtagController : ControllerBase /// JTAG 设备地址 /// JTAG 设备端口 /// 比特流ID + /// 取消令牌 /// 进度跟踪TaskID [HttpPost("DownloadBitstream")] [EnableCors("Users")] @@ -134,7 +140,7 @@ public class JtagController : ControllerBase [ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async ValueTask DownloadBitstream(string address, int port, int bitstreamId, CancellationToken cancelToken) + public IResult DownloadBitstream(string address, int port, string bitstreamId, CancellationToken cancelToken) { logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}"); @@ -149,35 +155,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}"); @@ -235,7 +239,7 @@ public class JtagController : ControllerBase if (ret.IsSuccessful) { - logger.Info($"User {username} successfully downloaded bitstream '{bitstream.ResourceName}' to device {address}"); + logger.Info($"User {username} successfully downloaded bitstream '{resource.ResourceName}' to device {address}"); progress.Finish(); } else diff --git a/server/src/Controllers/LogicAnalyzerController.cs b/server/src/Controllers/LogicAnalyzerController.cs index f54eeeb..ee6ded3 100644 --- a/server/src/Controllers/LogicAnalyzerController.cs +++ b/server/src/Controllers/LogicAnalyzerController.cs @@ -15,56 +15,11 @@ public class LogicAnalyzerController : ControllerBase { private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - /// - /// 信号触发配置 - /// - public class SignalTriggerConfig + private readonly Database.UserManager _userManager; + + public LogicAnalyzerController(Database.UserManager userManager) { - /// - /// 信号索引 (0-7) - /// - public int SignalIndex { get; set; } - - /// - /// 操作符 - /// - public SignalOperator Operator { get; set; } - - /// - /// 信号值 - /// - public SignalValue Value { get; set; } - } - - /// - /// 捕获配置 - /// - public class CaptureConfig - { - /// - /// 全局触发模式 - /// - public GlobalCaptureMode GlobalMode { get; set; } - /// - /// 捕获深度 - /// - public int CaptureLength { get; set; } = 2048 * 32; - /// - /// 预采样深度 - /// - public int PreCaptureLength { get; set; } = 2048; - /// - /// 有效通道 - /// - public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT; - /// - /// 时钟分频系数 - /// - public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1; - /// - /// 信号触发配置列表 - /// - public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty(); + _userManager = userManager; } /// @@ -78,8 +33,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 +41,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 +376,57 @@ public class LogicAnalyzerController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试"); } } + + /// + /// 信号触发配置 + /// + public class SignalTriggerConfig + { + /// + /// 信号索引 (0-7) + /// + public int SignalIndex { get; set; } + + /// + /// 操作符 + /// + public SignalOperator Operator { get; set; } + + /// + /// 信号值 + /// + public SignalValue Value { get; set; } + } + + /// + /// 捕获配置 + /// + public class CaptureConfig + { + /// + /// 全局触发模式 + /// + public GlobalCaptureMode GlobalMode { get; set; } + /// + /// 捕获深度 + /// + public int CaptureLength { get; set; } = 2048 * 32; + /// + /// 预采样深度 + /// + public int PreCaptureLength { get; set; } = 2048; + /// + /// 有效通道 + /// + public AnalyzerChannelDiv ChannelDiv { get; set; } = AnalyzerChannelDiv.EIGHT; + /// + /// 时钟分频系数 + /// + public AnalyzerClockDiv ClockDiv { get; set; } = AnalyzerClockDiv.DIV1; + /// + /// 信号触发配置列表 + /// + public SignalTriggerConfig[] SignalConfigs { get; set; } = Array.Empty(); + } + } diff --git a/server/src/Controllers/OscilloscopeController.cs b/server/src/Controllers/OscilloscopeController.cs index a8c279f..ef8f629 100644 --- a/server/src/Controllers/OscilloscopeController.cs +++ b/server/src/Controllers/OscilloscopeController.cs @@ -15,71 +15,11 @@ public class OscilloscopeApiController : ControllerBase { private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - /// - /// 示波器完整配置 - /// - public class OscilloscopeFullConfig + private readonly Database.UserManager _userManager; + + public OscilloscopeApiController(Database.UserManager userManager) { - /// - /// 是否启动捕获 - /// - public bool CaptureEnabled { get; set; } - - /// - /// 触发电平(0-255) - /// - public byte TriggerLevel { get; set; } - - /// - /// 触发边沿(true为上升沿,false为下降沿) - /// - public bool TriggerRisingEdge { get; set; } - - /// - /// 水平偏移量(0-1023) - /// - public ushort HorizontalShift { get; set; } - - /// - /// 抽样率(0-1023) - /// - public ushort DecimationRate { get; set; } - - /// - /// 是否自动刷新RAM - /// - public bool AutoRefreshRAM { get; set; } = true; - } - - /// - /// 示波器状态和数据 - /// - public class OscilloscopeDataResponse - { - /// - /// AD采样频率 - /// - public uint ADFrequency { get; set; } - - /// - /// AD采样幅度 - /// - public byte ADVpp { get; set; } - - /// - /// AD采样最大值 - /// - public byte ADMax { get; set; } - - /// - /// AD采样最小值 - /// - public byte ADMin { get; set; } - - /// - /// 波形数据(Base64编码) - /// - public string WaveformData { get; set; } = string.Empty; + _userManager = userManager; } /// @@ -93,8 +33,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 +41,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 +420,72 @@ public class OscilloscopeApiController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, "操作失败,请稍后重试"); } } -} \ No newline at end of file + + /// + /// 示波器完整配置 + /// + public class OscilloscopeFullConfig + { + /// + /// 是否启动捕获 + /// + public bool CaptureEnabled { get; set; } + + /// + /// 触发电平(0-255) + /// + public byte TriggerLevel { get; set; } + + /// + /// 触发边沿(true为上升沿,false为下降沿) + /// + public bool TriggerRisingEdge { get; set; } + + /// + /// 水平偏移量(0-1023) + /// + public ushort HorizontalShift { get; set; } + + /// + /// 抽样率(0-1023) + /// + public ushort DecimationRate { get; set; } + + /// + /// 是否自动刷新RAM + /// + public bool AutoRefreshRAM { get; set; } = true; + } + + /// + /// 示波器状态和数据 + /// + public class OscilloscopeDataResponse + { + /// + /// AD采样频率 + /// + public uint ADFrequency { get; set; } + + /// + /// AD采样幅度 + /// + public byte ADVpp { get; set; } + + /// + /// AD采样最大值 + /// + public byte ADMax { get; set; } + + /// + /// AD采样最小值 + /// + public byte ADMin { get; set; } + + /// + /// 波形数据(Base64编码) + /// + public string WaveformData { get; set; } = string.Empty; + } + +} diff --git a/server/src/Controllers/ResourceController.cs b/server/src/Controllers/ResourceController.cs index af8c9db..7cca444 100644 --- a/server/src/Controllers/ResourceController.cs +++ b/server/src/Controllers/ResourceController.cs @@ -15,6 +15,309 @@ public class ResourceController : ControllerBase { private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + private readonly UserManager _userManager; + private readonly ResourceManager _resourceManager; + + public ResourceController(UserManager userManager, ResourceManager resourceManager) + { + _userManager = userManager; + _resourceManager = resourceManager; + } + + /// + /// 添加资源(文件上传) + /// + /// 添加资源请求 + /// 资源文件 + /// 添加结果 + [Authorize] + [HttpPost] + [EnableCors("Users")] + [ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task AddResource([FromForm] AddResourceRequest request, IFormFile file) + { + if (string.IsNullOrWhiteSpace(request.ResourceType) || file == null) + return BadRequest("资源类型、资源用途和文件不能为空"); + + // 验证资源用途 + if (request.ResourcePurpose != ResourcePurpose.Template && request.ResourcePurpose != ResourcePurpose.User) + return BadRequest($"无效的资源用途: {request.ResourcePurpose}"); + + // 模板资源需要管理员权限 + if (request.ResourcePurpose == ResourcePurpose.Template && !User.IsInRole("Admin")) + return Forbid("只有管理员可以添加模板资源"); + + try + { + // 获取当前用户ID + 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; + + // 读取文件数据 + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream); + var fileData = memoryStream.ToArray(); + + var result = _resourceManager.AddResource( + user.ID, request.ResourceType, request.ResourcePurpose, + file.FileName, fileData, request.ExamID); + + if (!result.IsSuccessful) + { + if (result.Error.Message.Contains("不存在")) + return NotFound(result.Error.Message); + + logger.Error($"添加资源时出错: {result.Error.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {result.Error.Message}"); + } + + var resource = result.Value; + var resourceInfo = new ResourceInfo + { + ID = resource.ID.ToString(), + Name = resource.ResourceName, + Type = resource.ResourceType, + Purpose = resource.Purpose, + UploadTime = resource.UploadTime, + ExamID = resource.ExamID, + MimeType = resource.MimeType + }; + + logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName}"); + return CreatedAtAction(nameof(GetResourceById), new { resourceId = resource.ID }, resourceInfo); + } + catch (Exception ex) + { + logger.Error($"添加资源 {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} 时出错: {ex.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {ex.Message}"); + } + } + + /// + /// 获取资源列表 + /// + /// 实验ID(可选) + /// 资源类型(可选) + /// 资源用途(可选) + /// 资源列表 + [Authorize] + [HttpGet] + [EnableCors("Users")] + [ProducesResponseType(typeof(ResourceInfo[]), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetResourceList( + [FromQuery] string? examId = null, + [FromQuery] string? resourceType = null, + [FromQuery] ResourcePurpose? resourcePurpose = null) + { + try + { + // 获取当前用户ID + 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; + + Result> result; + // 管理员 + if (user.Permission == UserPermission.Admin) + { + result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose); + } + // 用户 + else if (resourcePurpose == ResourcePurpose.User) + { + result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose, user.ID); + } + // 模板 + else if (resourcePurpose == ResourcePurpose.Template) + { + result = _resourceManager.GetFullResourceList(examId, resourceType, resourcePurpose); + } + // 其他 + else + { + // 这种情况下需要分别查询并合并结果 + var userResourcesResult = _resourceManager.GetFullResourceList( + examId, resourceType, ResourcePurpose.User, user.ID); + var templateResourcesResult = _resourceManager.GetFullResourceList( + examId, resourceType, ResourcePurpose.Template, null); + + if (!userResourcesResult.IsSuccessful || !templateResourcesResult.IsSuccessful) + { + logger.Error($"获取资源列表时出错"); + return StatusCode(StatusCodes.Status500InternalServerError, "获取资源列表失败"); + } + + var allResources = userResourcesResult.Value.Concat(templateResourcesResult.Value) + .OrderByDescending(r => r.UploadTime); + var mergedResourceInfos = allResources.Select(r => new ResourceInfo + { + ID = r.ID.ToString(), + Name = r.ResourceName, + Type = r.ResourceType, + Purpose = r.Purpose, + UploadTime = r.UploadTime, + ExamID = r.ExamID, + MimeType = r.MimeType + }).ToArray(); + + logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源"); + return Ok(mergedResourceInfos); + } + + if (!result.IsSuccessful) + { + logger.Error($"获取资源列表时出错: {result.Error.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {result.Error.Message}"); + } + + var resources = result.Value.Select(r => new ResourceInfo + { + ID = r.ID.ToString(), + Name = r.ResourceName, + Type = r.ResourceType, + Purpose = r.Purpose, + UploadTime = r.UploadTime, + ExamID = r.ExamID, + MimeType = r.MimeType + }).ToArray(); + + logger.Info($"成功获取资源列表,共 {resources.Length} 个资源"); + return Ok(resources); + } + catch (Exception ex) + { + logger.Error($"获取资源列表时出错: {ex.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {ex.Message}"); + } + } + + /// + /// 根据资源ID下载资源 + /// + /// 资源ID + /// 资源文件 + [HttpGet("{resourceId}")] + [EnableCors("Users")] + [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetResourceById(string resourceId) + { + try + { + var result = _resourceManager.GetResourceById(resourceId); + + if (!result.HasValue) + { + logger.Warn($"资源不存在: {resourceId}"); + return NotFound($"资源 {resourceId} 不存在"); + } + + var resource = result.Value; + logger.Info($"成功获取资源: {resourceId} ({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) + { + logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}"); + } + } + + /// + /// 删除资源 + /// + /// 资源ID + /// 删除结果 + [Authorize] + [HttpDelete("{resourceId}")] + [EnableCors("Users")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult DeleteResource(string resourceId) + { + 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 resourceResult = _resourceManager.GetResourceById(resourceId); + + if (!resourceResult.HasValue) + { + logger.Warn($"资源不存在: {resourceId}"); + return NotFound($"资源 {resourceId} 不存在"); + } + + var resource = resourceResult.Value; + + // 权限检查:管理员可以删除所有资源,普通用户只能删除自己的用户资源 + if (!User.IsInRole("Admin")) + { + if (resource.Purpose == ResourcePurpose.Template) + return Forbid("普通用户不能删除模板资源"); + + if (resource.UserID != user.ID) + return Forbid("只能删除自己的资源"); + } + + var deleteResult = _resourceManager.DeleteResource(resourceId); + if (!deleteResult.IsSuccessful) + { + logger.Error($"删除资源时出错: {deleteResult.Error.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {deleteResult.Error.Message}"); + } + + logger.Info($"成功删除资源: {resourceId} ({resource.ResourceName})"); + return NoContent(); + } + catch (Exception ex) + { + logger.Error($"删除资源 {resourceId} 时出错: {ex.Message}"); + return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {ex.Message}"); + } + } + /// /// 资源信息类 /// @@ -23,7 +326,7 @@ public class ResourceController : ControllerBase /// /// 资源ID /// - public int ID { get; set; } + public required string ID { get; set; } /// /// 资源名称 @@ -38,7 +341,7 @@ public class ResourceController : ControllerBase /// /// 资源用途(template/user) /// - public required string Purpose { get; set; } + public required ResourcePurpose Purpose { get; set; } /// /// 上传时间 @@ -69,7 +372,7 @@ public class ResourceController : ControllerBase /// /// 资源用途(template/user) /// - public required string ResourcePurpose { get; set; } + public required ResourcePurpose ResourcePurpose { get; set; } /// /// 所属实验ID(可选) @@ -77,301 +380,4 @@ public class ResourceController : ControllerBase public string? ExamID { get; set; } } - /// - /// 添加资源(文件上传) - /// - /// 添加资源请求 - /// 资源文件 - /// 添加结果 - [Authorize] - [HttpPost] - [EnableCors("Users")] - [ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task AddResource([FromForm] AddResourceRequest request, IFormFile file) - { - if (string.IsNullOrWhiteSpace(request.ResourceType) || string.IsNullOrWhiteSpace(request.ResourcePurpose) || file == null) - return BadRequest("资源类型、资源用途和文件不能为空"); - - // 验证资源用途 - if (request.ResourcePurpose != Resource.ResourcePurposes.Template && request.ResourcePurpose != Resource.ResourcePurposes.User) - return BadRequest($"无效的资源用途: {request.ResourcePurpose}"); - - // 模板资源需要管理员权限 - if (request.ResourcePurpose == Resource.ResourcePurposes.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); - if (!userResult.IsSuccessful || !userResult.Value.HasValue) - return Unauthorized("用户不存在"); - - var user = userResult.Value.Value; - - // 读取文件数据 - using var memoryStream = new MemoryStream(); - await file.CopyToAsync(memoryStream); - var fileData = memoryStream.ToArray(); - - var result = db.AddResource(user.ID, request.ResourceType, request.ResourcePurpose, file.FileName, fileData, request.ExamID); - - if (!result.IsSuccessful) - { - if (result.Error.Message.Contains("不存在")) - return NotFound(result.Error.Message); - - logger.Error($"添加资源时出错: {result.Error.Message}"); - 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 - }; - - logger.Info($"成功添加资源: {request.ResourceType}/{request.ResourcePurpose}/{file.FileName}"); - return CreatedAtAction(nameof(GetResourceById), new { resourceId = resource.ID }, resourceInfo); - } - catch (Exception ex) - { - logger.Error($"添加资源 {request.ResourceType}/{request.ResourcePurpose}/{file.FileName} 时出错: {ex.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"添加资源失败: {ex.Message}"); - } - } - - /// - /// 获取资源列表 - /// - /// 实验ID(可选) - /// 资源类型(可选) - /// 资源用途(可选) - /// 资源列表 - [Authorize] - [HttpGet] - [EnableCors("Users")] - [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) - { - try - { - using var db = new Database.AppDataConnection(); - - // 获取当前用户ID - var userName = User.Identity?.Name; - if (string.IsNullOrEmpty(userName)) - return Unauthorized("无法获取用户信息"); - - var userResult = db.GetUserByName(userName); - if (!userResult.IsSuccessful || !userResult.Value.HasValue) - return Unauthorized("用户不存在"); - - var user = userResult.Value.Value; - - // 普通用户只能查看自己的资源和模板资源 - Guid? userId = null; - if (!User.IsInRole("Admin")) - { - // 如果指定了用户资源用途,则只查看自己的资源 - if (resourcePurpose == Resource.ResourcePurposes.User) - { - userId = user.ID; - } - // 如果指定了模板资源用途,则不限制用户ID - else if (resourcePurpose == Resource.ResourcePurposes.Template) - { - userId = null; - } - // 如果没有指定用途,则查看自己的用户资源和所有模板资源 - else - { - // 这种情况下需要分别查询并合并结果 - var userResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.User, user.ID); - var templateResourcesResult = db.GetFullResourceList(examId, resourceType, Resource.ResourcePurposes.Template, null); - - if (!userResourcesResult.IsSuccessful || !templateResourcesResult.IsSuccessful) - { - logger.Error($"获取资源列表时出错"); - return StatusCode(StatusCodes.Status500InternalServerError, "获取资源列表失败"); - } - - 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(); - - logger.Info($"成功获取资源列表,共 {mergedResourceInfos.Length} 个资源"); - return Ok(mergedResourceInfos); - } - } - - var result = db.GetFullResourceList(examId, resourceType, resourcePurpose, userId); - - if (!result.IsSuccessful) - { - logger.Error($"获取资源列表时出错: {result.Error.Message}"); - 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(); - - logger.Info($"成功获取资源列表,共 {resources.Length} 个资源"); - return Ok(resources); - } - catch (Exception ex) - { - logger.Error($"获取资源列表时出错: {ex.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源列表失败: {ex.Message}"); - } - } - - /// - /// 根据资源ID下载资源 - /// - /// 资源ID - /// 资源文件 - [HttpGet("{resourceId}")] - [EnableCors("Users")] - [ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult GetResourceById(int resourceId) - { - try - { - using var db = new Database.AppDataConnection(); - var result = db.GetResourceById(resourceId); - - if (!result.IsSuccessful) - { - logger.Error($"获取资源时出错: {result.Error.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {result.Error.Message}"); - } - - if (!result.Value.HasValue) - { - logger.Warn($"资源不存在: {resourceId}"); - return NotFound($"资源 {resourceId} 不存在"); - } - - var resource = result.Value.Value; - logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})"); - return File(resource.Data, resource.MimeType ?? "application/octet-stream", resource.ResourceName); - } - catch (Exception ex) - { - logger.Error($"获取资源 {resourceId} 时出错: {ex.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"获取资源失败: {ex.Message}"); - } - } - - /// - /// 删除资源 - /// - /// 资源ID - /// 删除结果 - [Authorize] - [HttpDelete("{resourceId}")] - [EnableCors("Users")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult DeleteResource(int resourceId) - { - try - { - using var db = new Database.AppDataConnection(); - - // 获取当前用户信息 - var userName = User.Identity?.Name; - if (string.IsNullOrEmpty(userName)) - return Unauthorized("无法获取用户信息"); - - var userResult = db.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}"); - } - - if (!resourceResult.Value.HasValue) - { - logger.Warn($"资源不存在: {resourceId}"); - return NotFound($"资源 {resourceId} 不存在"); - } - - var resource = resourceResult.Value.Value; - - // 权限检查:管理员可以删除所有资源,普通用户只能删除自己的用户资源 - if (!User.IsInRole("Admin")) - { - if (resource.ResourcePurpose == Resource.ResourcePurposes.Template) - return Forbid("普通用户不能删除模板资源"); - - if (resource.UserID != user.ID) - return Forbid("只能删除自己的资源"); - } - - var deleteResult = db.DeleteResource(resourceId); - if (!deleteResult.IsSuccessful) - { - logger.Error($"删除资源时出错: {deleteResult.Error.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {deleteResult.Error.Message}"); - } - - logger.Info($"成功删除资源: {resourceId} ({resource.ResourceName})"); - return NoContent(); - } - catch (Exception ex) - { - logger.Error($"删除资源 {resourceId} 时出错: {ex.Message}"); - return StatusCode(StatusCodes.Status500InternalServerError, $"删除资源失败: {ex.Message}"); - } - } } diff --git a/server/src/Controllers/VideoStreamController.cs b/server/src/Controllers/VideoStreamController.cs index fe786b9..fca04e3 100644 --- a/server/src/Controllers/VideoStreamController.cs +++ b/server/src/Controllers/VideoStreamController.cs @@ -3,39 +3,22 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; using System.Security.Claims; -using Database; using DotNext; +using server.Services; /// /// 视频流控制器,支持动态配置摄像头连接 /// [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; - /// - /// 分辨率配置请求模型 - /// - public class ResolutionConfigRequest - { - /// - /// 宽度 - /// - [Required] - [Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")] - public int Width { get; set; } - - /// - /// 高度 - /// - [Required] - [Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")] - public int Height { get; set; } - } + private readonly HttpVideoStreamService _videoStreamService; + private readonly Database.UserManager _userManager; public class AvailableResolutionsResponse { @@ -49,10 +32,40 @@ public class VideoStreamController : ControllerBase /// 初始化HTTP视频流控制器 /// /// HTTP视频流服务 - public VideoStreamController(server.Services.HttpVideoStreamService videoStreamService) + /// 用户管理服务 + public VideoStreamController( + HttpVideoStreamService videoStreamService, Database.UserManager userManager) { logger.Info("创建VideoStreamController,命名空间:{Namespace}", this.GetType().Namespace); _videoStreamService = videoStreamService; + _userManager = userManager; + } + + private Optional TryGetBoardId() + { + var userName = User.FindFirstValue(ClaimTypes.Name); + if (string.IsNullOrEmpty(userName)) + { + logger.Error("User name not found in claims."); + return Optional.None; + } + + var userRet = _userManager.GetUserByName(userName); + if (!userRet.IsSuccessful || !userRet.Value.HasValue) + { + logger.Error("User not found."); + return Optional.None; + } + + var user = userRet.Value.Value; + var boardId = user.BoardID; + if (boardId == Guid.Empty) + { + logger.Error("No board bound to this user."); + return Optional.None; + } + + return boardId.ToString(); } private Optional TryGetBoardId() @@ -93,11 +106,10 @@ public class VideoStreamController : ControllerBase /// 获取 HTTP 视频流服务状态 /// /// 服务状态信息 - [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 { @@ -115,8 +127,7 @@ public class VideoStreamController : ControllerBase } [HttpGet("MyEndpoint")] - [EnableCors("Users")] - [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(VideoStreamEndpoint), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)] public IResult MyEndpoint() { @@ -139,7 +150,6 @@ public class VideoStreamController : ControllerBase /// /// 连接测试结果 [HttpPost("TestConnection")] - [EnableCors("Users")] [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] [ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)] public async Task TestConnection() @@ -172,14 +182,16 @@ public class VideoStreamController : ControllerBase } } - [HttpPost("DisableTransmission")] - public async Task DisableHdmiTransmission() + [HttpPost("SetVideoStreamEnable")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)] + public async Task SetVideoStreamEnable(bool enable) { try { var boardId = TryGetBoardId().OrThrow(() => new ArgumentException("Board ID is required")); - await _videoStreamService.DisableHdmiTransmissionAsync(boardId.ToString()); + await _videoStreamService.SetVideoStreamEnableAsync(boardId.ToString(), enable); return Ok($"HDMI transmission for board {boardId} disabled."); } catch (Exception ex) @@ -241,7 +253,7 @@ public class VideoStreamController : ControllerBase /// 支持的分辨率列表 [HttpGet("SupportedResolutions")] [EnableCors("Users")] - [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(AvailableResolutionsResponse[]), StatusCodes.Status200OK)] [ProducesResponseType(typeof(string), StatusCodes.Status500InternalServerError)] public IResult GetSupportedResolutions() { @@ -349,4 +361,65 @@ public class VideoStreamController : ControllerBase return TypedResults.InternalServerError($"执行自动对焦失败: {ex.Message}"); } } + + /// + /// 配置摄像头连接参数 + /// + /// 配置结果 + [HttpPost("ConfigureCamera")] + [EnableCors("Users")] + [ProducesResponseType(typeof(object), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(object), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)] + public async Task ConfigureCamera() + { + try + { + var boardId = TryGetBoardId().OrThrow(() => new Exception("Board ID not found")); + + var ret = await _videoStreamService.ConfigureCameraAsync(boardId); + + if (ret) + { + return TypedResults.Ok(new { Message = "配置成功" }); + } + else + { + return TypedResults.BadRequest(new { Message = "配置失败" }); + } + } + catch (Exception ex) + { + logger.Error(ex, "配置摄像头连接失败"); + return TypedResults.InternalServerError(ex.Message); + } + } + + /// + /// 分辨率配置请求模型 + /// + public class ResolutionConfigRequest + { + /// + /// 宽度 + /// + [Required] + [Range(640, 1920, ErrorMessage = "宽度必须在640-1920范围内")] + public int Width { get; set; } + + /// + /// 高度 + /// + [Required] + [Range(480, 1080, ErrorMessage = "高度必须在480-1080范围内")] + public int Height { get; set; } + } + + public class AvailableResolutionsResponse + { + public int Width { get; set; } + public int Height { get; set; } + public string Name { get; set; } = string.Empty; + public string Value => $"{Width}x{Height}"; + } } diff --git a/server/src/Database.cs b/server/src/Database.cs deleted file mode 100644 index 9f50902..0000000 --- a/server/src/Database.cs +++ /dev/null @@ -1,1266 +0,0 @@ -using DotNext; -using LinqToDB; -using LinqToDB.Data; -using LinqToDB.Mapping; - -namespace Database; - -/// -/// 用户类,表示用户信息 -/// -public class User -{ - /// - /// 用户的唯一标识符 - /// - [PrimaryKey] - public Guid ID { get; set; } = Guid.NewGuid(); - - /// - /// 用户的名称 - /// - [NotNull] - public required string Name { get; set; } - - /// - /// 用户的电子邮箱 - /// - [NotNull] - public required string EMail { get; set; } - - /// - /// 用户的密码(应该进行哈希处理) - /// - [NotNull] - public required string Password { get; set; } - - /// - /// 用户权限等级 - /// - [NotNull] - public required UserPermission Permission { get; set; } - - /// - /// 绑定的实验板ID,如果未绑定则为空 - /// - [Nullable] - public Guid BoardID { get; set; } - - /// - /// 用户绑定板子的过期时间 - /// - [Nullable] - public DateTime? BoardExpireTime { get; set; } - - /// - /// 用户权限枚举 - /// - public enum UserPermission - { - /// - /// 管理员权限,可以管理用户和实验板 - /// - Admin, - - /// - /// 普通用户权限,只能使用实验板 - /// - Normal, - } -} - -/// -/// FPGA 板子类,表示板子信息 -/// -public class Board -{ - /// - /// FPGA 板子的唯一标识符 - /// - [PrimaryKey] - public Guid ID { get; set; } = Guid.NewGuid(); - - /// - /// FPGA 板子的名称 - /// - [NotNull] - public required string BoardName { get; set; } - - /// - /// FPGA 板子的IP地址 - /// - [NotNull] - public required string IpAddr { get; set; } - - /// - /// FPGA 板子的MAC地址 - /// - [NotNull] - public required string MacAddr { get; set; } - - /// - /// FPGA 板子的通信端口 - /// - [NotNull] - public int Port { get; set; } = 1234; - - /// - /// FPGA 板子的当前状态 - /// - [NotNull] - public required BoardStatus Status { get; set; } - - /// - /// 占用该板子的用户的唯一标识符 - /// - [Nullable] - public Guid OccupiedUserID { get; set; } - - /// - /// 占用该板子的用户的用户名 - /// - [Nullable] - public string? OccupiedUserName { get; set; } - - /// - /// FPGA 板子的固件版本号 - /// - [NotNull] - public string FirmVersion { get; set; } = "1.0.0"; - - /// - /// FPGA 板子状态枚举 - /// - public enum BoardStatus - { - /// - /// 未启用状态,无法被使用 - /// - Disabled, - - /// - /// 繁忙状态,正在被用户使用 - /// - Busy, - - /// - /// 可用状态,可以被分配给用户 - /// - Available, - } -} - -/// -/// 实验类,表示实验信息 -/// -public class Exam -{ - /// - /// 实验的唯一标识符 - /// - [PrimaryKey] - public required string ID { get; set; } - - /// - /// 实验名称 - /// - [NotNull] - public required string Name { get; set; } - - /// - /// 实验描述 - /// - [NotNull] - public required string Description { get; set; } - - /// - /// 实验创建时间 - /// - [NotNull] - public DateTime CreatedTime { get; set; } = DateTime.Now; - - /// - /// 实验最后更新时间 - /// - [NotNull] - public DateTime UpdatedTime { get; set; } = DateTime.Now; - - /// - /// 实验标签(以逗号分隔的字符串) - /// - [NotNull] - public string Tags { get; set; } = ""; - - /// - /// 实验难度(1-5,1为最简单) - /// - [NotNull] - public int Difficulty { get; set; } = 1; - - /// - /// 普通用户是否可见 - /// - [NotNull] - public bool IsVisibleToUsers { get; set; } = true; - - /// - /// 获取标签列表 - /// - /// 标签数组 - public string[] GetTagsList() - { - if (string.IsNullOrWhiteSpace(Tags)) - return Array.Empty(); - - return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(tag => tag.Trim()) - .Where(tag => !string.IsNullOrEmpty(tag)) - .ToArray(); - } - - /// - /// 设置标签列表 - /// - /// 标签数组 - public void SetTagsList(string[] tags) - { - Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim())); - } -} - -/// -/// 资源类,统一管理实验资源、用户比特流等各类资源 -/// -public class Resource -{ - /// - /// 资源的唯一标识符 - /// - [PrimaryKey, Identity] - public int ID { get; set; } - - /// - /// 上传资源的用户ID - /// - [NotNull] - public required Guid UserID { get; set; } - - /// - /// 所属实验ID(可选,如果不属于特定实验则为空) - /// - [Nullable] - public string? ExamID { get; set; } - - /// - /// 资源类型(images, markdown, bitstream, diagram, project等) - /// - [NotNull] - public required string ResourceType { get; set; } - - /// - /// 资源用途:template(模板)或 user(用户上传) - /// - [NotNull] - public required string ResourcePurpose { get; set; } - - /// - /// 资源名称(包含文件扩展名) - /// - [NotNull] - public required string ResourceName { get; set; } - - /// - /// 资源的二进制数据 - /// - [NotNull] - public required byte[] Data { get; set; } - - /// - /// 资源创建/上传时间 - /// - [NotNull] - public DateTime UploadTime { get; set; } = DateTime.Now; - - /// - /// 资源的MIME类型 - /// - [NotNull] - public string MimeType { get; set; } = "application/octet-stream"; - - /// - /// 资源类型枚举 - /// - public static class ResourceTypes - { - /// - /// 图片资源类型 - /// - public const string Images = "images"; - - /// - /// Markdown文档资源类型 - /// - public const string Markdown = "markdown"; - - /// - /// 比特流文件资源类型 - /// - public const string Bitstream = "bitstream"; - - /// - /// 原理图资源类型 - /// - public const string Diagram = "diagram"; - - /// - /// 项目文件资源类型 - /// - public const string Project = "project"; - } - - /// - /// 资源用途枚举 - /// - public static class ResourcePurposes - { - /// - /// 模板资源,通常由管理员上传,供用户参考 - /// - public const string Template = "template"; - - /// - /// 用户上传的资源 - /// - public const string User = "user"; - } -} - -/// -/// 应用程序数据连接类,用于与数据库交互 -/// -public class AppDataConnection : DataConnection -{ - private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); - - static readonly string DATABASE_FILEPATH = $"{Environment.CurrentDirectory}/Database.sqlite"; - - static readonly LinqToDB.DataOptions options = - new LinqToDB.DataOptions().UseSQLite($"Data Source={DATABASE_FILEPATH}"); - - /// - /// 初始化应用程序数据连接 - /// - public AppDataConnection() : base(options) - { - 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.User.UserPermission.Admin, - }; - this.Insert(user); - logger.Info("默认管理员用户已创建"); - } - else - { - logger.Info($"数据库连接已建立: {DATABASE_FILEPATH}"); - } - } - - - /// - /// 创建所有数据库表 - /// - public void CreateAllTables() - { - logger.Info("正在创建数据库表..."); - this.CreateTable(); - this.CreateTable(); - this.CreateTable(); - this.CreateTable(); - logger.Info("数据库表创建完成"); - } - - /// - /// 删除所有数据库表 - /// - public void DropAllTables() - { - logger.Warn("正在删除所有数据库表..."); - this.DropTable(); - this.DropTable(); - this.DropTable(); - this.DropTable(); - logger.Warn("所有数据库表已删除"); - } - - /// - /// 添加一个新的用户到数据库 - /// - /// 用户的名称 - /// 用户的电子邮箱地址 - /// 用户的密码 - /// 插入的记录数 - public int AddUser(string name, string email, string password) - { - var user = new User() - { - Name = name, - EMail = email, - Password = password, - Permission = Database.User.UserPermission.Normal, - }; - var result = this.Insert(user); - logger.Info($"新用户已添加: {name} ({email})"); - return result; - } - - /// - /// 根据用户名获取用户信息 - /// - /// 用户名 - /// 包含用户信息的结果,如果未找到或出错则返回相应状态 - public Result> GetUserByName(string name) - { - var user = this.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.None); - } - - logger.Debug($"成功获取用户信息: {name}"); - return new(user[0]); - } - - /// - /// 根据电子邮箱获取用户信息 - /// - /// 用户的电子邮箱地址 - /// 包含用户信息的结果,如果未找到或出错则返回相应状态 - public Result> GetUserByEMail(string email) - { - var user = this.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.None); - } - - logger.Debug($"成功获取用户信息: {email}"); - return new(user[0]); - } - - /// - /// 验证用户密码 - /// - /// 用户名 - /// 用户密码 - /// 如果密码正确返回用户信息,否则返回空 - public Result> CheckUserPassword(string name, string password) - { - var ret = this.GetUserByName(name); - if (!ret.IsSuccessful) - return new(ret.Error); - - if (!ret.Value.HasValue) - return new(Optional.None); - - var user = ret.Value.Value; - - if (user.Password == password) - { - logger.Info($"用户 {name} 密码验证成功"); - return new(user); - } - else - { - logger.Warn($"用户 {name} 密码验证失败"); - return new(Optional.None); - } - } - - /// - /// 绑定用户与实验板 - /// - /// 用户的唯一标识符 - /// 实验板的唯一标识符 - /// 绑定过期时间 - /// 更新的记录数 - public int BindUserToBoard(Guid userId, Guid boardId, DateTime expireTime) - { - // 获取用户信息 - var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault(); - if (user == null) - { - logger.Error($"未找到用户: {userId}"); - return 0; - } - - // 更新用户的板子绑定信息 - var userResult = this.UserTable - .Where(u => u.ID == userId) - .Set(u => u.BoardID, boardId) - .Set(u => u.BoardExpireTime, expireTime) - .Update(); - - // 更新板子的用户绑定信息 - var boardResult = this.BoardTable - .Where(b => b.ID == boardId) - .Set(b => b.Status, Board.BoardStatus.Busy) - .Set(b => b.OccupiedUserID, userId) - .Set(b => b.OccupiedUserName, user.Name) - .Update(); - - logger.Info($"用户 {userId} ({user.Name}) 已绑定到实验板 {boardId},过期时间: {expireTime}"); - return userResult + boardResult; - } - - /// - /// 解除用户与实验板的绑定 - /// - /// 用户的唯一标识符 - /// 更新的记录数 - public int UnbindUserFromBoard(Guid userId) - { - // 获取用户当前绑定的板子ID - var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault(); - Guid boardId = user?.BoardID ?? Guid.Empty; - - // 清空用户的板子绑定信息 - var userResult = this.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 = this.BoardTable - .Where(b => b.ID == boardId) - .Set(b => b.Status, Board.BoardStatus.Available) - .Set(b => b.OccupiedUserID, Guid.Empty) - .Set(b => b.OccupiedUserName, (string?)null) - .Update(); - logger.Info($"实验板 {boardId} 状态已设置为空闲,用户绑定信息已清空"); - } - - logger.Info($"用户 {userId} 已解除实验板绑定"); - return userResult + boardResult; - } - - /// - /// 自动分配一个未被占用的IP地址 - /// - /// 分配的IP地址字符串 - public string AllocateIpAddr() - { - var usedIps = this.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地址"); - } - - /// - /// 自动分配一个未被占用的MAC地址 - /// - /// 分配的MAC地址字符串 - public string AllocateMacAddr() - { - var usedMacs = this.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地址"); - } - - /// - /// 添加一块新的 FPGA 板子到数据库 - /// - /// FPGA 板子的名称 - /// 插入的记录数 - 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 = Database.Board.BoardStatus.Disabled, - }; - var result = this.Insert(board); - logger.Info($"新实验板已添加: {name} ({board.IpAddr}:{board.MacAddr})"); - return board.ID; - } - - /// - /// 根据名称删除实验板 - /// - /// 实验板的名称 - /// 删除的记录数 - public int DeleteBoardByName(string name) - { - // 先获取要删除的板子信息 - var board = this.BoardTable.Where(b => b.BoardName == name).FirstOrDefault(); - if (board == null) - { - logger.Warn($"未找到名称为 {name} 的实验板"); - return 0; - } - - // 如果板子被占用,先解除绑定 - if (board.OccupiedUserID != Guid.Empty) - { - this.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 = this.BoardTable.Where(b => b.BoardName == name).Delete(); - logger.Info($"实验板已删除: {name},删除记录数: {result}"); - return result; - } - - /// - /// 根据ID删除实验板 - /// - /// 实验板的唯一标识符 - /// 删除的记录数 - public int DeleteBoardByID(Guid id) - { - // 先获取要删除的板子信息 - var board = this.BoardTable.Where(b => b.ID == id).FirstOrDefault(); - if (board == null) - { - logger.Warn($"未找到ID为 {id} 的实验板"); - return 0; - } - - // 如果板子被占用,先解除绑定 - if (board.OccupiedUserID != Guid.Empty) - { - this.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 = this.BoardTable.Where(b => b.ID == id).Delete(); - logger.Info($"实验板已删除: {id},删除记录数: {result}"); - return result; - } - - /// - /// 根据实验板ID获取实验板信息 - /// - /// 实验板的唯一标识符 - /// 包含实验板信息的结果,如果未找到则返回空 - public Result> GetBoardByID(Guid id) - { - var boards = this.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.None); - } - - logger.Debug($"成功获取实验板信息: {id}"); - return new(boards[0]); - } - - /// - /// 根据用户名获取实验板信息 - /// - /// 用户名 - /// 包含实验板信息的结果,如果未找到则返回空 - public Result> GetBoardByUserName(string userName) - { - var boards = this.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.None); - } - - logger.Debug($"成功获取实验板信息: {userName}"); - return new(boards[0]); - } - - /// - /// 获取所有实验板信息 - /// - /// 所有实验板的数组 - public Board[] GetAllBoard() - { - var boards = this.BoardTable.ToArray(); - logger.Debug($"获取所有实验板,共 {boards.Length} 块"); - return boards; - } - - /// - /// 获取一块可用的实验板并将其状态设置为繁忙 - /// - /// 要分配板子的用户ID - /// 绑定过期时间 - /// 可用的实验板,如果没有可用的板子则返回空 - public Optional GetAvailableBoard(Guid userId, DateTime expireTime) - { - var boards = this.BoardTable.Where( - (board) => board.Status == Database.Board.BoardStatus.Available - ).ToArray(); - - if (boards.Length == 0) - { - logger.Warn("没有可用的实验板"); - return new(null); - } - else - { - var board = boards[0]; - var user = this.UserTable.Where(u => u.ID == userId).FirstOrDefault(); - - if (user == null) - { - logger.Error($"未找到用户: {userId}"); - return new(null); - } - - // 更新板子状态和用户绑定信息 - this.BoardTable - .Where(target => target.ID == board.ID) - .Set(target => target.Status, Board.BoardStatus.Busy) - .Set(target => target.OccupiedUserID, userId) - .Set(target => target.OccupiedUserName, user.Name) - .Update(); - - // 更新用户的板子绑定信息 - this.UserTable - .Where(u => u.ID == userId) - .Set(u => u.BoardID, board.ID) - .Set(u => u.BoardExpireTime, expireTime) - .Update(); - - board.Status = Database.Board.BoardStatus.Busy; - board.OccupiedUserID = userId; - board.OccupiedUserName = user.Name; - - logger.Info($"实验板 {board.BoardName} ({board.ID}) 已分配给用户 {user.Name} ({userId}),过期时间: {expireTime}"); - return new(board); - } - } - - /// - /// [TODO:description] - /// - /// [TODO:parameter] - /// [TODO:parameter] - /// [TODO:return] - public int UpdateBoardName(Guid boardId, string newName) - { - if (string.IsNullOrWhiteSpace(newName) || newName.Contains('\'') || newName.Contains(';')) - { - logger.Error("实验板名称非法,包含不允许的字符"); - return 0; - } - var result = this.BoardTable - .Where(b => b.ID == boardId) - .Set(b => b.BoardName, newName) - .Update(); - logger.Info($"实验板名称已更新: {boardId} -> {newName}"); - return result; - } - - /// - /// [TODO:description] - /// - /// [TODO:parameter] - /// [TODO:parameter] - /// [TODO:return] - public int UpdateBoardStatus(Guid boardId, Board.BoardStatus newStatus) - { - var result = this.BoardTable - .Where(b => b.ID == boardId) - .Set(b => b.Status, newStatus) - .Update(); - logger.Info($"TODO"); - return result; - } - - /// - /// 用户表 - /// - public ITable UserTable => this.GetTable(); - - /// - /// FPGA 板子表 - /// - public ITable BoardTable => this.GetTable(); - - /// - /// 实验表 - /// - public ITable ExamTable => this.GetTable(); - - /// - /// 资源表(统一管理实验资源、用户比特流等) - /// - public ITable ResourceTable => this.GetTable(); - - /// - /// 创建新实验 - /// - /// 实验ID - /// 实验名称 - /// 实验描述 - /// 实验标签 - /// 实验难度 - /// 普通用户是否可见 - /// 创建的实验 - public Result CreateExam(string id, string name, string description, string[]? tags = null, int difficulty = 1, bool isVisibleToUsers = true) - { - try - { - // 检查实验ID是否已存在 - var existingExam = this.ExamTable.Where(e => e.ID == 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); - } - - this.Insert(exam); - logger.Info($"新实验已创建: {id} ({name})"); - return new(exam); - } - catch (Exception ex) - { - logger.Error($"创建实验时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 更新实验信息 - /// - /// 实验ID - /// 实验名称 - /// 实验描述 - /// 实验标签 - /// 实验难度 - /// 普通用户是否可见 - /// 更新的记录数 - public Result 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 += this.ExamTable.Where(e => e.ID == id).Set(e => e.Name, name).Update(); - } - if (description != null) - { - result += this.ExamTable.Where(e => e.ID == 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 += this.ExamTable.Where(e => e.ID == id).Set(e => e.Tags, tagsString).Update(); - } - if (difficulty.HasValue) - { - result += this.ExamTable.Where(e => e.ID == id).Set(e => e.Difficulty, Math.Max(1, Math.Min(5, difficulty.Value))).Update(); - } - if (isVisibleToUsers.HasValue) - { - result += this.ExamTable.Where(e => e.ID == id).Set(e => e.IsVisibleToUsers, isVisibleToUsers.Value).Update(); - } - - // 更新时间 - this.ExamTable.Where(e => e.ID == 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); - } - } - - /// - /// 添加资源 - /// - /// 上传用户ID - /// 资源类型 - /// 资源用途(template 或 user) - /// 资源名称 - /// 资源二进制数据 - /// 所属实验ID(可选) - /// MIME类型(可选,将根据文件扩展名自动确定) - /// 创建的资源 - public Result AddResource(Guid userId, string resourceType, string resourcePurpose, string resourceName, byte[] data, string? examId = null, string? mimeType = null) - { - try - { - // 验证用户是否存在 - var user = this.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 = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault(); - if (exam == null) - { - logger.Error($"实验不存在: {examId}"); - return new(new Exception($"实验不存在: {examId}")); - } - } - - // 验证资源用途 - if (resourcePurpose != Resource.ResourcePurposes.Template && resourcePurpose != Resource.ResourcePurposes.User) - { - logger.Error($"无效的资源用途: {resourcePurpose}"); - return new(new Exception($"无效的资源用途: {resourcePurpose}")); - } - - // 如果未指定MIME类型,根据文件扩展名自动确定 - if (string.IsNullOrEmpty(mimeType)) - { - var extension = Path.GetExtension(resourceName).ToLowerInvariant(); - mimeType = GetMimeTypeFromExtension(extension, resourceName); - } - - var resource = new Resource - { - UserID = userId, - ExamID = examId, - ResourceType = resourceType, - ResourcePurpose = resourcePurpose, - ResourceName = resourceName, - Data = data, - MimeType = mimeType, - UploadTime = DateTime.Now - }; - - var insertedId = this.InsertWithIdentity(resource); - resource.ID = Convert.ToInt32(insertedId); - - logger.Info($"新资源已添加: {userId}/{resourceType}/{resourcePurpose}/{resourceName} ({data.Length} bytes)" + - (examId != null ? $" [实验: {examId}]" : "") + $" [ID: {resource.ID}]"); - return new(resource); - } - catch (Exception ex) - { - logger.Error($"添加资源时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 获取资源信息列表(返回ID和名称) - /// 资源类型 - /// 实验ID(可选) - /// 资源用途(可选) - /// 用户ID(可选) - /// - /// 资源信息列表 - public Result<(int ID, string Name)[]> GetResourceList(string resourceType, string? examId = null, string? resourcePurpose = null, Guid? userId = null) - { - try - { - var query = this.ResourceTable.Where(r => r.ResourceType == resourceType); - - if (examId != null) - { - query = query.Where(r => r.ExamID == examId); - } - - if (resourcePurpose != null) - { - query = query.Where(r => r.ResourcePurpose == resourcePurpose); - } - - if (userId != null) - { - query = query.Where(r => r.UserID == userId); - } - - var resources = query - .Select(r => new { r.ID, r.ResourceName }) - .ToArray(); - - var result = resources.Select(r => (r.ID, r.ResourceName)).ToArray(); - logger.Info($"获取资源列表: {resourceType}" + - (examId != null ? $"/{examId}" : "") + - (resourcePurpose != null ? $"/{resourcePurpose}" : "") + - (userId != null ? $"/{userId}" : "") + - $",共 {result.Length} 个资源"); - return new(result); - } - catch (Exception ex) - { - logger.Error($"获取资源列表时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 获取完整的资源列表 - /// - /// 实验ID(可选) - /// 资源类型(可选) - /// 资源用途(可选) - /// 用户ID(可选) - /// 完整的资源对象列表 - public Result> GetFullResourceList(string? examId = null, string? resourceType = null, string? resourcePurpose = null, Guid? userId = null) - { - try - { - var query = this.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.ResourcePurpose == 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 != null ? $" [用途: {resourcePurpose}]" : "") + - (userId != null ? $" [用户: {userId}]" : "") + - $",共 {resources.Count} 个资源"); - return new(resources); - } - catch (Exception ex) - { - logger.Error($"获取完整资源列表时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 根据资源ID获取资源 - /// - /// 资源ID - /// 资源数据 - public Result> GetResourceById(int resourceId) - { - try - { - var resource = this.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault(); - - if (resource == null) - { - logger.Info($"未找到资源: {resourceId}"); - return new(Optional.None); - } - - logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})"); - return new(resource); - } - catch (Exception ex) - { - logger.Error($"获取资源时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 删除资源 - /// - /// 资源ID - /// 删除的记录数 - public Result DeleteResource(int resourceId) - { - try - { - var result = this.ResourceTable.Where(r => r.ID == resourceId).Delete(); - logger.Info($"资源已删除: {resourceId},删除记录数: {result}"); - return new(result); - } - catch (Exception ex) - { - logger.Error($"删除资源时出错: {ex.Message}"); - return new(ex); - } - } - - /// - /// 根据文件扩展名获取MIME类型 - /// - /// 文件扩展名 - /// 文件名(可选,用于特殊文件判断) - /// MIME类型 - 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", - ".sbit" => "application/octet-stream", - ".bit" => "application/octet-stream", - ".bin" => "application/octet-stream", - ".json" => "application/json", - ".zip" => "application/zip", - ".md" => "text/markdown", - _ => "application/octet-stream" - }; - } - - /// - /// 获取所有实验信息 - /// - /// 所有实验的数组 - public Exam[] GetAllExams() - { - var exams = this.ExamTable.OrderBy(e => e.ID).ToArray(); - logger.Debug($"获取所有实验,共 {exams.Length} 个"); - return exams; - } - - /// - /// 根据实验ID获取实验信息 - /// - /// 实验ID - /// 包含实验信息的结果,如果未找到则返回空 - public Result> GetExamByID(string examId) - { - var exams = this.ExamTable.Where(exam => exam.ID == 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.None); - } - - logger.Debug($"成功获取实验信息: {examId}"); - return new(exams[0]); - } - - /// - /// 根据文件扩展名获取比特流MIME类型 - /// - /// 文件扩展名 - /// MIME类型 - private string GetBitstreamMimeType(string extension) - { - return extension.ToLowerInvariant() switch - { - ".bit" => "application/octet-stream", - ".sbit" => "application/octet-stream", - ".bin" => "application/octet-stream", - ".mcs" => "application/octet-stream", - ".hex" => "text/plain", - _ => "application/octet-stream" - }; - } -} diff --git a/server/src/Database/Connection.cs b/server/src/Database/Connection.cs new file mode 100644 index 0000000..125b84f --- /dev/null +++ b/server/src/Database/Connection.cs @@ -0,0 +1,98 @@ +using DotNext; +using LinqToDB; +using LinqToDB.Data; + +namespace Database; + +/// +/// 应用程序数据连接类,用于与数据库交互 +/// +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}"); + + /// + /// 用户表 + /// + public ITable UserTable => this.GetTable(); + + /// + /// FPGA 板子表 + /// + public ITable BoardTable => this.GetTable(); + + /// + /// 实验表 + /// + public ITable ExamTable => this.GetTable(); + + /// + /// 资源表(统一管理实验资源、用户比特流等) + /// + public ITable ResourceTable => this.GetTable(); + + /// + /// 初始化应用程序数据连接 + /// + 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}"); + } + } + + /// + /// 创建所有数据库表 + /// + public void CreateAllTables() + { + logger.Info("正在创建数据库表..."); + this.CreateTable(); + this.CreateTable(); + this.CreateTable(); + this.CreateTable(); + logger.Info("数据库表创建完成"); + } + + /// + /// 删除所有数据库表 + /// + public void DropAllTables() + { + logger.Warn("正在删除所有数据库表..."); + this.DropTable(); + this.DropTable(); + this.DropTable(); + this.DropTable(); + logger.Warn("所有数据库表已删除"); + } + + +} diff --git a/server/src/Database/ExamManager.cs b/server/src/Database/ExamManager.cs new file mode 100644 index 0000000..477280c --- /dev/null +++ b/server/src/Database/ExamManager.cs @@ -0,0 +1,154 @@ +using DotNext; +using LinqToDB; +using LinqToDB.Data; + +namespace Database; + +public class ExamManager +{ + private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly AppDataConnection _db; + + public ExamManager(AppDataConnection db) + { + this._db = db; + } + + /// + /// 创建新实验 + /// + /// 实验ID + /// 实验名称 + /// 实验描述 + /// 实验标签 + /// 实验难度 + /// 普通用户是否可见 + /// 创建的实验 + public Result 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); + } + } + + /// + /// 更新实验信息 + /// + /// 实验ID + /// 实验名称 + /// 实验描述 + /// 实验标签 + /// 实验难度 + /// 普通用户是否可见 + /// 更新的记录数 + public Result 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); + } + } + + /// + /// 获取所有实验信息 + /// + /// 所有实验的数组 + public Exam[] GetAllExams() + { + var exams = _db.ExamTable.OrderBy(e => e.ID).ToArray(); + logger.Debug($"获取所有实验,共 {exams.Length} 个"); + return exams; + } + + /// + /// 根据实验ID获取实验信息 + /// + /// 实验ID + /// 包含实验信息的结果,如果未找到则返回空 + public Result> 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.None); + } + + logger.Debug($"成功获取实验信息: {examId}"); + return new(exams[0]); + } + +} diff --git a/server/src/Database/ResourceManager.cs b/server/src/Database/ResourceManager.cs new file mode 100644 index 0000000..5df3691 --- /dev/null +++ b/server/src/Database/ResourceManager.cs @@ -0,0 +1,356 @@ +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; + + public ResourceManager(AppDataConnection db) + { + this._db = db; + } + + /// + /// 根据文件扩展名获取MIME类型 + /// + /// 文件扩展名 + /// 文件名(可选,用于特殊文件判断) + /// MIME类型 + 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" + }; + } + + /// + /// 将二进制数据写入指定路径 + /// + /// 目标文件路径 + /// 要写入的二进制数据 + /// 写入是否成功 + public Result 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); + } + } + + /// + /// 从指定路径读取二进制数据 + /// + /// 要读取的文件路径 + /// 读取到的二进制数据 + public Result 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); + } + } + + /// + /// 添加资源 + /// + /// 上传用户ID + /// 资源类型 + /// 资源用途(template 或 user) + /// 资源名称 + /// 资源二进制数据 + /// 所属实验ID(可选) + /// MIME类型(可选,将根据文件扩展名自动确定) + /// 创建的资源 + public Result 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}"); + 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); + } + } + + /// + /// 获取资源信息列表(返回ID和名称) + /// 资源类型 + /// 实验ID(可选) + /// 资源用途(可选) + /// 用户ID(可选) + /// + /// 资源信息列表 + public Result<(string ID, string Name)[]> 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 + .Select(r => new { r.ID, r.ResourceName }) + .ToArray(); + + var result = resources.Select(r => (r.ID.ToString(), r.ResourceName)).ToArray(); + logger.Info($"获取资源列表: {resourceType}" + + (examId != null ? $"/{examId}" : "") + + ($"/{resourcePurpose.ToString()}") + + (userId != null ? $"/{userId}" : "") + + $",共 {result.Length} 个资源"); + return new(result); + } + catch (Exception ex) + { + logger.Error($"获取资源列表时出错: {ex.Message}"); + return new(ex); + } + } + + /// + /// 获取完整的资源列表 + /// + /// 实验ID(可选) + /// 资源类型(可选) + /// 资源用途(可选) + /// 用户ID(可选) + /// 完整的资源对象列表 + public Result> 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); + } + } + + /// + /// 根据资源ID获取资源 + /// + /// 资源ID + /// 资源数据 + public Optional GetResourceById(string resourceId) + { + var resource = _db.ResourceTable.Where(r => r.ID.ToString() == resourceId).FirstOrDefault(); + + if (resource == null) + { + logger.Info($"未找到资源: {resourceId}"); + return new(null); + } + + logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})"); + return new(resource); + } + + /// + /// 删除资源 + /// + /// 资源ID + /// 删除的记录数 + public Result DeleteResource(string resourceId) + { + try + { + var result = _db.ResourceTable.Where(r => r.ID.ToString() == resourceId).Delete(); + logger.Info($"资源已删除: {resourceId},删除记录数: {result}"); + return new(result); + } + catch (Exception ex) + { + logger.Error($"删除资源时出错: {ex.Message}"); + return new(ex); + } + } + +} diff --git a/server/src/Database/Type.cs b/server/src/Database/Type.cs new file mode 100644 index 0000000..8989bf7 --- /dev/null +++ b/server/src/Database/Type.cs @@ -0,0 +1,350 @@ +using DotNext; +using LinqToDB; +using LinqToDB.Mapping; + +namespace Database; + +/// +/// 用户权限枚举 +/// +public enum UserPermission +{ + /// + /// 管理员权限,可以管理用户和实验板 + /// + Admin, + + /// + /// 普通用户权限,只能使用实验板 + /// + Normal, +} + +/// +/// 用户类,表示用户信息 +/// +public class User +{ + /// + /// 用户的唯一标识符 + /// + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + /// + /// 用户的名称 + /// + [NotNull] + public required string Name { get; set; } + + /// + /// 用户的电子邮箱 + /// + [NotNull] + public required string EMail { get; set; } + + /// + /// 用户的密码(应该进行哈希处理) + /// + [NotNull] + public required string Password { get; set; } + + /// + /// 用户权限等级 + /// + [NotNull] + public required UserPermission Permission { get; set; } + + /// + /// 绑定的实验板ID,如果未绑定则为空 + /// + [Nullable] + public Guid BoardID { get; set; } + + /// + /// 用户绑定板子的过期时间 + /// + [Nullable] + public DateTime? BoardExpireTime { get; set; } +} + +/// +/// FPGA 板子状态枚举 +/// +public enum BoardStatus +{ + /// + /// 未启用状态,无法被使用 + /// + Disabled, + + /// + /// 繁忙状态,正在被用户使用 + /// + Busy, + + /// + /// 可用状态,可以被分配给用户 + /// + Available, +} + +/// +/// FPGA 板子类,表示板子信息 +/// +public class Board +{ + /// + /// FPGA 板子的唯一标识符 + /// + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + /// + /// FPGA 板子的名称 + /// + [NotNull] + public required string BoardName { get; set; } + + /// + /// FPGA 板子的IP地址 + /// + [NotNull] + public required string IpAddr { get; set; } + + /// + /// FPGA 板子的MAC地址 + /// + [NotNull] + public required string MacAddr { get; set; } + + /// + /// FPGA 板子的通信端口 + /// + [NotNull] + public int Port { get; set; } = 1234; + + /// + /// FPGA 板子的当前状态 + /// + [NotNull] + public required BoardStatus Status { get; set; } + + /// + /// 占用该板子的用户的唯一标识符 + /// + [Nullable] + public Guid OccupiedUserID { get; set; } + + /// + /// 占用该板子的用户的用户名 + /// + [Nullable] + public string? OccupiedUserName { get; set; } + + /// + /// FPGA 板子的固件版本号 + /// + [NotNull] + public string FirmVersion { get; set; } = "1.0.0"; + +} + +/// +/// 实验类,表示实验信息 +/// +public class Exam +{ + /// + /// 实验的唯一标识符 + /// + [PrimaryKey] + public required string ID { get; set; } + + /// + /// 实验名称 + /// + [NotNull] + public required string Name { get; set; } + + /// + /// 实验描述 + /// + [NotNull] + public required string Description { get; set; } + + /// + /// 实验创建时间 + /// + [NotNull] + public DateTime CreatedTime { get; set; } = DateTime.Now; + + /// + /// 实验最后更新时间 + /// + [NotNull] + public DateTime UpdatedTime { get; set; } = DateTime.Now; + + /// + /// 实验标签(以逗号分隔的字符串) + /// + [NotNull] + public string Tags { get; set; } = ""; + + /// + /// 实验难度(1-5,1为最简单) + /// + [NotNull] + public int Difficulty { get; set; } = 1; + + /// + /// 普通用户是否可见 + /// + [NotNull] + public bool IsVisibleToUsers { get; set; } = true; + + /// + /// 获取标签列表 + /// + /// 标签数组 + public string[] GetTagsList() + { + if (string.IsNullOrWhiteSpace(Tags)) + return Array.Empty(); + + return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(tag => tag.Trim()) + .Where(tag => !string.IsNullOrEmpty(tag)) + .ToArray(); + } + + /// + /// 设置标签列表 + /// + /// 标签数组 + public void SetTagsList(string[] tags) + { + Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim())); + } +} + +/// +/// 资源类型枚举 +/// +public static class ResourceTypes +{ + /// + /// 图片资源类型 + /// + public const string Images = "images"; + + /// + /// Markdown文档资源类型 + /// + public const string Markdown = "markdown"; + + /// + /// 比特流文件资源类型 + /// + public const string Bitstream = "bitstream"; + + /// + /// 原理图资源类型 + /// + public const string Diagram = "diagram"; + + /// + /// 项目文件资源类型 + /// + public const string Project = "project"; + + /// + /// 压缩文件资源类型 + /// + public const string Compression = "compression"; +} + +public enum ResourcePurpose : int +{ + /// + /// 模板资源,通常由管理员上传,供用户参考 + /// + Template, + + /// + /// 用户上传的资源 + /// + User, + + /// + /// 用户提交的作业 + /// + Homework +} + +/// +/// 资源类,统一管理实验资源、用户比特流等各类资源 +/// +public class Resource +{ + /// + /// 资源的唯一标识符 + /// + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + /// + /// 上传资源的用户ID + /// + [NotNull] + public required Guid UserID { get; set; } + + /// + /// 所属实验ID(可选,如果不属于特定实验则为空) + /// + [Nullable] + public string? ExamID { get; set; } + + /// + /// 资源类型(images, markdown, bitstream, diagram, project等) + /// + [NotNull] + public required string ResourceType { get; set; } + + /// + /// 资源用途:template(模板)或 user(用户上传) + /// + [NotNull] + public required ResourcePurpose Purpose { get; set; } + + /// + /// 资源名称(包含文件扩展名) + /// + [NotNull] + public required string ResourceName { get; set; } + + /// + /// 资源路径(包含文件名和扩展名) + /// + [NotNull] + public required string Path { get; set; } + + /// + /// 资源SHA256哈希值 + /// + [NotNull] + public required string SHA256 { get; set; } + + /// + /// 资源创建/上传时间 + /// + [NotNull] + public DateTime UploadTime { get; set; } = DateTime.Now; + + /// + /// 资源的MIME类型 + /// + [NotNull] + public string MimeType { get; set; } = "application/octet-stream"; + +} diff --git a/server/src/Database/UserManager.cs b/server/src/Database/UserManager.cs new file mode 100644 index 0000000..72c68ae --- /dev/null +++ b/server/src/Database/UserManager.cs @@ -0,0 +1,458 @@ +using DotNext; +using LinqToDB; +using LinqToDB.Data; + +namespace Database; + +public class UserManager +{ + private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly AppDataConnection _db; + + public UserManager(AppDataConnection db) + { + this._db = db; + } + + /// + /// 添加一个新的用户到数据库 + /// + /// 用户的名称 + /// 用户的电子邮箱地址 + /// 用户的密码 + /// 插入的记录数 + 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; + } + + /// + /// 根据用户名获取用户信息 + /// + /// 用户名 + /// 包含用户信息的结果,如果未找到或出错则返回相应状态 + public Result> 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.None); + } + + logger.Debug($"成功获取用户信息: {name}"); + return new(user[0]); + } + + /// + /// 根据电子邮箱获取用户信息 + /// + /// 用户的电子邮箱地址 + /// 包含用户信息的结果,如果未找到或出错则返回相应状态 + public Result> 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.None); + } + + logger.Debug($"成功获取用户信息: {email}"); + return new(user[0]); + } + + /// + /// 验证用户密码 + /// + /// 用户名 + /// 用户密码 + /// 如果密码正确返回用户信息,否则返回空 + public Result> CheckUserPassword(string name, string password) + { + var ret = GetUserByName(name); + if (!ret.IsSuccessful) + return new(ret.Error); + + if (!ret.Value.HasValue) + return new(Optional.None); + + var user = ret.Value.Value; + + if (user.Password == password) + { + logger.Info($"用户 {name} 密码验证成功"); + return new(user); + } + else + { + logger.Warn($"用户 {name} 密码验证失败"); + return new(Optional.None); + } + } + + /// + /// 绑定用户与实验板 + /// + /// 用户的唯一标识符 + /// 实验板的唯一标识符 + /// 绑定过期时间 + /// 更新的记录数 + 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; + } + + /// + /// 解除用户与实验板的绑定 + /// + /// 用户的唯一标识符 + /// 更新的记录数 + 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; + } + + /// + /// 自动分配一个未被占用的IP地址 + /// + /// 分配的IP地址字符串 + 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地址"); + } + + /// + /// 自动分配一个未被占用的MAC地址 + /// + /// 分配的MAC地址字符串 + 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地址"); + } + + /// + /// 添加一块新的 FPGA 板子到数据库 + /// + /// FPGA 板子的名称 + /// 插入的记录数 + 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; + } + + /// + /// 根据名称删除实验板 + /// + /// 实验板的名称 + /// 删除的记录数 + 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; + } + + /// + /// 根据ID删除实验板 + /// + /// 实验板的唯一标识符 + /// 删除的记录数 + 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; + } + + /// + /// 根据实验板ID获取实验板信息 + /// + /// 实验板的唯一标识符 + /// 包含实验板信息的结果,如果未找到则返回空 + public Result> 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.None); + } + + logger.Debug($"成功获取实验板信息: {id}"); + return new(boards[0]); + } + + /// + /// 根据用户名获取实验板信息 + /// + /// 用户名 + /// 包含实验板信息的结果,如果未找到则返回空 + public Result> 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.None); + } + + logger.Debug($"成功获取实验板信息: {userName}"); + return new(boards[0]); + } + + /// + /// 获取所有实验板信息 + /// + /// 所有实验板的数组 + public Board[] GetAllBoard() + { + var boards = _db.BoardTable.ToArray(); + logger.Debug($"获取所有实验板,共 {boards.Length} 块"); + return boards; + } + + /// + /// 获取一块可用的实验板并将其状态设置为繁忙 + /// + /// 要分配板子的用户ID + /// 绑定过期时间 + /// 可用的实验板,如果没有可用的板子则返回空 + public Optional 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); + } + } + + /// + /// [TODO:description] + /// + /// [TODO:parameter] + /// [TODO:parameter] + /// [TODO:return] + 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; + } + + /// + /// [TODO:description] + /// + /// [TODO:parameter] + /// [TODO:parameter] + /// [TODO:return] + 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; + } + +} diff --git a/server/src/Hubs/JtagHub.cs b/server/src/Hubs/JtagHub.cs index f768f87..51cf78a 100644 --- a/server/src/Hubs/JtagHub.cs +++ b/server/src/Hubs/JtagHub.cs @@ -28,22 +28,24 @@ public interface IJtagReceiver public class JtagHub : Hub, IJtagHub { private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly IHubContext _hubContext; + private readonly Database.UserManager _userManager; + private static ConcurrentDictionary FreqTable = new(); private static ConcurrentDictionary CancellationTokenSourceTable = new(); - private readonly IHubContext _hubContext; - - public JtagHub(IHubContext hubContext) + public JtagHub(IHubContext hubContext, Database.UserManager userManager) { _hubContext = hubContext; + _userManager = userManager; } private Optional GetJtagClient(string userName) { 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}"); @@ -97,7 +99,7 @@ public class JtagHub : Hub, IJtagHub return false; } - await SetBoundaryScanFreq(freq); + SetBoundaryScanFreq(freq); var cts = new CancellationTokenSource(); CancellationTokenSourceTable.AddOrUpdate(userName, cts, (key, value) => cts); diff --git a/server/src/Peripherals/HdmiInClient.cs b/server/src/Peripherals/HdmiInClient.cs index 9b5080c..493da7e 100644 --- a/server/src/Peripherals/HdmiInClient.cs +++ b/server/src/Peripherals/HdmiInClient.cs @@ -12,7 +12,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(); diff --git a/server/src/Peripherals/JpegClient.cs b/server/src/Peripherals/JpegClient.cs index 870c441..2be3f7c 100644 --- a/server/src/Peripherals/JpegClient.cs +++ b/server/src/Peripherals/JpegClient.cs @@ -7,14 +7,28 @@ namespace Peripherals.JpegClient; static class JpegAddr { const UInt32 BASE = 0x0000_0000; - public const UInt32 ENABLE = BASE + 0x0; - public const UInt32 FRAME_NUM = BASE + 0x1; - public const UInt32 FRAME_INFO = BASE + 0x2; - public const UInt32 FRAME_SAMPLE_RATE = BASE + 0x3; - public const UInt32 FRAME_DATA_MAX_POINTER = BASE + 0x4; - public const UInt32 DDR_FRAME_DATA_ADDR = 0x0000_0000; - public const UInt32 DDR_FRAME_DATA_MAX_ADDR = 0x8000_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 ADDR_HDMI_WD_START = 0x4000_0000; + public const UInt32 ADDR_JPEG_START = 0x8000_0000; + public const UInt32 ADDR_JPEG_END = 0xA000_0000; } public class JpegInfo @@ -79,39 +93,248 @@ public class Jpeg this.timeout = timeout; } + public async ValueTask> 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 SetEnable(bool enable) { - var ret = await UDPClientPool.WriteAddr( - this.ep, this.taskID, JpegAddr.ENABLE, Convert.ToUInt32(enable), this.timeout); + 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 false; + } + return ret.Value; + } + 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> CheckHdmiIsReady() + { + var ret = await UDPClientPool.ReadAddrWithWait( + this.ep, this.taskID, JpegAddr.HDMI_NOT_READY, 0b01, 0b01, 100, this.timeout); if (!ret.IsSuccessful) { - logger.Error($"Failed to set JPEG enable: {ret.Error}"); - return false; + logger.Error($"Failed to check HDMI status: {ret.Error}"); + return new(ret.Error); } return ret.Value; } - public async ValueTask SetSampleRate(uint rate) + public async ValueTask> GetHdmiResolution() { - var ret = await UDPClientPool.WriteAddr( - this.ep, this.taskID, JpegAddr.FRAME_SAMPLE_RATE, rate, this.timeout); + var ret = await UDPClientPool.ReadAddr( + this.ep, this.taskID, JpegAddr.HDMI_HEIGHT_WIDTH, 0, this.timeout); if (!ret.IsSuccessful) { - logger.Error($"Failed to set JPEG sample rate: {ret.Error}"); - return false; + logger.Error($"Failed to get HDMI resolution: {ret.Error}"); + return new(ret.Error); } - return ret.Value; + + 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[0] | (data[1] << 8); + var height = data[2] | (data[3] << 8); + return new((width, height)); } - public async ValueTask SetSampleRate(JpegSampleRate rate) + public async ValueTask> ConnectJpeg2Hdmi(int width, int height) { - return await SetSampleRate((uint)rate); + 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 / 4); + + { + 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, 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, 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 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 SetSampleRate(JpegSampleRate rate) + // { + // return await SetSampleRate((uint)rate); + // } + public async ValueTask GetFrameNumber() { var ret = await UDPClientPool.ReadAddrByte( - this.ep, this.taskID, JpegAddr.FRAME_NUM, this.timeout); + this.ep, this.taskID, JpegAddr.JPEG_FRAME_SAVE_NUM, this.timeout); if (!ret.IsSuccessful) { logger.Error($"Failed to get JPEG frame number: {ret.Error}"); @@ -122,7 +345,7 @@ public class Jpeg public async ValueTask>> GetFrameInfo(int num) { - var ret = await UDPClientPool.ReadAddr(this.ep, this.taskID, JpegAddr.FRAME_INFO, num, this.timeout); + 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}"); @@ -150,10 +373,10 @@ public class Jpeg return new(infos); } - public async ValueTask UpdatePointer(uint cnt) + public async ValueTask AddFrameNum2Process(uint cnt) { var ret = await UDPClientPool.WriteAddr( - this.ep, this.taskID, JpegAddr.FRAME_DATA_MAX_POINTER, cnt, this.timeout); + this.ep, this.taskID, JpegAddr.JPEG_ADD_NEED_FRAME_NUM, cnt, this.timeout); if (!ret.IsSuccessful) { logger.Error($"Failed to update pointer: {ret.Error}"); @@ -171,13 +394,16 @@ public class Jpeg } MsgBus.UDPServer.ClearUDPData(this.ep.Address.ToString(), this.ep.Port); - var firstReadLength = (int)(Math.Min(length, JpegAddr.DDR_FRAME_DATA_MAX_ADDR - offset)); + 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.DDR_FRAME_DATA_ADDR + offset, firstReadLength, this.timeout); + 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}"); @@ -194,7 +420,7 @@ public class Jpeg if (secondReadLength > 0) { var ret = await UDPClientPool.ReadAddr4Bytes( - this.ep, this.taskID, JpegAddr.DDR_FRAME_DATA_ADDR, secondReadLength, this.timeout); + this.ep, this.taskID, JpegAddr.ADDR_JPEG_START, secondReadLength, this.timeout); if (!ret.IsSuccessful) { logger.Error($"Failed to get JPEG frame data: {ret.Error}"); @@ -239,7 +465,7 @@ public class Jpeg } { - var ret = await UpdatePointer((uint)sizes.Length); + var ret = await AddFrameNum2Process((uint)sizes.Length); if (!ret) logger.Error($"Failed to update pointer"); } diff --git a/server/src/Services/HttpHdmiVideoStreamService.cs b/server/src/Services/HttpHdmiVideoStreamService.cs index d6475cf..883137b 100644 --- a/server/src/Services/HttpHdmiVideoStreamService.cs +++ b/server/src/Services/HttpHdmiVideoStreamService.cs @@ -1,6 +1,7 @@ using System.Net; using System.Collections.Concurrent; using Peripherals.HdmiInClient; +using Peripherals.JpegClient; namespace server.Services; @@ -12,18 +13,34 @@ 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 class HttpHdmiVideoStreamService : BackgroundService { private readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + + private readonly IServiceProvider _serviceProvider; + private HttpListener? _httpListener; private readonly int _serverPort = 4322; - private readonly ConcurrentDictionary _hdmiInDict = new(); - private readonly ConcurrentDictionary _hdmiInCtsDict = new(); + private readonly ConcurrentDictionary _clientDict = new(); + + public HttpHdmiVideoStreamService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } public override async Task StartAsync(CancellationToken cancellationToken) { _httpListener = new HttpListener(); - _httpListener.Prefixes.Add($"http://{Global.localhost}:{_serverPort}/"); + _httpListener.Prefixes.Add($"http://{Global.LocalHost}:{_serverPort}/"); _httpListener.Start(); logger.Info($"HDMI Video Stream Service started on port {_serverPort}"); @@ -67,7 +84,7 @@ public class HttpHdmiVideoStreamService : BackgroundService // 禁用所有活跃的HDMI传输 var disableTasks = new List(); - foreach (var hdmiKey in _hdmiInDict.Keys) + foreach (var hdmiKey in _clientDict.Keys) { disableTasks.Add(DisableHdmiTransmissionAsync(hdmiKey)); } @@ -76,8 +93,7 @@ public class HttpHdmiVideoStreamService : BackgroundService await Task.WhenAll(disableTasks); // 清空字典 - _hdmiInDict.Clear(); - _hdmiInCtsDict.Clear(); + _clientDict.Clear(); _httpListener?.Close(); // 立即关闭监听器,唤醒阻塞 await base.StopAsync(cancellationToken); @@ -87,11 +103,10 @@ 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); + var disableResult = await client.HdmiInClient.EnableTrans(false); if (disableResult.IsSuccessful) { logger.Info("Successfully disabled HDMI transmission"); @@ -107,40 +122,14 @@ public class HttpHdmiVideoStreamService : BackgroundService } } - // 获取/创建 HdmiIn 实例 - private async Task GetOrCreateHdmiInAsync(string boardId) + private async Task GetOrCreateClientAsync(string boardId) { - if (_hdmiInDict.TryGetValue(boardId, out var hdmiIn)) - { - 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; - } + if (_clientDict.TryGetValue(boardId, out var client)) return client; - _hdmiInDict[boardId] = hdmiIn; - _hdmiInCtsDict[boardId] = new CancellationTokenSource(); - return hdmiIn; - } + using var scope = _serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService(); - 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}"); @@ -149,18 +138,31 @@ 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() + }; // 启用HDMI传输 try { - var enableResult = await hdmiIn.EnableTrans(true); - if (!enableResult.IsSuccessful) + var hdmiEnableRet = await client.HdmiInClient.EnableTrans(true); + if (!hdmiEnableRet.IsSuccessful) { - logger.Error($"Failed to enable HDMI transmission for board {boardId}: {enableResult.Error}"); + 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 JPEG transmission for board {boardId}: {jpegEnableRet.Error}"); + return null; + } + logger.Info($"Successfully enabled JPEG transmission for board {boardId}"); } catch (Exception ex) { @@ -168,9 +170,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) @@ -183,14 +184,14 @@ 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; + var hdmiInToken = _clientDict[boardId].CTS.Token; if (hdmiInToken == null) { await SendErrorAsync(context.Response, "HDMI input is not available"); @@ -199,11 +200,11 @@ public class HttpHdmiVideoStreamService : BackgroundService 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") { @@ -215,14 +216,15 @@ 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快照请求"); // 从HDMI读取RGB565数据 - var frameResult = await hdmiIn.ReadFrame(); + var frameResult = await client.HdmiInClient.ReadFrame(); if (!frameResult.IsSuccessful || frameResult.Value == null) { logger.Error("HDMI快照获取失败"); @@ -256,7 +258,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 { @@ -276,7 +279,7 @@ public class HttpHdmiVideoStreamService : BackgroundService { var frameStartTime = DateTime.UtcNow; - var ret = await hdmiIn.GetMJpegFrame(); + var ret = await client.HdmiInClient.GetMJpegFrame(); if (ret == null) continue; var frame = ret.Value; @@ -311,7 +314,7 @@ public class HttpHdmiVideoStreamService : BackgroundService try { // 停止传输时禁用HDMI传输 - await hdmiIn.EnableTrans(false); + await client.HdmiInClient.EnableTrans(false); logger.Info("已禁用HDMI传输"); } catch (Exception ex) @@ -366,8 +369,10 @@ public class HttpHdmiVideoStreamService : BackgroundService /// 返回所有可用的HDMI视频流终端点列表 public List? GetAllVideoEndpoints() { - var db = new Database.AppDataConnection(); - var boards = db?.GetAllBoard(); + using var scope = _serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var boards = userManager.GetAllBoard(); if (boards == null) return null; @@ -377,9 +382,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; @@ -395,9 +400,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}" }; } } diff --git a/server/src/Services/HttpVideoStreamService.cs b/server/src/Services/HttpVideoStreamService.cs index f06967e..2a14caf 100644 --- a/server/src/Services/HttpVideoStreamService.cs +++ b/server/src/Services/HttpVideoStreamService.cs @@ -13,6 +13,7 @@ namespace server.Services; public class VideoStreamClient { public string? ClientId { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; public int FrameWidth { get; set; } public int FrameHeight { get; set; } public int FrameRate { get; set; } @@ -35,28 +36,35 @@ public class VideoStreamClient /// /// 表示摄像头连接状态信息 /// -public class VideoEndpoint +public class VideoStreamEndpoint { - public string BoardId { get; set; } = ""; - public string MjpegUrl { get; set; } = ""; - public string VideoUrl { get; set; } = ""; - public string SnapshotUrl { get; set; } = ""; + public required string BoardId { get; set; } = ""; + public required string MjpegUrl { get; set; } = ""; + public required string VideoUrl { get; set; } = ""; + public required string SnapshotUrl { get; set; } = ""; + public required string HtmlUrl { get; set; } = ""; + public required string UsbCameraUrl { get; set; } = ""; + + public required bool IsEnabled { get; set; } /// /// 视频流的帧率(FPS) /// - public int FrameRate { get; set; } + public required int FrameRate { get; set; } + + public int FrameWidth { get; set; } + public int FrameHeight { get; set; } /// /// 视频分辨率(如 640x480) /// - public string Resolution { get; set; } = string.Empty; + public string Resolution => $"{FrameWidth}x{FrameHeight}"; } /// /// 表示视频流服务的运行状态 /// -public class ServiceStatus +public class VideoStreamServiceStatus { /// /// 服务是否正在运行 @@ -71,7 +79,7 @@ public class ServiceStatus /// /// 当前连接的客户端端点列表 /// - public List ClientEndpoints { get; set; } = new(); + public List ClientEndpoints { get; set; } = new(); /// /// 当前连接的客户端数量 @@ -87,6 +95,8 @@ public class HttpVideoStreamService : BackgroundService { private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); + private readonly IServiceProvider _serviceProvider; + private HttpListener? _httpListener; private readonly int _serverPort = 4321; @@ -99,13 +109,60 @@ public class HttpVideoStreamService : BackgroundService private readonly object _usbCameraLock = new object(); #endif + public HttpVideoStreamService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + private Optional TryGetClient(string boardId) + { + if (_clientDict.TryGetValue(boardId, out var client)) + { + return client; + } + return null; + } + + private async Task GetOrCreateClientAsync(string boardId, int initWidth, int initHeight) + { + if (_clientDict.TryGetValue(boardId, out var client)) + { + // 可在此处做分辨率/Camera等配置更新 + return client; + } + + using var scope = _serviceProvider.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService(); + + var boardRet = userManager.GetBoardByID(Guid.Parse(boardId)); + if (!boardRet.IsSuccessful || !boardRet.Value.HasValue) + { + logger.Error($"Failed to get board with ID {boardId}"); + return null; + } + + var board = boardRet.Value.Value; + + var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port); + var ret = await camera.Init(); + if (!ret.IsSuccessful || !ret.Value) + { + logger.Error("Camera Init Failed!"); + return null; + } + + client = new VideoStreamClient(boardId, initWidth, initHeight, camera); + _clientDict[boardId] = client; + return client; + } + /// /// 初始化 HttpVideoStreamService /// public override async Task StartAsync(CancellationToken cancellationToken) { _httpListener = new HttpListener(); - _httpListener.Prefixes.Add($"http://{Global.localhost}:{_serverPort}/"); + _httpListener.Prefixes.Add($"http://{Global.LocalHost}:{_serverPort}/"); _httpListener.Start(); logger.Info($"Video Stream Service started on port {_serverPort}"); @@ -130,53 +187,6 @@ public class HttpVideoStreamService : BackgroundService await base.StopAsync(cancellationToken); } - private Optional TryGetClient(string boardId) - { - if (_clientDict.TryGetValue(boardId, out var client)) - { - return client; - } - return null; - } - - private async Task GetOrCreateClientAsync(string boardId, int initWidth, int initHeight) - { - if (_clientDict.TryGetValue(boardId, out var client)) - { - // 可在此处做分辨率/Camera等配置更新 - return client; - } - - var db = new Database.AppDataConnection(); - if (db == null) - { - logger.Error("Failed to create HdmiIn instance"); - return null; - } - - var boardRet = db.GetBoardByID(Guid.Parse(boardId)); - if (!boardRet.IsSuccessful || !boardRet.Value.HasValue) - { - logger.Error($"Failed to get board with ID {boardId}"); - return null; - } - - var board = boardRet.Value.Value; - - var camera = new Peripherals.CameraClient.Camera(board.IpAddr, board.Port); - var ret = await camera.Init(); - if (!ret.IsSuccessful || !ret.Value) - { - logger.Error("Camera Init Failed!"); - return null; - } - - client = new VideoStreamClient(boardId, initWidth, initHeight, camera); - _clientDict[boardId] = client; - return client; - } - - /// /// 执行 HTTP 视频流服务 /// @@ -254,6 +264,11 @@ public class HttpVideoStreamService : BackgroundService // 单帧图像请求 await HandleSnapshotRequestAsync(context.Response, client, cancellationToken); } + else if (path == "/html") + { + // HTML页面请求 + await SendIndexHtmlPageAsync(context.Response); + } else { // 默认返回简单的HTML页面,提供链接到视频页面 @@ -668,42 +683,12 @@ public class HttpVideoStreamService : BackgroundService } } - public VideoEndpoint GetVideoEndpoint(string boardId) - { - var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}")); - - return new VideoEndpoint - { - 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}", - Resolution = $"{client.FrameWidth}x{client.FrameHeight}", - FrameRate = client.FrameRate - }; - } - - public List GetAllVideoEndpoints() - { - var endpoints = new List(); - - foreach (var boardId in _clientDict.Keys) - endpoints.Add(GetVideoEndpoint(boardId)); - - return endpoints; - } - - public ServiceStatus GetServiceStatus() - { - return new ServiceStatus - { - IsRunning = true, - ServerPort = _serverPort, - ClientEndpoints = GetAllVideoEndpoints() - }; - } - - public async Task DisableHdmiTransmissionAsync(string boardId) + /// + /// 配置摄像头连接参数 + /// + /// 板卡ID + /// 配置是否成功 + public async Task ConfigureCameraAsync(string boardId) { try { @@ -711,8 +696,67 @@ public class HttpVideoStreamService : BackgroundService using (await client.Lock.AcquireWriteLockAsync()) { + var ret = await client.Camera.Init(); + if (!ret.IsSuccessful) + { + logger.Error(ret.Error); + throw ret.Error; + } + + if (!ret.Value) + { + logger.Error($"Camera Init Failed!"); + throw new Exception($"Camera Init Failed!"); + } + } + + using (await client.Lock.AcquireWriteLockAsync()) + { + var ret = await client.Camera.ChangeResolution(client.FrameWidth, client.FrameHeight); + if (!ret.IsSuccessful) + { + logger.Error(ret.Error); + throw ret.Error; + } + + if (!ret.Value) + { + logger.Error($"Camera Resolution Change Failed!"); + throw new Exception($"Camera Resolution Change Failed!"); + } + } + + return true; + } + catch (Exception ex) + { + logger.Error(ex, "配置摄像头连接时发生错误"); + return false; + } + } + + public async Task SetVideoStreamEnableAsync(string boardId, bool enable) + { + try + { + var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}")); + + if (client.IsEnabled == enable) + return; + + using (await client.Lock.AcquireWriteLockAsync()) + { + if (enable) + { + client.CTS = new CancellationTokenSource(); + } + else + { + client.CTS.Cancel(); + } + var camera = client.Camera; - var disableResult = await camera.EnableHardwareTrans(false); + var disableResult = await camera.EnableHardwareTrans(enable); if (disableResult.IsSuccessful && disableResult.Value) logger.Info($"Successfully disabled camera {boardId} hardware transmission"); else @@ -743,4 +787,41 @@ public class HttpVideoStreamService : BackgroundService return false; } } + + public VideoStreamEndpoint GetVideoEndpoint(string boardId) + { + var client = TryGetClient(boardId).OrThrow(() => new Exception($"无法获取摄像头客户端: {boardId}")); + + return new VideoStreamEndpoint + { + 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}", + UsbCameraUrl = $"http://{Global.LocalHost}:{_serverPort}/usbCamera?boardId={boardId}", + HtmlUrl = $"http://{Global.LocalHost}:{_serverPort}/html?boardId={boardId}", + IsEnabled = client.IsEnabled, + FrameRate = client.FrameRate + }; + } + + public List GetAllVideoEndpoints() + { + var endpoints = new List(); + + foreach (var boardId in _clientDict.Keys) + endpoints.Add(GetVideoEndpoint(boardId)); + + return endpoints; + } + + public VideoStreamServiceStatus GetServiceStatus() + { + return new VideoStreamServiceStatus + { + IsRunning = true, + ServerPort = _serverPort, + ClientEndpoints = GetAllVideoEndpoints() + }; + } } diff --git a/server/src/UdpClientPool.cs b/server/src/UdpClientPool.cs index 582d7d9..665109a 100644 --- a/server/src/UdpClientPool.cs +++ b/server/src/UdpClientPool.cs @@ -331,7 +331,9 @@ public class UDPClientPool /// 超时时间(毫秒) /// 校验结果,true表示在超时前数据匹配期望值 public static async ValueTask> 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(); diff --git a/src/APIClient.ts b/src/APIClient.ts index e553bac..38edd7d 100644 --- a/src/APIClient.ts +++ b/src/APIClient.ts @@ -129,8 +129,8 @@ export class VideoStreamClient { * 获取 HTTP 视频流服务状态 * @return 服务状态信息 */ - getStatus( cancelToken?: CancelToken): Promise { - let url_ = this.baseUrl + "/api/VideoStream/Status"; + getServiceStatus( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/VideoStream/ServiceStatus"; url_ = url_.replace(/[?&]$/, ""); let options_: AxiosRequestConfig = { @@ -149,11 +149,11 @@ export class VideoStreamClient { throw _error; } }).then((_response: AxiosResponse) => { - return this.processGetStatus(_response); + return this.processGetServiceStatus(_response); }); } - protected processGetStatus(response: AxiosResponse): Promise { + protected processGetServiceStatus(response: AxiosResponse): Promise { const status = response.status; let _headers: any = {}; if (response.headers && typeof response.headers === "object") { @@ -167,9 +167,8 @@ export class VideoStreamClient { const _responseText = response.data; let result200: any = null; let resultData200 = _responseText; - result200 = resultData200 !== undefined ? resultData200 : null; - - return Promise.resolve(result200); + result200 = VideoStreamServiceStatus.fromJS(resultData200); + return Promise.resolve(result200); } else if (status === 500) { const _responseText = response.data; @@ -182,10 +181,10 @@ export class VideoStreamClient { const _responseText = response.data; return throwException("An unexpected server error occurred.", status, _responseText, _headers); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } - myEndpoint( cancelToken?: CancelToken): Promise { + myEndpoint( cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/VideoStream/MyEndpoint"; url_ = url_.replace(/[?&]$/, ""); @@ -209,7 +208,7 @@ export class VideoStreamClient { }); } - protected processMyEndpoint(response: AxiosResponse): Promise { + protected processMyEndpoint(response: AxiosResponse): Promise { const status = response.status; let _headers: any = {}; if (response.headers && typeof response.headers === "object") { @@ -223,9 +222,8 @@ export class VideoStreamClient { const _responseText = response.data; let result200: any = null; let resultData200 = _responseText; - result200 = resultData200 !== undefined ? resultData200 : null; - - return Promise.resolve(result200); + result200 = VideoStreamEndpoint.fromJS(resultData200); + return Promise.resolve(result200); } else if (status === 500) { const _responseText = response.data; @@ -238,7 +236,7 @@ export class VideoStreamClient { const _responseText = response.data; return throwException("An unexpected server error occurred.", status, _responseText, _headers); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -301,16 +299,19 @@ export class VideoStreamClient { return Promise.resolve(null as any); } - disableHdmiTransmission( cancelToken?: CancelToken): Promise { - let url_ = this.baseUrl + "/api/VideoStream/DisableTransmission"; + setVideoStreamEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/VideoStream/SetVideoStreamEnable?"; + if (enable === null) + throw new Error("The parameter 'enable' cannot be null."); + else if (enable !== undefined) + url_ += "enable=" + encodeURIComponent("" + enable) + "&"; url_ = url_.replace(/[?&]$/, ""); let options_: AxiosRequestConfig = { - responseType: "blob", method: "POST", url: url_, headers: { - "Accept": "application/octet-stream" + "Accept": "application/json" }, cancelToken }; @@ -322,11 +323,11 @@ export class VideoStreamClient { throw _error; } }).then((_response: AxiosResponse) => { - return this.processDisableHdmiTransmission(_response); + return this.processSetVideoStreamEnable(_response); }); } - protected processDisableHdmiTransmission(response: AxiosResponse): Promise { + protected processSetVideoStreamEnable(response: AxiosResponse): Promise { const status = response.status; let _headers: any = {}; if (response.headers && typeof response.headers === "object") { @@ -336,22 +337,27 @@ export class VideoStreamClient { } } } - if (status === 200 || status === 206) { - const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; - let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; - let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; - if (fileName) { - fileName = decodeURIComponent(fileName); - } else { - fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined; - fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; - } - return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + result200 = resultData200 !== undefined ? resultData200 : null; + + return Promise.resolve(result200); + + } else if (status === 500) { + const _responseText = response.data; + let result500: any = null; + let resultData500 = _responseText; + result500 = resultData500 !== undefined ? resultData500 : null; + + return throwException("A server side error occurred.", status, _responseText, _headers, result500); + } else if (status !== 200 && status !== 204) { const _responseText = response.data; return throwException("An unexpected server error occurred.", status, _responseText, _headers); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -432,7 +438,7 @@ export class VideoStreamClient { * 获取支持的分辨率列表 * @return 支持的分辨率列表 */ - getSupportedResolutions( cancelToken?: CancelToken): Promise { + getSupportedResolutions( cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/VideoStream/SupportedResolutions"; url_ = url_.replace(/[?&]$/, ""); @@ -456,7 +462,7 @@ export class VideoStreamClient { }); } - protected processGetSupportedResolutions(response: AxiosResponse): Promise { + protected processGetSupportedResolutions(response: AxiosResponse): Promise { const status = response.status; let _headers: any = {}; if (response.headers && typeof response.headers === "object") { @@ -470,9 +476,15 @@ export class VideoStreamClient { const _responseText = response.data; let result200: any = null; let resultData200 = _responseText; - result200 = resultData200 !== undefined ? resultData200 : null; - - return Promise.resolve(result200); + if (Array.isArray(resultData200)) { + result200 = [] as any; + for (let item of resultData200) + result200!.push(AvailableResolutionsResponse.fromJS(item)); + } + else { + result200 = null; + } + return Promise.resolve(result200); } else if (status === 500) { const _responseText = response.data; @@ -486,7 +498,7 @@ export class VideoStreamClient { const _responseText = response.data; return throwException("An unexpected server error occurred.", status, _responseText, _headers); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -626,6 +638,74 @@ export class VideoStreamClient { } return Promise.resolve(null as any); } + + /** + * 配置摄像头连接参数 + * @return 配置结果 + */ + configureCamera( cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/VideoStream/ConfigureCamera"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + method: "POST", + url: url_, + headers: { + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processConfigureCamera(_response); + }); + } + + protected processConfigureCamera(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + result200 = resultData200 !== undefined ? resultData200 : null; + + return Promise.resolve(result200); + + } else if (status === 400) { + const _responseText = response.data; + let result400: any = null; + let resultData400 = _responseText; + result400 = resultData400 !== undefined ? resultData400 : null; + + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + + } else if (status === 500) { + const _responseText = response.data; + let result500: any = null; + let resultData500 = _responseText; + result500 = Exception.fromJS(resultData500); + return throwException("A server side error occurred.", status, _responseText, _headers, result500); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } } export class BsdlParserClient { @@ -2429,7 +2509,7 @@ export class ExamClient { * 获取所有实验列表 * @return 实验列表 */ - getExamList( cancelToken?: CancelToken): Promise { + getExamList( cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/Exam/list"; url_ = url_.replace(/[?&]$/, ""); @@ -2453,7 +2533,7 @@ export class ExamClient { }); } - protected processGetExamList(response: AxiosResponse): Promise { + protected processGetExamList(response: AxiosResponse): Promise { const status = response.status; let _headers: any = {}; if (response.headers && typeof response.headers === "object") { @@ -2470,12 +2550,12 @@ export class ExamClient { if (Array.isArray(resultData200)) { result200 = [] as any; for (let item of resultData200) - result200!.push(ExamSummary.fromJS(item)); + result200!.push(ExamInfo.fromJS(item)); } else { result200 = null; } - return Promise.resolve(result200); + return Promise.resolve(result200); } else if (status === 401) { const _responseText = response.data; @@ -2492,7 +2572,7 @@ export class ExamClient { const _responseText = response.data; return throwException("An unexpected server error occurred.", status, _responseText, _headers); } - return Promise.resolve(null as any); + return Promise.resolve(null as any); } /** @@ -2581,8 +2661,8 @@ export class ExamClient { * @param request 创建实验请求 * @return 创建结果 */ - createExam(request: CreateExamRequest, cancelToken?: CancelToken): Promise { - let url_ = this.baseUrl + "/api/Exam"; + createExam(request: ExamDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/Exam/create"; url_ = url_.replace(/[?&]$/, ""); const content_ = JSON.stringify(request); @@ -2664,6 +2744,356 @@ export class ExamClient { } return Promise.resolve(null as any); } + + /** + * 更新实验信息 + * @param request 更新实验请求 + * @return 更新结果 + */ + updateExam(request: ExamDto, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/Exam/update"; + url_ = url_.replace(/[?&]$/, ""); + + const content_ = JSON.stringify(request); + + let options_: AxiosRequestConfig = { + data: content_, + method: "POST", + url: url_, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processUpdateExam(_response); + }); + } + + protected processUpdateExam(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + result200 = ExamInfo.fromJS(resultData200); + return Promise.resolve(result200); + + } else if (status === 400) { + const _responseText = response.data; + let result400: any = null; + let resultData400 = _responseText; + result400 = ProblemDetails.fromJS(resultData400); + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + + } else if (status === 401) { + const _responseText = response.data; + let result401: any = null; + let resultData401 = _responseText; + result401 = ProblemDetails.fromJS(resultData401); + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + + } else if (status === 403) { + const _responseText = response.data; + let result403: any = null; + let resultData403 = _responseText; + result403 = ProblemDetails.fromJS(resultData403); + return throwException("A server side error occurred.", status, _responseText, _headers, result403); + + } else if (status === 404) { + const _responseText = response.data; + let result404: any = null; + let resultData404 = _responseText; + result404 = ProblemDetails.fromJS(resultData404); + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + + } else if (status === 500) { + const _responseText = response.data; + return throwException("A server side error occurred.", status, _responseText, _headers); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + /** + * 提交作业 + * @param examId 实验ID + * @param file (optional) 提交的文件 + * @return 提交结果 + */ + submitCommit(examId: string, file: FileParameter | undefined, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/Exam/commit/{examId}"; + if (examId === undefined || examId === null) + throw new Error("The parameter 'examId' must be defined."); + url_ = url_.replace("{examId}", encodeURIComponent("" + examId)); + url_ = url_.replace(/[?&]$/, ""); + + const content_ = new FormData(); + if (file === null || file === undefined) + throw new Error("The parameter 'file' cannot be null."); + else + content_.append("file", file.data, file.fileName ? file.fileName : "file"); + + let options_: AxiosRequestConfig = { + data: content_, + method: "POST", + url: url_, + headers: { + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processSubmitCommit(_response); + }); + } + + protected processSubmitCommit(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 201) { + const _responseText = response.data; + let result201: any = null; + let resultData201 = _responseText; + result201 = Commit.fromJS(resultData201); + return Promise.resolve(result201); + + } else if (status === 400) { + const _responseText = response.data; + let result400: any = null; + let resultData400 = _responseText; + result400 = ProblemDetails.fromJS(resultData400); + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + + } else if (status === 401) { + const _responseText = response.data; + let result401: any = null; + let resultData401 = _responseText; + result401 = ProblemDetails.fromJS(resultData401); + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + + } else if (status === 404) { + const _responseText = response.data; + let result404: any = null; + let resultData404 = _responseText; + result404 = ProblemDetails.fromJS(resultData404); + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + + } else if (status === 500) { + const _responseText = response.data; + return throwException("A server side error occurred.", status, _responseText, _headers); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + /** + * 获取用户在指定实验中的提交记录 + * @param examId 实验ID + * @return 提交记录列表 + */ + getCommitsByExamId(examId: string, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/Exam/commits/{examId}"; + if (examId === undefined || examId === null) + throw new Error("The parameter 'examId' must be defined."); + url_ = url_.replace("{examId}", encodeURIComponent("" + examId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + method: "GET", + url: url_, + headers: { + "Accept": "application/json" + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processGetCommitsByExamId(_response); + }); + } + + protected processGetCommitsByExamId(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + let result200: any = null; + let resultData200 = _responseText; + if (Array.isArray(resultData200)) { + result200 = [] as any; + for (let item of resultData200) + result200!.push(Commit.fromJS(item)); + } + else { + result200 = null; + } + return Promise.resolve(result200); + + } else if (status === 400) { + const _responseText = response.data; + let result400: any = null; + let resultData400 = _responseText; + result400 = ProblemDetails.fromJS(resultData400); + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + + } else if (status === 401) { + const _responseText = response.data; + let result401: any = null; + let resultData401 = _responseText; + result401 = ProblemDetails.fromJS(resultData401); + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + + } else if (status === 404) { + const _responseText = response.data; + let result404: any = null; + let resultData404 = _responseText; + result404 = ProblemDetails.fromJS(resultData404); + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + + } else if (status === 500) { + const _responseText = response.data; + return throwException("A server side error occurred.", status, _responseText, _headers); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } + + /** + * 删除提交记录 + * @param commitId 提交记录ID + * @return 删除结果 + */ + deleteCommit(commitId: string, cancelToken?: CancelToken): Promise { + let url_ = this.baseUrl + "/api/Exam/commit/{commitId}"; + if (commitId === undefined || commitId === null) + throw new Error("The parameter 'commitId' must be defined."); + url_ = url_.replace("{commitId}", encodeURIComponent("" + commitId)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: AxiosRequestConfig = { + method: "DELETE", + url: url_, + headers: { + }, + cancelToken + }; + + return this.instance.request(options_).catch((_error: any) => { + if (isAxiosError(_error) && _error.response) { + return _error.response; + } else { + throw _error; + } + }).then((_response: AxiosResponse) => { + return this.processDeleteCommit(_response); + }); + } + + protected processDeleteCommit(response: AxiosResponse): Promise { + const status = response.status; + let _headers: any = {}; + if (response.headers && typeof response.headers === "object") { + for (const k in response.headers) { + if (response.headers.hasOwnProperty(k)) { + _headers[k] = response.headers[k]; + } + } + } + if (status === 200) { + const _responseText = response.data; + return Promise.resolve(null as any); + + } else if (status === 400) { + const _responseText = response.data; + let result400: any = null; + let resultData400 = _responseText; + result400 = ProblemDetails.fromJS(resultData400); + return throwException("A server side error occurred.", status, _responseText, _headers, result400); + + } else if (status === 401) { + const _responseText = response.data; + let result401: any = null; + let resultData401 = _responseText; + result401 = ProblemDetails.fromJS(resultData401); + return throwException("A server side error occurred.", status, _responseText, _headers, result401); + + } else if (status === 403) { + const _responseText = response.data; + let result403: any = null; + let resultData403 = _responseText; + result403 = ProblemDetails.fromJS(resultData403); + return throwException("A server side error occurred.", status, _responseText, _headers, result403); + + } else if (status === 404) { + const _responseText = response.data; + let result404: any = null; + let resultData404 = _responseText; + result404 = ProblemDetails.fromJS(resultData404); + return throwException("A server side error occurred.", status, _responseText, _headers, result404); + + } else if (status === 500) { + const _responseText = response.data; + return throwException("A server side error occurred.", status, _responseText, _headers); + + } else if (status !== 200 && status !== 204) { + const _responseText = response.data; + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + } + return Promise.resolve(null as any); + } } export class HdmiVideoStreamClient { @@ -3055,7 +3485,7 @@ export class JtagClient { * @param bitstreamId (optional) 比特流ID * @return 进度跟踪TaskID */ - downloadBitstream(address: string | undefined, port: number | undefined, bitstreamId: number | undefined, cancelToken?: CancelToken): Promise { + downloadBitstream(address: string | undefined, port: number | undefined, bitstreamId: string | undefined, cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/Jtag/DownloadBitstream?"; if (address === null) throw new Error("The parameter 'address' cannot be null."); @@ -6306,7 +6736,7 @@ export class ResourceClient { * @param resourceId 资源ID * @return 资源文件 */ - getResourceById(resourceId: number, cancelToken?: CancelToken): Promise { + getResourceById(resourceId: string, cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/Resource/{resourceId}"; if (resourceId === undefined || resourceId === null) throw new Error("The parameter 'resourceId' must be defined."); @@ -6385,7 +6815,7 @@ export class ResourceClient { * @param resourceId 资源ID * @return 删除结果 */ - deleteResource(resourceId: number, cancelToken?: CancelToken): Promise { + deleteResource(resourceId: string, cancelToken?: CancelToken): Promise { let url_ = this.baseUrl + "/api/Resource/{resourceId}"; if (resourceId === undefined || resourceId === null) throw new Error("The parameter 'resourceId' must be defined."); @@ -6927,6 +7357,157 @@ export class UDPClient { } } +/** 表示视频流服务的运行状态 */ +export class VideoStreamServiceStatus implements IVideoStreamServiceStatus { + /** 服务是否正在运行 */ + isRunning!: boolean; + /** 服务监听的端口号 */ + serverPort!: number; + /** 当前连接的客户端端点列表 */ + clientEndpoints!: VideoStreamEndpoint[]; + /** 当前连接的客户端数量 */ + connectedClientsNum!: number; + + constructor(data?: IVideoStreamServiceStatus) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) + (this)[property] = (data)[property]; + } + } + if (!data) { + this.clientEndpoints = []; + } + } + + init(_data?: any) { + if (_data) { + this.isRunning = _data["isRunning"]; + this.serverPort = _data["serverPort"]; + if (Array.isArray(_data["clientEndpoints"])) { + this.clientEndpoints = [] as any; + for (let item of _data["clientEndpoints"]) + this.clientEndpoints!.push(VideoStreamEndpoint.fromJS(item)); + } + this.connectedClientsNum = _data["connectedClientsNum"]; + } + } + + static fromJS(data: any): VideoStreamServiceStatus { + data = typeof data === 'object' ? data : {}; + let result = new VideoStreamServiceStatus(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + data["isRunning"] = this.isRunning; + data["serverPort"] = this.serverPort; + if (Array.isArray(this.clientEndpoints)) { + data["clientEndpoints"] = []; + for (let item of this.clientEndpoints) + data["clientEndpoints"].push(item ? item.toJSON() : undefined); + } + data["connectedClientsNum"] = this.connectedClientsNum; + return data; + } +} + +/** 表示视频流服务的运行状态 */ +export interface IVideoStreamServiceStatus { + /** 服务是否正在运行 */ + isRunning: boolean; + /** 服务监听的端口号 */ + serverPort: number; + /** 当前连接的客户端端点列表 */ + clientEndpoints: VideoStreamEndpoint[]; + /** 当前连接的客户端数量 */ + connectedClientsNum: number; +} + +/** 表示摄像头连接状态信息 */ +export class VideoStreamEndpoint implements IVideoStreamEndpoint { + boardId!: string; + mjpegUrl!: string; + videoUrl!: string; + snapshotUrl!: string; + htmlUrl!: string; + usbCameraUrl!: string; + isEnabled!: boolean; + /** 视频流的帧率(FPS) */ + frameRate!: number; + frameWidth!: number; + frameHeight!: number; + /** 视频分辨率(如 640x480) */ + resolution!: string; + + constructor(data?: IVideoStreamEndpoint) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) + (this)[property] = (data)[property]; + } + } + } + + init(_data?: any) { + if (_data) { + this.boardId = _data["boardId"]; + this.mjpegUrl = _data["mjpegUrl"]; + this.videoUrl = _data["videoUrl"]; + this.snapshotUrl = _data["snapshotUrl"]; + this.htmlUrl = _data["htmlUrl"]; + this.usbCameraUrl = _data["usbCameraUrl"]; + this.isEnabled = _data["isEnabled"]; + this.frameRate = _data["frameRate"]; + this.frameWidth = _data["frameWidth"]; + this.frameHeight = _data["frameHeight"]; + this.resolution = _data["resolution"]; + } + } + + static fromJS(data: any): VideoStreamEndpoint { + data = typeof data === 'object' ? data : {}; + let result = new VideoStreamEndpoint(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + data["boardId"] = this.boardId; + data["mjpegUrl"] = this.mjpegUrl; + data["videoUrl"] = this.videoUrl; + data["snapshotUrl"] = this.snapshotUrl; + data["htmlUrl"] = this.htmlUrl; + data["usbCameraUrl"] = this.usbCameraUrl; + data["isEnabled"] = this.isEnabled; + data["frameRate"] = this.frameRate; + data["frameWidth"] = this.frameWidth; + data["frameHeight"] = this.frameHeight; + data["resolution"] = this.resolution; + return data; + } +} + +/** 表示摄像头连接状态信息 */ +export interface IVideoStreamEndpoint { + boardId: string; + mjpegUrl: string; + videoUrl: string; + snapshotUrl: string; + htmlUrl: string; + usbCameraUrl: string; + isEnabled: boolean; + /** 视频流的帧率(FPS) */ + frameRate: number; + frameWidth: number; + frameHeight: number; + /** 视频分辨率(如 640x480) */ + resolution: string; +} + export class Exception implements IException { declare message: string; innerException?: Exception | undefined; @@ -7021,6 +7602,54 @@ export interface IResolutionConfigRequest { height: number; } +export class AvailableResolutionsResponse implements IAvailableResolutionsResponse { + width!: number; + height!: number; + name!: string; + value!: string; + + constructor(data?: IAvailableResolutionsResponse) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) + (this)[property] = (data)[property]; + } + } + } + + init(_data?: any) { + if (_data) { + this.width = _data["width"]; + this.height = _data["height"]; + this.name = _data["name"]; + this.value = _data["value"]; + } + } + + static fromJS(data: any): AvailableResolutionsResponse { + data = typeof data === 'object' ? data : {}; + let result = new AvailableResolutionsResponse(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + data["width"] = this.width; + data["height"] = this.height; + data["name"] = this.name; + data["value"] = this.value; + return data; + } +} + +export interface IAvailableResolutionsResponse { + width: number; + height: number; + name: string; + value: string; +} + export class ProblemDetails implements IProblemDetails { type?: string | undefined; title?: string | undefined; @@ -7527,94 +8156,7 @@ export interface IChannelCaptureData { data: string; } -/** 实验简要信息类(用于列表显示) */ -export class ExamSummary implements IExamSummary { - /** 实验的唯一标识符 */ - id!: string; - /** 实验名称 */ - name!: string; - /** 实验创建时间 */ - createdTime!: Date; - /** 实验最后更新时间 */ - updatedTime!: Date; - /** 实验标签 */ - tags!: string[]; - /** 实验难度(1-5) */ - difficulty!: number; - /** 普通用户是否可见 */ - isVisibleToUsers!: boolean; - - constructor(data?: IExamSummary) { - if (data) { - for (var property in data) { - if (data.hasOwnProperty(property)) - (this)[property] = (data)[property]; - } - } - if (!data) { - this.tags = []; - } - } - - init(_data?: any) { - if (_data) { - this.id = _data["id"]; - this.name = _data["name"]; - this.createdTime = _data["createdTime"] ? new Date(_data["createdTime"].toString()) : undefined; - this.updatedTime = _data["updatedTime"] ? new Date(_data["updatedTime"].toString()) : undefined; - if (Array.isArray(_data["tags"])) { - this.tags = [] as any; - for (let item of _data["tags"]) - this.tags!.push(item); - } - this.difficulty = _data["difficulty"]; - this.isVisibleToUsers = _data["isVisibleToUsers"]; - } - } - - static fromJS(data: any): ExamSummary { - data = typeof data === 'object' ? data : {}; - let result = new ExamSummary(); - result.init(data); - return result; - } - - toJSON(data?: any) { - data = typeof data === 'object' ? data : {}; - data["id"] = this.id; - data["name"] = this.name; - data["createdTime"] = this.createdTime ? this.createdTime.toISOString() : undefined; - data["updatedTime"] = this.updatedTime ? this.updatedTime.toISOString() : undefined; - if (Array.isArray(this.tags)) { - data["tags"] = []; - for (let item of this.tags) - data["tags"].push(item); - } - data["difficulty"] = this.difficulty; - data["isVisibleToUsers"] = this.isVisibleToUsers; - return data; - } -} - -/** 实验简要信息类(用于列表显示) */ -export interface IExamSummary { - /** 实验的唯一标识符 */ - id: string; - /** 实验名称 */ - name: string; - /** 实验创建时间 */ - createdTime: Date; - /** 实验最后更新时间 */ - updatedTime: Date; - /** 实验标签 */ - tags: string[]; - /** 实验难度(1-5) */ - difficulty: number; - /** 普通用户是否可见 */ - isVisibleToUsers: boolean; -} - -/** 实验信息类 */ +/** 实验信息 */ export class ExamInfo implements IExamInfo { /** 实验的唯一标识符 */ id!: string; @@ -7687,7 +8229,7 @@ export class ExamInfo implements IExamInfo { } } -/** 实验信息类 */ +/** 实验信息 */ export interface IExamInfo { /** 实验的唯一标识符 */ id: string; @@ -7707,9 +8249,9 @@ export interface IExamInfo { isVisibleToUsers: boolean; } -/** 创建实验请求类 */ -export class CreateExamRequest implements ICreateExamRequest { - /** 实验ID */ +/** 统一的实验数据传输对象 */ +export class ExamDto implements IExamDto { + /** 实验的唯一标识符 */ id!: string; /** 实验名称 */ name!: string; @@ -7722,7 +8264,7 @@ export class CreateExamRequest implements ICreateExamRequest { /** 普通用户是否可见 */ isVisibleToUsers!: boolean; - constructor(data?: ICreateExamRequest) { + constructor(data?: IExamDto) { if (data) { for (var property in data) { if (data.hasOwnProperty(property)) @@ -7749,9 +8291,9 @@ export class CreateExamRequest implements ICreateExamRequest { } } - static fromJS(data: any): CreateExamRequest { + static fromJS(data: any): ExamDto { data = typeof data === 'object' ? data : {}; - let result = new CreateExamRequest(); + let result = new ExamDto(); result.init(data); return result; } @@ -7772,9 +8314,9 @@ export class CreateExamRequest implements ICreateExamRequest { } } -/** 创建实验请求类 */ -export interface ICreateExamRequest { - /** 实验ID */ +/** 统一的实验数据传输对象 */ +export interface IExamDto { + /** 实验的唯一标识符 */ id: string; /** 实验名称 */ name: string; @@ -7788,6 +8330,74 @@ export interface ICreateExamRequest { isVisibleToUsers: boolean; } +export class Commit implements ICommit { + /** 资源的唯一标识符 */ + id!: string; + /** 上传资源的用户ID */ + userID!: string; + /** 所属实验ID */ + examID?: string | undefined; + type!: CommitType; + resourceID!: string; + createdAt!: Date; + + constructor(data?: ICommit) { + if (data) { + for (var property in data) { + if (data.hasOwnProperty(property)) + (this)[property] = (data)[property]; + } + } + } + + init(_data?: any) { + if (_data) { + this.id = _data["id"]; + this.userID = _data["userID"]; + this.examID = _data["examID"]; + this.type = _data["type"]; + this.resourceID = _data["resourceID"]; + this.createdAt = _data["createdAt"] ? new Date(_data["createdAt"].toString()) : undefined; + } + } + + static fromJS(data: any): Commit { + data = typeof data === 'object' ? data : {}; + let result = new Commit(); + result.init(data); + return result; + } + + toJSON(data?: any) { + data = typeof data === 'object' ? data : {}; + data["id"] = this.id; + data["userID"] = this.userID; + data["examID"] = this.examID; + data["type"] = this.type; + data["resourceID"] = this.resourceID; + data["createdAt"] = this.createdAt ? this.createdAt.toISOString() : undefined; + return data; + } +} + +export interface ICommit { + /** 资源的唯一标识符 */ + id: string; + /** 上传资源的用户ID */ + userID: string; + /** 所属实验ID */ + examID?: string | undefined; + type: CommitType; + resourceID: string; + createdAt: Date; +} + +export enum CommitType { + Homework = 0, + Project = 1, + Markdown = 2, +} + export class HdmiVideoStreamEndpoint implements IHdmiVideoStreamEndpoint { boardId!: string; mjpegUrl!: string; @@ -8306,7 +8916,7 @@ export interface IOscilloscopeDataResponse { /** 资源信息类 */ export class ResourceInfo implements IResourceInfo { /** 资源ID */ - id!: number; + id!: string; /** 资源名称 */ name!: string; /** 资源类型 */ @@ -8364,7 +8974,7 @@ export class ResourceInfo implements IResourceInfo { /** 资源信息类 */ export interface IResourceInfo { /** 资源ID */ - id: number; + id: string; /** 资源名称 */ name: string; /** 资源类型 */ diff --git a/src/App.vue b/src/App.vue index 7d50002..5ff88a9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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(); }; diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue new file mode 100644 index 0000000..cbc0396 --- /dev/null +++ b/src/components/MarkdownEditor.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue index 2eacc9d..89730c9 100644 --- a/src/components/Navbar.vue +++ b/src/components/Navbar.vue @@ -44,7 +44,7 @@
  • - + Markdown测试 diff --git a/src/components/TutorialCarousel.vue b/src/components/TutorialCarousel.vue index 83511b7..9bc01e0 100644 --- a/src/components/TutorialCarousel.vue +++ b/src/components/TutorialCarousel.vue @@ -1,325 +1,356 @@ - - - - - + + + + + diff --git a/src/components/UploadCard.vue b/src/components/UploadCard.vue index a2207b9..7853981 100644 --- a/src/components/UploadCard.vue +++ b/src/components/UploadCard.vue @@ -87,9 +87,12 @@ 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"; +} from "@/utils/signalR/TypedSignalR.Client/server.Hubs"; +import { + getHubProxyFactory, + getReceiverRegister, +} from "@/utils/signalR/TypedSignalR.Client"; +import { ProgressStatus } from "@/utils/signalR/server.Hubs"; import { useRequiredInjection } from "@/utils/Common"; import { useAlertStore } from "./Alert"; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ddd0173 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +import "./assets/main.css"; + +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"); diff --git a/src/router/index.ts b/src/router/index.ts index 56158e0..40601b9 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 }, ], }); diff --git a/src/stores/equipments.ts b/src/stores/equipments.ts index 225d17c..b236fbc 100644 --- a/src/stores/equipments.ts +++ b/src/stores/equipments.ts @@ -10,9 +10,12 @@ import { useDialogStore } from "./dialog"; import { toFileParameterOrUndefined } from "@/utils/Common"; import { AuthManager } from "@/utils/AuthManager"; import { HubConnection, HubConnectionBuilder } from "@microsoft/signalr"; -import { getHubProxyFactory, getReceiverRegister } from "@/TypedSignalR.Client"; +import { + getHubProxyFactory, + getReceiverRegister, +} from "@/utils/signalR/TypedSignalR.Client"; import type { ResourceInfo } from "@/APIClient"; -import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs"; +import type { IJtagHub } from "@/utils/signalR/TypedSignalR.Client/server.Hubs"; export const useEquipments = defineStore("equipments", () => { // Global Stores @@ -126,7 +129,7 @@ export const useEquipments = defineStore("equipments", () => { async function jtagUploadBitstream( bitstream: File, examId?: string, - ): Promise { + ): Promise { try { // 自动开启电源 await powerSetOnOff(true); @@ -152,7 +155,7 @@ export const useEquipments = defineStore("equipments", () => { } } - async function jtagDownloadBitstream(bitstreamId?: number): Promise { + async function jtagDownloadBitstream(bitstreamId?: string): Promise { if (bitstreamId === null || bitstreamId === undefined) { dialog.error("请先选择要下载的比特流"); return ""; diff --git a/src/stores/theme.ts b/src/stores/theme.ts index c4d93db..39871c4 100644 --- a/src/stores/theme.ts +++ b/src/stores/theme.ts @@ -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, + }; +}); diff --git a/src/utils/AuthManager.ts b/src/utils/AuthManager.ts index 50c47b8..c2f8544 100644 --- a/src/utils/AuthManager.ts +++ b/src/utils/AuthManager.ts @@ -212,30 +212,18 @@ export class AuthManager { } public static createAuthenticatedJtagHubConnection() { - 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/JtagHub", { - accessTokenFactory: () => token, + 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, + accessTokenFactory: () => this.getToken() ?? "", }) .withAutomaticReconnect() .build(); diff --git a/src/utils/Common.ts b/src/utils/Common.ts index 922e843..a8ff5cd 100644 --- a/src/utils/Common.ts +++ b/src/utils/Common.ts @@ -48,3 +48,14 @@ export function useOptionalInjection( 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", + }); +} diff --git a/src/TypedSignalR.Client/index.ts b/src/utils/signalR/TypedSignalR.Client/index.ts similarity index 100% rename from src/TypedSignalR.Client/index.ts rename to src/utils/signalR/TypedSignalR.Client/index.ts diff --git a/src/TypedSignalR.Client/server.Hubs.ts b/src/utils/signalR/TypedSignalR.Client/server.Hubs.ts similarity index 100% rename from src/TypedSignalR.Client/server.Hubs.ts rename to src/utils/signalR/TypedSignalR.Client/server.Hubs.ts diff --git a/src/server.Hubs.ts b/src/utils/signalR/server.Hubs.ts similarity index 100% rename from src/server.Hubs.ts rename to src/utils/signalR/server.Hubs.ts diff --git a/src/views/AuthView.vue b/src/views/AuthView.vue index 69f024c..0c64764 100644 --- a/src/views/AuthView.vue +++ b/src/views/AuthView.vue @@ -2,7 +2,10 @@
    -
    +

    用户登录

    @@ -44,7 +47,10 @@
    -
    +

    用户注册

    @@ -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,13 +233,13 @@ 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) { // 注册成功 alertStore?.show("注册成功!请登录", "success", 2000); - + // 延迟后返回登录页面 setTimeout(() => { backToLogin(); @@ -271,7 +277,7 @@ const checkExistingToken = async () => { const isValid = await AuthManager.verifyToken(); if (isValid) { // 如果token仍然有效,直接跳转到project页面 - await router.push("/project"); + router.go(-1); } } catch (error) { // token无效或验证失败,继续显示登录页面 diff --git a/src/views/Exam/ExamCard.vue b/src/views/Exam/ExamCard.vue new file mode 100644 index 0000000..e69de29 diff --git a/src/views/Exam/ExamEditModal.vue b/src/views/Exam/ExamEditModal.vue new file mode 100644 index 0000000..2501c79 --- /dev/null +++ b/src/views/Exam/ExamEditModal.vue @@ -0,0 +1,776 @@ + + + + + diff --git a/src/views/Exam/ExamInfoModal.vue b/src/views/Exam/ExamInfoModal.vue new file mode 100644 index 0000000..77569eb --- /dev/null +++ b/src/views/Exam/ExamInfoModal.vue @@ -0,0 +1,352 @@ + + + + diff --git a/src/views/Exam/Index.vue b/src/views/Exam/Index.vue new file mode 100644 index 0000000..93142bd --- /dev/null +++ b/src/views/Exam/Index.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/src/views/ExamView.vue b/src/views/ExamView.vue deleted file mode 100644 index 9e56614..0000000 --- a/src/views/ExamView.vue +++ /dev/null @@ -1,1069 +0,0 @@ - - - - - diff --git a/src/views/Project/VideoStream.vue b/src/views/Project/VideoStream.vue index a444871..007ae24 100644 --- a/src/views/Project/VideoStream.vue +++ b/src/views/Project/VideoStream.vue @@ -8,20 +8,31 @@ 控制面板 -
    +
    -
    - {{ statusInfo.isRunning ? "运行中" : "已停止" }} +
    + {{ videoStreamInfo.isRunning ? "运行中" : "已停止" }}
    服务状态
    HTTP
    -
    端口: {{ statusInfo.serverPort }}
    +
    + 端口: {{ videoStreamInfo.serverPort }} +
    @@ -33,9 +44,11 @@
    视频规格
    - {{ streamInfo.frameWidth }}×{{ streamInfo.frameHeight }} + {{ videoStreamInfo.frameWidth }}×{{ + videoStreamInfo.frameHeight + }}
    -
    {{ streamInfo.frameRate }} FPS
    +
    {{ videoStreamInfo.frameRate }} FPS
    @@ -47,17 +60,31 @@
    分辨率设置
    - +
    -
    @@ -72,22 +99,34 @@
    连接数
    - {{ statusInfo.connectedClients }} + {{ videoStreamInfo.connectedClients }}