Compare commits
16 Commits
d27b5d7737
...
dpp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51b39cee07 | ||
|
|
0bd1ad8a0e | ||
|
|
f2c7c78b64 | ||
|
|
2f23ffe482 | ||
|
|
9904fecbee | ||
| cb229c2a30 | |||
|
|
e5f2be616c | ||
|
|
2e9e378457 | ||
|
|
9fe0ee959f | ||
| 9adc5295f8 | |||
|
|
8047987935 | ||
|
|
2d77706013 | ||
|
|
c564844673 | ||
| 2adeca3b99 | |||
| bafd06162c | |||
| 8c404d4072 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,7 +28,6 @@ DebuggerCmd.md
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
48
FPGAWebLabServer.sln
Normal file
48
FPGAWebLabServer.sln
Normal file
@@ -0,0 +1,48 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "server", "server\server.csproj", "{F31D6A0D-0407-41CE-A67E-01B847488EFB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "server.test", "server.test\server.test.csproj", "{CC274582-AC3C-4FD1-977C-96F1BC2760BD}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F31D6A0D-0407-41CE-A67E-01B847488EFB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CC274582-AC3C-4FD1-977C-96F1BC2760BD}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -7,7 +7,7 @@
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
|
||||
pkgs = import nixpkgs {
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.permittedInsecurePackages = ["dotnet-sdk-6.0.428"];
|
||||
};
|
||||
@@ -16,7 +16,7 @@
|
||||
{
|
||||
devShells = forEachSupportedSystem ({ pkgs }: {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
packages = with pkgs; [
|
||||
# Frontend
|
||||
nodejs
|
||||
sqlite
|
||||
@@ -39,10 +39,10 @@
|
||||
typescript-language-server
|
||||
];
|
||||
shellHook = ''
|
||||
export PATH=$PATH:
|
||||
export PATH=$PATH:/home/sikongjueluo/.dotnet/tools
|
||||
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:${pkgs.zlib}/lib
|
||||
export DOTNET_ROOT=${pkgs.dotnetCorePackages.sdk_9_0}/share/dotnet
|
||||
'';
|
||||
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
215
package-lock.json
generated
215
package-lock.json
generated
@@ -8,9 +8,11 @@
|
||||
"name": "fpga-weblab",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^9.0.6",
|
||||
"@svgdotjs/svg.js": "^3.2.4",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/signalr": "^2.4.3",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.11.0",
|
||||
@@ -1128,6 +1130,39 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz",
|
||||
"integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
"fetch-cookie": "^2.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"ws": "^7.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -1845,6 +1880,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jquery": {
|
||||
"version": "3.5.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.32.tgz",
|
||||
"integrity": "sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/sizzle": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||
@@ -1861,6 +1905,21 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/signalr": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/signalr/-/signalr-2.4.3.tgz",
|
||||
"integrity": "sha512-W6C1wMRIIhJV9nsw19yhw4h9zlkLnJzsu9dYlH35aHUQblPsDF6UpCcAVu4Ljy4RS3c3uJyV88wf2M2SOWqqZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jquery": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sizzle": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.9.tgz",
|
||||
"integrity": "sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -2257,6 +2316,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -2988,6 +3059,24 @@
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "9.5.2",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.5.2.tgz",
|
||||
@@ -3061,6 +3150,16 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-cookie": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"set-cookie-parser": "^2.4.8",
|
||||
"tough-cookie": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -4475,6 +4574,27 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
|
||||
@@ -4492,6 +4612,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/read-package-json-fast": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-4.0.0.tgz",
|
||||
@@ -4577,6 +4703,12 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -4662,6 +4794,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4832,6 +4970,36 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie/node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ts-log": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.7.tgz",
|
||||
@@ -5073,6 +5241,16 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
@@ -5392,6 +5570,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
@@ -5399,6 +5583,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||
@@ -5415,6 +5609,27 @@
|
||||
"node": "^18.17.0 || >=20.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -12,9 +12,11 @@
|
||||
"gen-api": "npx tsx scripts/GenerateWebAPI.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^9.0.6",
|
||||
"@svgdotjs/svg.js": "^3.2.4",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/signalr": "^2.4.3",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.11.0",
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
import { spawn, exec, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fetch from 'node-fetch';
|
||||
import * as fs from 'fs';
|
||||
import { spawn, exec, ChildProcess } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fetch from "node-fetch";
|
||||
import * as fs from "fs";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Windows 支持函数
|
||||
function getCommand(command: string): string {
|
||||
// dotnet 在 Windows 上不需要 .cmd 后缀
|
||||
if (command === 'dotnet') {
|
||||
return 'dotnet';
|
||||
if (command === "dotnet") {
|
||||
return "dotnet";
|
||||
}
|
||||
return process.platform === 'win32' ? `${command}.cmd` : command;
|
||||
return process.platform === "win32" ? `${command}.cmd` : command;
|
||||
}
|
||||
|
||||
function getSpawnOptions() {
|
||||
return process.platform === 'win32' ? { stdio: 'pipe', shell: true } : { stdio: 'pipe' };
|
||||
return process.platform === "win32"
|
||||
? { stdio: "pipe", shell: true }
|
||||
: { stdio: "pipe" };
|
||||
}
|
||||
|
||||
async function waitForServer(url: string, maxRetries: number = 30, interval: number = 1000): Promise<boolean> {
|
||||
async function waitForServer(
|
||||
url: string,
|
||||
maxRetries: number = 30,
|
||||
interval: number = 1000,
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
console.log('✓ Server is ready');
|
||||
console.log("✓ Server is ready");
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Server not ready yet
|
||||
}
|
||||
console.log(`Waiting for server... (${i + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -40,32 +46,39 @@ let serverProcess: ChildProcess | null = null;
|
||||
let webProcess: ChildProcess | null = null;
|
||||
|
||||
async function startWeb(): Promise<ChildProcess> {
|
||||
console.log('Starting Vite frontend...');
|
||||
console.log("Starting Vite frontend...");
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(getCommand('npm'), ['run', 'dev'], getSpawnOptions() as any);
|
||||
const process = spawn(
|
||||
getCommand("npm"),
|
||||
["run", "dev"],
|
||||
getSpawnOptions() as any,
|
||||
);
|
||||
|
||||
let webStarted = false;
|
||||
|
||||
process.stdout?.on('data', (data) => {
|
||||
process.stdout?.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
console.log(`Web: ${output}`);
|
||||
|
||||
|
||||
// 检查 Vite 是否已启动
|
||||
if ((output.includes('Local:') || output.includes('ready in')) && !webStarted) {
|
||||
if (
|
||||
(output.includes("Local:") || output.includes("ready in")) &&
|
||||
!webStarted
|
||||
) {
|
||||
webStarted = true;
|
||||
resolve(process);
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr?.on('data', (data) => {
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(`Web Error: ${data}`);
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
process.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
process.on('exit', (code, signal) => {
|
||||
process.on("exit", (code, signal) => {
|
||||
console.log(`Web process exited with code ${code} and signal ${signal}`);
|
||||
if (!webStarted) {
|
||||
reject(new Error(`Web process exited unexpectedly with code ${code}`));
|
||||
@@ -74,222 +87,211 @@ async function startWeb(): Promise<ChildProcess> {
|
||||
|
||||
// 存储进程引用
|
||||
webProcess = process;
|
||||
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
if (!webStarted) {
|
||||
reject(new Error('Web server failed to start within timeout'));
|
||||
reject(new Error("Web server failed to start within timeout"));
|
||||
}
|
||||
}, 30000); // 30秒超时
|
||||
}, 10000); // 10秒超时
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer(): Promise<ChildProcess> {
|
||||
console.log('Starting .NET server...');
|
||||
console.log("Starting .NET server...");
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = spawn(getCommand('dotnet'), ['run', '--property:Configuration=Release'], {
|
||||
cwd: 'server',
|
||||
...getSpawnOptions()
|
||||
} as any);
|
||||
const process = spawn(
|
||||
getCommand("dotnet"),
|
||||
["run", "--property:Configuration=Release"],
|
||||
{
|
||||
cwd: "server",
|
||||
...getSpawnOptions(),
|
||||
} as any,
|
||||
);
|
||||
|
||||
let serverStarted = false;
|
||||
|
||||
process.stdout?.on('data', (data) => {
|
||||
process.stdout?.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
console.log(`Server: ${output}`);
|
||||
|
||||
|
||||
// 检查服务器是否已启动
|
||||
if (output.includes('Now listening on:') && !serverStarted) {
|
||||
if (output.includes("Now listening on:") && !serverStarted) {
|
||||
serverStarted = true;
|
||||
resolve(process);
|
||||
}
|
||||
});
|
||||
|
||||
process.stderr?.on('data', (data) => {
|
||||
process.stderr?.on("data", (data) => {
|
||||
console.error(`Server Error: ${data}`);
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
process.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
process.on('exit', (code, signal) => {
|
||||
console.log(`Server process exited with code ${code} and signal ${signal}`);
|
||||
process.on("exit", (code, signal) => {
|
||||
console.log(
|
||||
`Server process exited with code ${code} and signal ${signal}`,
|
||||
);
|
||||
if (!serverStarted) {
|
||||
reject(new Error(`Server process exited unexpectedly with code ${code}`));
|
||||
reject(
|
||||
new Error(`Server process exited unexpectedly with code ${code}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 存储进程引用
|
||||
serverProcess = process;
|
||||
|
||||
|
||||
// 超时处理
|
||||
setTimeout(() => {
|
||||
if (!serverStarted) {
|
||||
reject(new Error('Server failed to start within timeout'));
|
||||
reject(new Error("Server failed to start within timeout"));
|
||||
}
|
||||
}, 30000); // 30秒超时
|
||||
}, 10000); // 10秒超时
|
||||
});
|
||||
}
|
||||
|
||||
async function stopServer(): Promise<void> {
|
||||
console.log('Stopping server...');
|
||||
|
||||
console.log("Stopping server...");
|
||||
|
||||
if (!serverProcess) {
|
||||
console.log('No server process to stop');
|
||||
console.log("No server process to stop");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查进程是否还存在
|
||||
if (serverProcess.killed || serverProcess.exitCode !== null) {
|
||||
console.log('✓ Server process already terminated');
|
||||
console.log("✓ Server process already terminated");
|
||||
serverProcess = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送 SIGTERM 信号
|
||||
const killed = serverProcess.kill('SIGTERM');
|
||||
const killed = serverProcess.kill("SIGTERM");
|
||||
if (!killed) {
|
||||
console.warn('Failed to send SIGTERM to server process');
|
||||
console.warn("Failed to send SIGTERM to server process");
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待进程优雅退出
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
if (serverProcess) {
|
||||
serverProcess.on('exit', () => {
|
||||
console.log('✓ Server stopped gracefully');
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// 设置超时,如果 3 秒内没有退出则强制终止
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (serverProcess && !serverProcess.killed && serverProcess.exitCode === null) {
|
||||
console.log('Force killing server process...');
|
||||
serverProcess.kill('SIGKILL');
|
||||
if (
|
||||
serverProcess &&
|
||||
!serverProcess.killed &&
|
||||
serverProcess.exitCode === null
|
||||
) {
|
||||
console.log("Force killing server process...");
|
||||
serverProcess.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 3000); // 减少超时时间到3秒
|
||||
});
|
||||
|
||||
await Promise.race([exitPromise, timeoutPromise]);
|
||||
|
||||
await Promise.race([timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.warn('Warning: Could not stop server process:', error);
|
||||
console.warn("Warning: Could not stop server process:", error);
|
||||
} finally {
|
||||
serverProcess = null;
|
||||
|
||||
|
||||
// 只有在进程可能没有正常退出时才执行清理
|
||||
// 移除自动清理逻辑,因为正常退出时不需要
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWeb(): Promise<void> {
|
||||
console.log('Stopping web server...');
|
||||
|
||||
console.log("Stopping web server...");
|
||||
|
||||
if (!webProcess) {
|
||||
console.log('No web process to stop');
|
||||
console.log("No web process to stop");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查进程是否还存在
|
||||
if (webProcess.killed || webProcess.exitCode !== null) {
|
||||
console.log('✓ Web process already terminated');
|
||||
console.log("✓ Web process already terminated");
|
||||
webProcess = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送 SIGTERM 信号
|
||||
const killed = webProcess.kill('SIGTERM');
|
||||
const killed = webProcess.kill("SIGTERM");
|
||||
if (!killed) {
|
||||
console.warn('Failed to send SIGTERM to web process');
|
||||
console.warn("Failed to send SIGTERM to web process");
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待进程优雅退出
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
if (webProcess) {
|
||||
webProcess.on('exit', () => {
|
||||
console.log('✓ Web server stopped gracefully');
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
// 设置超时,如果 3 秒内没有退出则强制终止
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
if (webProcess && !webProcess.killed && webProcess.exitCode === null) {
|
||||
console.log('Force killing web process...');
|
||||
webProcess.kill('SIGKILL');
|
||||
console.log("Force killing web process...");
|
||||
webProcess.kill("SIGKILL");
|
||||
}
|
||||
resolve();
|
||||
}, 3000); // 减少超时时间到3秒
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
await Promise.race([exitPromise, timeoutPromise]);
|
||||
|
||||
await Promise.race([timeoutPromise]);
|
||||
} catch (error) {
|
||||
console.warn('Warning: Could not stop web process:', error);
|
||||
console.warn("Warning: Could not stop web process:", error);
|
||||
} finally {
|
||||
webProcess = null;
|
||||
|
||||
// 只有在进程可能没有正常退出时才执行清理
|
||||
// 移除自动清理逻辑,因为正常退出时不需要
|
||||
}
|
||||
}
|
||||
|
||||
async function postProcessApiClient(): Promise<void> {
|
||||
console.log('Post-processing API client...');
|
||||
console.log("Post-processing API client...");
|
||||
try {
|
||||
const filePath = 'src/APIClient.ts';
|
||||
|
||||
const filePath = "src/APIClient.ts";
|
||||
|
||||
// 检查文件是否存在
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`API client file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
|
||||
// 读取文件内容
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
let content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
// 替换 ArgumentException 中的 message 属性声明
|
||||
content = content.replace(
|
||||
/(\s+)message!:\s*string;/g,
|
||||
'$1declare message: string;'
|
||||
"$1declare message: string;",
|
||||
);
|
||||
|
||||
content = content.replace(
|
||||
"{ AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, CancelToken }",
|
||||
"{ AxiosError, type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type CancelToken }",
|
||||
);
|
||||
|
||||
// 写回文件
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
|
||||
console.log('✓ API client post-processing completed');
|
||||
fs.writeFileSync(filePath, content, "utf8");
|
||||
|
||||
console.log("✓ API client post-processing completed");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to post-process API client: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateApiClient(): Promise<void> {
|
||||
console.log('Generating API client...');
|
||||
console.log("Generating API client...");
|
||||
try {
|
||||
const url = 'http://127.0.0.1:5000/GetAPIClientCode';
|
||||
const url = "http://127.0.0.1:5000/GetAPIClientCode";
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch API client code: ${response.status} ${response.statusText}`);
|
||||
throw new Error(
|
||||
`Failed to fetch API client code: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const code = await response.text();
|
||||
|
||||
// 写入 APIClient.ts
|
||||
const filePath = 'src/APIClient.ts';
|
||||
fs.writeFileSync(filePath, code, 'utf8');
|
||||
console.log('✓ API client code fetched and written successfully');
|
||||
const filePath = "src/APIClient.ts";
|
||||
fs.writeFileSync(filePath, code, "utf8");
|
||||
console.log("✓ API client code fetched and written successfully");
|
||||
|
||||
// 添加后处理步骤
|
||||
await postProcessApiClient();
|
||||
@@ -298,35 +300,58 @@ async function generateApiClient(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSignalRClient(): Promise<void> {
|
||||
console.log("Generating SignalR TypeScript client...");
|
||||
try {
|
||||
// TypedSignalR.Client.TypeScript.Analyzer 会在编译时自动生成客户端
|
||||
// 我们只需要确保服务器项目构建一次即可生成 TypeScript 客户端
|
||||
const { stdout, stderr } = await execAsync(
|
||||
"dotnet build --configuration Release",
|
||||
{ cwd: "./server" }
|
||||
);
|
||||
if (stdout) console.log(stdout);
|
||||
if (stderr) console.error(stderr);
|
||||
console.log("✓ SignalR TypeScript client generated successfully");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate SignalR client: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
// Generate SignalR client
|
||||
await generateSignalRClient();
|
||||
console.log("✓ SignalR TypeScript client generated successfully");
|
||||
|
||||
// Start web frontend first
|
||||
await startWeb();
|
||||
console.log('✓ Frontend started');
|
||||
|
||||
console.log("✓ Frontend started");
|
||||
|
||||
// Wait a bit for frontend to fully initialize
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
// Start server
|
||||
await startServer();
|
||||
console.log('✓ Backend started');
|
||||
|
||||
console.log("✓ Backend started");
|
||||
|
||||
// Wait for server to be ready (给服务器额外时间完全启动)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Check if swagger endpoint is available
|
||||
const serverReady = await waitForServer('http://localhost:5000/swagger/v1/swagger.json');
|
||||
|
||||
const serverReady = await waitForServer(
|
||||
"http://localhost:5000/swagger/v1/swagger.json",
|
||||
);
|
||||
|
||||
if (!serverReady) {
|
||||
throw new Error('Server failed to start within the expected time');
|
||||
throw new Error("Server failed to start within the expected time");
|
||||
}
|
||||
|
||||
|
||||
// Generate API client
|
||||
await generateApiClient();
|
||||
|
||||
console.log('✓ API generation completed successfully');
|
||||
|
||||
console.log("✓ API generation completed successfully");
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error);
|
||||
console.error("❌ Error:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
// Always try to stop processes in order: server first, then web
|
||||
@@ -340,80 +365,68 @@ let isCleaningUp = false;
|
||||
|
||||
const cleanup = async (signal: string) => {
|
||||
if (isCleaningUp) {
|
||||
console.log('Cleanup already in progress, ignoring signal');
|
||||
console.log("Cleanup already in progress, ignoring signal");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
isCleaningUp = true;
|
||||
console.log(`\nReceived ${signal}, cleaning up...`);
|
||||
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
stopServer(),
|
||||
stopWeb()
|
||||
]);
|
||||
await Promise.all([stopServer(), stopWeb()]);
|
||||
} catch (error) {
|
||||
console.error('Error during cleanup:', error);
|
||||
console.error("Error during cleanup:", error);
|
||||
}
|
||||
|
||||
|
||||
// 立即退出,不等待
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => cleanup('SIGINT'));
|
||||
process.on('SIGTERM', () => cleanup('SIGTERM'));
|
||||
process.on("SIGINT", () => cleanup("SIGINT"));
|
||||
process.on("SIGTERM", () => cleanup("SIGTERM"));
|
||||
|
||||
// 处理未捕获的异常
|
||||
process.on('uncaughtException', async (error) => {
|
||||
process.on("uncaughtException", async (error) => {
|
||||
if (isCleaningUp) return;
|
||||
|
||||
console.error('❌ Uncaught exception:', error);
|
||||
|
||||
console.error("❌ Uncaught exception:", error);
|
||||
isCleaningUp = true;
|
||||
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
stopServer(),
|
||||
stopWeb()
|
||||
]);
|
||||
await Promise.all([stopServer(), stopWeb()]);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error during cleanup:', cleanupError);
|
||||
console.error("Error during cleanup:", cleanupError);
|
||||
}
|
||||
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', async (reason, promise) => {
|
||||
process.on("unhandledRejection", async (reason, promise) => {
|
||||
if (isCleaningUp) return;
|
||||
|
||||
console.error('❌ Unhandled rejection at:', promise, 'reason:', reason);
|
||||
|
||||
console.error("❌ Unhandled rejection at:", promise, "reason:", reason);
|
||||
isCleaningUp = true;
|
||||
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
stopServer(),
|
||||
stopWeb()
|
||||
]);
|
||||
await Promise.all([stopServer(), stopWeb()]);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error during cleanup:', cleanupError);
|
||||
console.error("Error during cleanup:", cleanupError);
|
||||
}
|
||||
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch(async (error) => {
|
||||
if (isCleaningUp) return;
|
||||
|
||||
console.error('❌ Unhandled error:', error);
|
||||
|
||||
console.error("❌ Unhandled error:", error);
|
||||
isCleaningUp = true;
|
||||
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
stopServer(),
|
||||
stopWeb()
|
||||
]);
|
||||
await Promise.all([stopServer(), stopWeb()]);
|
||||
} catch (cleanupError) {
|
||||
console.error('Error during cleanup:', cleanupError);
|
||||
console.error("Error during cleanup:", cleanupError);
|
||||
}
|
||||
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Buffers.Binary;
|
||||
using Common;
|
||||
|
||||
namespace server.test;
|
||||
|
||||
public class CommonTest
|
||||
{
|
||||
[Fact]
|
||||
public void ReverseBytesTest()
|
||||
{
|
||||
var rnd = new Random();
|
||||
var bytesLen = 8;
|
||||
var bytesArray = new byte[bytesLen];
|
||||
rnd.NextBytes(bytesArray);
|
||||
|
||||
var rev2Bytes = new byte[] {
|
||||
bytesArray[1],
|
||||
bytesArray[0],
|
||||
bytesArray[3],
|
||||
bytesArray[2],
|
||||
bytesArray[5],
|
||||
bytesArray[4],
|
||||
bytesArray[7],
|
||||
bytesArray[6],
|
||||
};
|
||||
Assert.Equal(Number.ReverseBytes(bytesArray, 2).Value, rev2Bytes);
|
||||
|
||||
var rev4Bytes = new byte[] {
|
||||
bytesArray[3],
|
||||
bytesArray[2],
|
||||
bytesArray[1],
|
||||
bytesArray[0],
|
||||
bytesArray[7],
|
||||
bytesArray[6],
|
||||
bytesArray[5],
|
||||
bytesArray[4],
|
||||
};
|
||||
Assert.Equal(Number.ReverseBytes(bytesArray, 4).Value, rev4Bytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToBitTest()
|
||||
{
|
||||
Assert.Equal(true, Number.ToBit(0xFF, 3).Value);
|
||||
Assert.Equal(false, Number.ToBit(0x00, 3).Value);
|
||||
Assert.Equal(true, Number.ToBit(0xAA, 3).Value);
|
||||
Assert.Equal(false, Number.ToBit(0xAA, 2).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReverseBits()
|
||||
{
|
||||
Assert.Equal((byte)0x05, Common.Number.ReverseBits((byte)0xA0));
|
||||
|
||||
var bytesSrc = new byte[] { 0xAB, 0x00, 0x00, 0x01 };
|
||||
var bytes = new byte[4];
|
||||
for (int i = 0; i < 4; i++)
|
||||
bytes[i] = Common.Number.ReverseBits(bytesSrc[i]);
|
||||
Assert.Equal(new byte[] { 0xD5, 0x00, 0x00, 0x80 }, bytes);
|
||||
}
|
||||
}
|
||||
286
server.test/NumberTest.cs
Normal file
286
server.test/NumberTest.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using System.Collections;
|
||||
using Common;
|
||||
|
||||
namespace CommonTest;
|
||||
|
||||
/// <summary>
|
||||
/// 针对 Common.Number 的单元测试,覆盖所有公开方法
|
||||
/// </summary>
|
||||
public class NumberTest
|
||||
{
|
||||
/// <summary>
|
||||
/// 测试 NumberToBytes 的正常与异常情况
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_NumberToBytes()
|
||||
{
|
||||
// 测试大端(isLowNumHigh=false)
|
||||
var result1 = Number.NumberToBytes(0x12345678ABCDEF01, 8, false);
|
||||
Assert.True(result1.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 }, result1.Value);
|
||||
|
||||
// 测试小端(isLowNumHigh=true)
|
||||
var result2 = Number.NumberToBytes(0x12345678ABCDEF01, 8, true);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0x01, 0xEF, 0xCD, 0xAB, 0x78, 0x56, 0x34, 0x12 }, result2.Value);
|
||||
|
||||
// 测试长度不足(4字节)
|
||||
var result3 = Number.NumberToBytes(0x12345678, 4, false);
|
||||
Assert.True(result3.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0x12, 0x34, 0x56, 0x78 }, result3.Value);
|
||||
|
||||
// 测试超长
|
||||
var result4 = Number.NumberToBytes(0x1, 9, false);
|
||||
Assert.False(result4.IsSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 BytesToUInt64 的正常与异常情况
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_BytesToUInt64()
|
||||
{
|
||||
// 正常大端
|
||||
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78, 0xAB, 0xCD, 0xEF, 0x01 };
|
||||
var result = Number.BytesToUInt64((byte[])bytes.Clone(), false);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal(0x12345678ABCDEF01UL, result.Value);
|
||||
|
||||
// 正常小端
|
||||
var bytes2 = new byte[] { 0x01, 0xEF, 0xCD, 0xAB, 0x78, 0x56, 0x34, 0x12 };
|
||||
var result2 = Number.BytesToUInt64((byte[])bytes2.Clone(), true);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.Equal(0x12345678ABCDEF01UL, result2.Value);
|
||||
|
||||
// 异常:长度超限
|
||||
var result3 = Number.BytesToUInt64(new byte[9], false);
|
||||
Assert.False(result3.IsSuccessful);
|
||||
|
||||
// 异常:不足8字节
|
||||
var result4 = Number.BytesToUInt64(new byte[] { 0x01, 0x02 }, false);
|
||||
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt64 需要8字节
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 BytesToUInt32 的正常与异常情况
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_BytesToUInt32()
|
||||
{
|
||||
// 正常大端
|
||||
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||
var result = Number.BytesToUInt32((byte[])bytes.Clone(), false);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal(0x12345678U, result.Value);
|
||||
|
||||
// 正常小端
|
||||
var bytes2 = new byte[] { 0x78, 0x56, 0x34, 0x12 };
|
||||
var result2 = Number.BytesToUInt32((byte[])bytes2.Clone(), true);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.Equal(0x12345678U, result2.Value);
|
||||
|
||||
// 异常:长度超限
|
||||
var result3 = Number.BytesToUInt32(new byte[5], false);
|
||||
Assert.False(result3.IsSuccessful);
|
||||
|
||||
// 异常:不足4字节
|
||||
var result4 = Number.BytesToUInt32(new byte[] { 0x01, 0x02 }, false);
|
||||
Assert.False(result4.IsSuccessful); // BitConverter.ToUInt32 需要4字节
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 UInt32ArrayToBytes 的正常与异常情况
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_UInt32ArrayToBytes()
|
||||
{
|
||||
// 正常情况
|
||||
var arr = new UInt32[] { 0x12345678, 0xABCDEF01 };
|
||||
var result = Number.UInt32ArrayToBytes(arr);
|
||||
Assert.True(result.IsSuccessful);
|
||||
// BlockCopy 按小端序
|
||||
Assert.Equal(new byte[] { 0x78, 0x56, 0x34, 0x12, 0x01, 0xEF, 0xCD, 0xAB }, result.Value);
|
||||
|
||||
// 空数组
|
||||
var result2 = Number.UInt32ArrayToBytes(new UInt32[0]);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.Empty(result2.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 MultiBitsToBytes 和 MultiBitsToNumber (ulong)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_MultiBitsToBytesAndNumber_Ulong()
|
||||
{
|
||||
// 合并两个比特段
|
||||
var result = Number.MultiBitsToNumber(0b101UL, 3, 0b11UL, 2);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal((ulong)0b10111, result.Value);
|
||||
|
||||
// 合并为字节数组
|
||||
var bytesResult = Number.MultiBitsToBytes(0b101UL, 3, 0b11UL, 2);
|
||||
Assert.True(bytesResult.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0b10111 }, bytesResult.Value);
|
||||
|
||||
// 超过64位
|
||||
var failResult = Number.MultiBitsToNumber(0xFFFFFFFFFFFFFFFF, 64, 1, 1);
|
||||
Assert.False(failResult.IsSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 MultiBitsToNumber (uint)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_MultiBitsToNumber_Uint()
|
||||
{
|
||||
var result = Number.MultiBitsToNumber(0b101U, 3, 0b11U, 2);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal((uint)0b10111, result.Value);
|
||||
|
||||
// 超过64位
|
||||
var failResult = Number.MultiBitsToNumber(uint.MaxValue, 64, 1, 1);
|
||||
Assert.False(failResult.IsSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 BitsCheck (ulong)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_BitsCheck_Ulong()
|
||||
{
|
||||
// 完全匹配
|
||||
Assert.True(Number.BitsCheck(0b1101UL, 0b1101UL));
|
||||
// 不匹配
|
||||
Assert.False(Number.BitsCheck(0b1101UL, 0b1001UL));
|
||||
// 掩码
|
||||
Assert.True(Number.BitsCheck(0b1101UL, 0b1001UL, 0b1001UL));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 BitsCheck (uint)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_BitsCheck_Uint()
|
||||
{
|
||||
Assert.True(Number.BitsCheck(0b1011U, 0b1011U));
|
||||
Assert.False(Number.BitsCheck(0b1011U, 0b1001U));
|
||||
Assert.True(Number.BitsCheck(0b1011U, 0b1001U, 0b1001U));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 ToBit
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_ToBit()
|
||||
{
|
||||
// 取第0位
|
||||
var result = Number.ToBit(0b1010U, 0);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.False(result.Value);
|
||||
|
||||
// 取第1位
|
||||
var result2 = Number.ToBit(0b1010U, 1);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.True(result2.Value);
|
||||
|
||||
// 负数位置
|
||||
var result3 = Number.ToBit(0b1010U, -1);
|
||||
Assert.False(result3.IsSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 BitsToNumber
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_BitsToNumber()
|
||||
{
|
||||
// 5位BitArray
|
||||
var bits = new BitArray(new bool[] { true, true, false, true, false }); // 0b01011
|
||||
var result = Number.BitsToNumber(bits);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal((uint)0b01011, result.Value);
|
||||
|
||||
// 超过32位
|
||||
var bits2 = new BitArray(33);
|
||||
Assert.Throws<ArgumentException>(() => Number.BitsToNumber(bits2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 StringToBytes
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_StringToBytes()
|
||||
{
|
||||
// 16进制字符串
|
||||
var bytes = Number.StringToBytes("1234ABCD");
|
||||
Assert.Equal(new byte[] { 0x12, 0x34, 0xAB, 0xCD }, bytes);
|
||||
|
||||
// 8位字符串
|
||||
var bytes2 = Number.StringToBytes("01020304");
|
||||
Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, bytes2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 ReverseBytes
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_ReverseBytes()
|
||||
{
|
||||
// 步长为2
|
||||
var src = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var result = Number.ReverseBytes(src, 2);
|
||||
Assert.True(result.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0x02, 0x01, 0x04, 0x03 }, result.Value);
|
||||
|
||||
// 步长为4
|
||||
var src2 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var result2 = Number.ReverseBytes(src2, 4);
|
||||
Assert.True(result2.IsSuccessful);
|
||||
Assert.Equal(new byte[] { 0x04, 0x03, 0x02, 0x01 }, result2.Value);
|
||||
|
||||
// 步长为1(无变化)
|
||||
var src3 = new byte[] { 0x01, 0x02, 0x03, 0x04 };
|
||||
var result3 = Number.ReverseBytes(src3, 1);
|
||||
Assert.True(result3.IsSuccessful);
|
||||
Assert.Equal(src3, result3.Value);
|
||||
|
||||
// 步长为0(异常)
|
||||
var result4 = Number.ReverseBytes(src3, 0);
|
||||
Assert.False(result4.IsSuccessful);
|
||||
|
||||
// 步长不能整除
|
||||
var result5 = Number.ReverseBytes(src3, 3);
|
||||
Assert.False(result5.IsSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 ReverseBits (byte)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_ReverseBits_Byte()
|
||||
{
|
||||
// 0b00010010 -> 0b01001000
|
||||
byte src = 0b00010010;
|
||||
byte reversed = Number.ReverseBits(src);
|
||||
Assert.Equal(0b01001000, reversed);
|
||||
|
||||
// 0b11110000 -> 0b00001111
|
||||
Assert.Equal(0b00001111, Number.ReverseBits(0b11110000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 ReverseBits (byte[])
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Test_ReverseBits_ByteArray()
|
||||
{
|
||||
var src = new byte[] { 0b00010010, 0b11110000 };
|
||||
var reversed = Number.ReverseBits(src);
|
||||
Assert.Equal(new byte[] { 0b01001000, 0b00001111 }, reversed);
|
||||
|
||||
// 空数组
|
||||
var reversed2 = Number.ReverseBits(new byte[0]);
|
||||
Assert.Empty(reversed2);
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Common;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace server.test;
|
||||
|
||||
|
||||
public class UDPServerTest
|
||||
{
|
||||
const string address = "127.0.0.1";
|
||||
const int port = 33000;
|
||||
private static readonly UDPServer udpServer = new UDPServer(port);
|
||||
|
||||
private readonly ITestOutputHelper output;
|
||||
|
||||
public UDPServerTest(ITestOutputHelper output)
|
||||
{
|
||||
this.output = output;
|
||||
udpServer.Start();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UDPDataDeepClone()
|
||||
{
|
||||
var udpData = new UDPData()
|
||||
{
|
||||
DateTime = DateTime.Now,
|
||||
Address = "127.0.0.1",
|
||||
Port = 1234,
|
||||
Data = new byte[] { 0xf0, 00, 00, 00 },
|
||||
HasRead = false
|
||||
};
|
||||
var cloneUdpData = udpData.DeepClone();
|
||||
|
||||
Assert.Equal(udpData.DateTime, cloneUdpData.DateTime);
|
||||
Assert.Equal(udpData.Address, cloneUdpData.Address);
|
||||
Assert.Equal(udpData.Port, cloneUdpData.Port);
|
||||
Assert.Equal(udpData.Data, cloneUdpData.Data);
|
||||
Assert.Equal(udpData.HasRead, cloneUdpData.HasRead);
|
||||
|
||||
udpData.DateTime = DateTime.Now;
|
||||
udpData.Address = "192.168.1.1";
|
||||
udpData.Port = 33000;
|
||||
udpData.Data = new byte[] { 0xFF, 00, 00, 00 };
|
||||
udpData.HasRead = true;
|
||||
|
||||
Assert.NotNull(cloneUdpData.DateTime);
|
||||
Assert.NotNull(cloneUdpData.Address);
|
||||
Assert.NotNull(cloneUdpData.Port);
|
||||
Assert.NotNull(cloneUdpData.Data);
|
||||
Assert.NotNull(cloneUdpData.HasRead);
|
||||
|
||||
Assert.NotEqual(udpData.DateTime, cloneUdpData.DateTime);
|
||||
Assert.NotEqual(udpData.Address, cloneUdpData.Address);
|
||||
Assert.NotEqual(udpData.Port, cloneUdpData.Port);
|
||||
Assert.NotEqual(udpData.Data, cloneUdpData.Data);
|
||||
Assert.NotEqual(udpData.HasRead, cloneUdpData.HasRead);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new object[] { new string[] { "Hello World!", "Hello Server!", "What is your problem?" } })]
|
||||
public async Task UDPServerFindString(string[] textArray)
|
||||
{
|
||||
Assert.True(udpServer.IsRunning);
|
||||
|
||||
var serverEP = new IPEndPoint(IPAddress.Parse(address), port);
|
||||
|
||||
foreach (var text in textArray)
|
||||
{
|
||||
Assert.True(await UDPClientPool.SendStringAsync(serverEP, [text]));
|
||||
var ret = await udpServer.FindDataAsync(address);
|
||||
Assert.True(ret.HasValue);
|
||||
var data = ret.Value;
|
||||
Assert.Equal(address, data.Address);
|
||||
Assert.Equal(text, Encoding.ASCII.GetString(data.Data));
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new object[] { new UInt32[] { 0xF0_00_00_00, 0xFF_00_00_00, 0xFF_FF_FF_FF } })]
|
||||
public async Task UDPServerFindBytes(UInt32[] bytesArray)
|
||||
{
|
||||
Assert.True(udpServer.IsRunning);
|
||||
|
||||
var serverEP = new IPEndPoint(IPAddress.Parse(address), port);
|
||||
|
||||
foreach (var number in bytesArray)
|
||||
{
|
||||
Assert.True(await UDPClientPool.SendBytesAsync(serverEP, Number.NumberToBytes(number, 4).Value));
|
||||
var ret = await udpServer.FindDataAsync(address);
|
||||
Assert.True(ret.HasValue);
|
||||
var data = ret.Value;
|
||||
Assert.Equal(address, data.Address);
|
||||
Assert.Equal(number, Number.BytesToUInt32(data.Data).Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new object[] { new UInt32[] { 0xF0_00_00_00, 0xF0_01_00_00 } })]
|
||||
public async Task UDPServerWaitResp(UInt32[] bytesArray)
|
||||
{
|
||||
Assert.True(udpServer.IsRunning);
|
||||
|
||||
var serverEP = new IPEndPoint(IPAddress.Parse(address), port);
|
||||
|
||||
foreach (var number in bytesArray)
|
||||
{
|
||||
Assert.True(await UDPClientPool.SendBytesAsync(serverEP, Number.NumberToBytes(number, 4).Value));
|
||||
|
||||
var ret = await udpServer.WaitForAckAsync(address);
|
||||
Assert.True(ret.IsSuccessful);
|
||||
var data = ret.Value;
|
||||
Assert.True(data.IsSuccessful);
|
||||
Assert.Equal(number, Number.BytesToUInt32(data.ToBytes()).Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new object[] { new UInt64[] { 0x0F_00_00_00_01_02_02_02, 0x0F_01_00_00_FF_FF_FF_FF } })]
|
||||
public async Task UDPServerWaitData(UInt64[] bytesArray)
|
||||
{
|
||||
Assert.True(udpServer.IsRunning);
|
||||
|
||||
var serverEP = new IPEndPoint(IPAddress.Parse(address), port);
|
||||
|
||||
foreach (var number in bytesArray)
|
||||
{
|
||||
Assert.True(await UDPClientPool.SendBytesAsync(serverEP, Number.NumberToBytes(number, 8).Value));
|
||||
|
||||
var ret = await udpServer.WaitForDataAsync(address);
|
||||
Assert.True(ret.IsSuccessful);
|
||||
var data = ret.Value;
|
||||
Assert.True(data.IsSuccessful);
|
||||
Assert.Equal((UInt64)number, Number.BytesToUInt64(data.ToBytes()).Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,13 @@ using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Newtonsoft.Json;
|
||||
using NJsonSchema.CodeGeneration.TypeScript;
|
||||
using NLog;
|
||||
using NLog.Web;
|
||||
using NSwag;
|
||||
using NSwag.CodeGeneration.TypeScript;
|
||||
using NSwag.Generation.Processors.Security;
|
||||
using server.Services;
|
||||
using TypedSignalR.Client.DevTools;
|
||||
|
||||
// Early init of NLog to allow startup and exception logging, before host is built
|
||||
var logger = NLog.LogManager.Setup()
|
||||
@@ -95,8 +95,17 @@ try
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
);
|
||||
options.AddPolicy("SignalR", policy => policy
|
||||
.WithOrigins("http://localhost:5173")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials()
|
||||
);
|
||||
});
|
||||
|
||||
// Use SignalR
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Add Swagger
|
||||
builder.Services.AddSwaggerDocument(options =>
|
||||
{
|
||||
@@ -168,6 +177,17 @@ try
|
||||
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "log")),
|
||||
RequestPath = "/log"
|
||||
});
|
||||
|
||||
// Exam Files (实验静态资源)
|
||||
if (Directory.Exists(Path.Combine(Directory.GetCurrentDirectory(), "exam")))
|
||||
{
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "exam")),
|
||||
RequestPath = "/exam"
|
||||
});
|
||||
}
|
||||
|
||||
app.MapFallbackToFile("index.html");
|
||||
}
|
||||
|
||||
@@ -188,25 +208,18 @@ try
|
||||
});
|
||||
app.UseSwaggerUi();
|
||||
|
||||
// SignalR
|
||||
app.UseWebSockets();
|
||||
app.UseSignalRHubSpecification();
|
||||
app.UseSignalRHubDevelopmentUI();
|
||||
|
||||
// Router
|
||||
app.MapControllers();
|
||||
app.MapHub<server.Hubs.JtagHub.JtagHub>("hubs/JtagHub");
|
||||
|
||||
// Setup Program
|
||||
MsgBus.Init();
|
||||
|
||||
// 扫描并更新实验数据库
|
||||
try
|
||||
{
|
||||
using var db = new Database.AppDataConnection();
|
||||
var examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
|
||||
var updateCount = db.ScanAndUpdateExams(examFolderPath);
|
||||
logger.Info($"实验数据库扫描完成,更新了 {updateCount} 个实验");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"扫描实验文件夹时出错: {ex.Message}");
|
||||
}
|
||||
|
||||
// Generate API Client
|
||||
app.MapGet("GetAPIClientCode", async (HttpContext context) =>
|
||||
{
|
||||
@@ -233,7 +246,7 @@ try
|
||||
logger.Error(err);
|
||||
return Results.Problem(err.ToString());
|
||||
}
|
||||
});
|
||||
}).RequireCors("Development");
|
||||
|
||||
app.Run();
|
||||
}
|
||||
@@ -254,4 +267,3 @@ finally
|
||||
// Close Program
|
||||
MsgBus.Exit();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# 实验001:基础逻辑门电路
|
||||
|
||||
## 实验目的
|
||||
|
||||
本实验旨在帮助学生理解基础逻辑门的工作原理,包括与门、或门、非门等基本逻辑运算。
|
||||
|
||||
## 实验内容
|
||||
|
||||
### 1. 与门(AND Gate)
|
||||
与门是一个基本的逻辑门,当所有输入都为高电平(1)时,输出才为高电平(1)。
|
||||
|
||||
### 2. 或门(OR Gate)
|
||||
或门是另一个基本的逻辑门,当任意一个输入为高电平(1)时,输出就为高电平(1)。
|
||||
|
||||
### 3. 非门(NOT Gate)
|
||||
非门是一个反相器,输入为高电平时输出为低电平,反之亦然。
|
||||
|
||||
## 实验步骤
|
||||
|
||||
1. 打开 FPGA 开发环境
|
||||
2. 创建新的项目文件
|
||||
3. 编写 Verilog 代码实现各种逻辑门
|
||||
4. 进行仿真验证
|
||||
5. 下载到 FPGA 板进行硬件验证
|
||||
|
||||
## 预期结果
|
||||
|
||||
通过本实验,学生应该能够:
|
||||
- 理解基本逻辑门的真值表
|
||||
- 掌握 Verilog 代码的基本语法
|
||||
- 学会使用 FPGA 开发工具进行仿真
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保输入信号的电平正确
|
||||
- 注意时序的约束
|
||||
- 验证结果时要仔细对比真值表
|
||||
@@ -1,35 +0,0 @@
|
||||
# 实验002:组合逻辑电路设计
|
||||
|
||||
## 实验目的
|
||||
|
||||
本实验旨在让学生学习如何设计和实现复杂的组合逻辑电路,掌握多个逻辑门的组合使用。
|
||||
|
||||
## 实验内容
|
||||
|
||||
### 1. 半加器设计
|
||||
设计一个半加器电路,实现两个一位二进制数的加法运算。
|
||||
|
||||
### 2. 全加器设计
|
||||
在半加器的基础上,设计全加器电路,考虑进位输入。
|
||||
|
||||
### 3. 编码器和译码器
|
||||
实现简单的编码器和译码器电路。
|
||||
|
||||
## 实验要求
|
||||
|
||||
1. 使用 Verilog HDL 编写代码
|
||||
2. 绘制逻辑电路图
|
||||
3. 编写测试用例验证功能
|
||||
4. 分析电路的延时特性
|
||||
|
||||
## 评估标准
|
||||
|
||||
- 电路功能正确性 (40%)
|
||||
- 代码质量和规范性 (30%)
|
||||
- 测试覆盖率 (20%)
|
||||
- 实验报告 (10%)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 数字逻辑设计教材第3-4章
|
||||
- Verilog HDL 语法参考手册
|
||||
@@ -14,6 +14,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ArpLookup" Version="2.0.3" />
|
||||
<PackageReference Include="DotNext" Version="5.23.0" />
|
||||
<PackageReference Include="DotNext.Threading" Version="5.23.0" />
|
||||
<PackageReference Include="Honoo.IO.Hashing.Crc" Version="1.3.3" />
|
||||
@@ -31,6 +32,16 @@
|
||||
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.11.0.20250507" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
|
||||
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
|
||||
<PackageReference Include="Tapper.Analyzer" Version="1.13.1">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="TypedSignalR.Client.DevTools" Version="1.2.4" />
|
||||
<PackageReference Include="TypedSignalR.Client.TypeScript.Analyzer" Version="1.15.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="TypedSignalR.Client.TypeScript.Attributes" Version="1.15.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.NetworkInformation;
|
||||
using ArpLookup;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
/// <summary>
|
||||
/// ARP 记录管理静态类(跨平台支持)
|
||||
/// </summary>
|
||||
public static class Arp
|
||||
public static class ArpClient
|
||||
{
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
@@ -44,20 +47,28 @@ public static class Arp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 Ping 动态更新指定 IP 的 ARP 记录
|
||||
/// 动态更新指定 IP 的 ARP 记录
|
||||
/// </summary>
|
||||
/// <param name="ipAddress">要更新的 IP 地址</param>
|
||||
/// <returns>是否成功发送 Ping</returns>
|
||||
public static async Task<bool> UpdateArpEntryByPingAsync(string ipAddress)
|
||||
public static async Task<bool> UpdateArpEntryAsync(string ipAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ipAddress))
|
||||
throw new ArgumentException("IP 地址不能为空", nameof(ipAddress));
|
||||
|
||||
try
|
||||
{
|
||||
using var ping = new System.Net.NetworkInformation.Ping();
|
||||
var reply = await ping.SendPingAsync(ipAddress, 100);
|
||||
return reply.Status == System.Net.NetworkInformation.IPStatus.Success;
|
||||
var ret = await ArpClient.DeleteArpEntryAsync(ipAddress);
|
||||
if (!ret)
|
||||
{
|
||||
logger.Error($"删除 ARP 记录失败: {ipAddress}");
|
||||
}
|
||||
|
||||
PhysicalAddress? mac = await Arp.LookupAsync(IPAddress.Parse(ipAddress));
|
||||
if (mac == null)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -228,22 +239,22 @@ public static class Arp
|
||||
return null;
|
||||
|
||||
var lines = result.Output.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
// 匹配接口行格式: Interface: 172.6.1.5 --- 0xa
|
||||
var interfacePattern = @"Interface:\s+(\d+\.\d+\.\d+\.\d+)\s+---\s+(0x[a-fA-F0-9]+)";
|
||||
var match = Regex.Match(line, interfacePattern);
|
||||
|
||||
|
||||
if (match.Success && match.Groups[1].Value == interfaceIp)
|
||||
{
|
||||
// 将十六进制索引转换为十进制
|
||||
var hexIndex = match.Groups[2].Value;
|
||||
// 去掉 "0x" 前缀
|
||||
var hexValue = hexIndex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? hexIndex.Substring(2)
|
||||
var hexValue = hexIndex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? hexIndex.Substring(2)
|
||||
: hexIndex;
|
||||
|
||||
|
||||
if (int.TryParse(hexValue, System.Globalization.NumberStyles.HexNumber, null, out int decimalIndex))
|
||||
{
|
||||
logger.Debug($"找到接口 {interfaceIp} 的索引: {hexIndex} -> {decimalIndex}");
|
||||
@@ -251,7 +262,7 @@ public static class Arp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.Warn($"未找到接口 {interfaceIp} 的索引");
|
||||
return null;
|
||||
}
|
||||
@@ -417,9 +428,9 @@ public static class Arp
|
||||
private static ArpEntry? ParseWindowsArpEntry(string line)
|
||||
{
|
||||
// 跳过空行和标题行
|
||||
if (string.IsNullOrWhiteSpace(line) ||
|
||||
line.Contains("Interface:") ||
|
||||
line.Contains("Internet Address") ||
|
||||
if (string.IsNullOrWhiteSpace(line) ||
|
||||
line.Contains("Interface:") ||
|
||||
line.Contains("Internet Address") ||
|
||||
line.Contains("Physical Address") ||
|
||||
line.Contains("Type"))
|
||||
{
|
||||
@@ -518,7 +529,7 @@ public static class Arp
|
||||
|
||||
// 格式化 MAC 地址以适配不同操作系统
|
||||
string formattedMac = FormatMacAddress(macAddress);
|
||||
|
||||
|
||||
var entry = await GetArpEntryAsync(ipAddress);
|
||||
if (entry != null && string.Equals(FormatMacAddress(entry.MacAddress), formattedMac, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -534,7 +545,7 @@ public static class Arp
|
||||
|
||||
// 新增 ARP 记录
|
||||
var ret = await AddArpEntryAsync(ipAddress, formattedMac, interfaceName);
|
||||
if(!ret) logger.Error($"添加 ARP 记录失败: {ipAddress} -> {formattedMac} on {interfaceName}");
|
||||
if (!ret) logger.Error($"添加 ARP 记录失败: {ipAddress} -> {formattedMac} on {interfaceName}");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ public class Number
|
||||
{
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
arr[length - 1 - i] = Convert.ToByte((num >> (i << 3)) & (0xFF));
|
||||
arr[i] = Convert.ToByte((num >> (i << 3)) & (0xFF));
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -99,20 +99,11 @@ public class Number
|
||||
|
||||
try
|
||||
{
|
||||
if (isLowNumHigh)
|
||||
if (!isLowNumHigh)
|
||||
{
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
num += Convert.ToUInt64((UInt64)bytes[len - 1 - i] << (i << 3));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
num += Convert.ToUInt64((UInt64)bytes[i] << ((int)(len - 1 - i) << 3));
|
||||
}
|
||||
Array.Reverse(bytes);
|
||||
}
|
||||
num = BitConverter.ToUInt64(bytes, 0);
|
||||
|
||||
return num;
|
||||
}
|
||||
@@ -143,20 +134,11 @@ public class Number
|
||||
|
||||
try
|
||||
{
|
||||
if (isLowNumHigh)
|
||||
if (!isLowNumHigh)
|
||||
{
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
num += Convert.ToUInt32((UInt32)bytes[len - 1 - i] << (i << 3));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < len; i++)
|
||||
{
|
||||
num += Convert.ToUInt32((UInt32)bytes[i] << ((int)(len - 1 - i) << 3));
|
||||
}
|
||||
Array.Reverse(bytes);
|
||||
}
|
||||
num = BitConverter.ToUInt32(bytes, 0);
|
||||
|
||||
return num;
|
||||
}
|
||||
@@ -333,10 +315,9 @@ public class Number
|
||||
|
||||
for (int i = 0; i < srcBytesLen; i += distance)
|
||||
{
|
||||
var end = i + distance;
|
||||
buffer = srcBytes[i..end];
|
||||
Buffer.BlockCopy(srcBytes, i, buffer, 0, distance);
|
||||
Array.Reverse(buffer);
|
||||
Array.Copy(buffer, 0, dstBytes, i, distance);
|
||||
Buffer.BlockCopy(buffer, 0, dstBytes, i, distance);
|
||||
}
|
||||
|
||||
return dstBytes;
|
||||
|
||||
@@ -51,7 +51,7 @@ public class DataController : ControllerBase
|
||||
public DateTime? BoardExpireTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// 获取本机IP地址(优先选择与实验板同网段的IP)
|
||||
/// </summary>
|
||||
/// <returns>本机IP地址</returns>
|
||||
@@ -60,7 +60,7 @@ public class DataController : ControllerBase
|
||||
try
|
||||
{
|
||||
var boardIpSegments = BOARD_IP.Split('.').Take(3).ToArray();
|
||||
|
||||
|
||||
// 优先选择与实验板IP前三段相同的IP
|
||||
var sameSegmentIP = System.Net.NetworkInformation.NetworkInterface
|
||||
.GetAllNetworkInterfaces()
|
||||
@@ -278,7 +278,7 @@ public class DataController : ControllerBase
|
||||
return NotFound("没有可用的实验板");
|
||||
|
||||
var boardInfo = boardOpt.Value;
|
||||
if (!(await Arp.CheckOrAddAsync(boardInfo.IpAddr, boardInfo.MacAddr, GetLocalIPAddress().ToString())))
|
||||
if (!(await ArpClient.CheckOrAddAsync(boardInfo.IpAddr, boardInfo.MacAddr, GetLocalIPAddress().ToString())))
|
||||
{
|
||||
logger.Error($"无法配置ARP,实验板可能会无法连接");
|
||||
}
|
||||
@@ -346,7 +346,7 @@ public class DataController : ControllerBase
|
||||
return NotFound("未找到对应的实验板");
|
||||
|
||||
var boardInfo = ret.Value.Value;
|
||||
if (!(await Arp.CheckOrAddAsync(boardInfo.IpAddr, boardInfo.MacAddr, GetLocalIPAddress().ToString())))
|
||||
if (!(await ArpClient.CheckOrAddAsync(boardInfo.IpAddr, boardInfo.MacAddr, GetLocalIPAddress().ToString())))
|
||||
{
|
||||
logger.Error($"无法配置ARP,实验板可能会无法连接");
|
||||
}
|
||||
@@ -490,4 +490,3 @@ public class DataController : ControllerBase
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ public class DebuggerController : ControllerBase
|
||||
}
|
||||
|
||||
var rawData = dataResult.Value;
|
||||
logger.Debug($"rawData: {BitConverter.ToString(rawData)}");
|
||||
// logger.Debug($"rawData: {BitConverter.ToString(rawData)}");
|
||||
int depth = (int)config.captureDepth;
|
||||
int portDataLen = 4 * depth;
|
||||
int portNum = (int)config.totalPortNum;
|
||||
@@ -406,13 +406,15 @@ public class DebuggerController : ControllerBase
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "数据越界");
|
||||
}
|
||||
var sampleBytes = rawData[sampleOffset..(sampleOffset + 4)];
|
||||
UInt32 sample = BitConverter.ToUInt32(Common.Number.ReverseBytes(sampleBytes, 4).Value, 0);
|
||||
UInt32 sample = Common.Number.BytesToUInt32(sampleBytes, true).Value;
|
||||
// 提取wireWidth位
|
||||
UInt32 mask = (wireWidth == 32) ? 0xFFFFFFFF : ((1u << wireWidth) - 1u);
|
||||
channelUintArr[i] = (sample >> wireStart) & mask;
|
||||
}
|
||||
logger.Debug($"{channel.name} HexData: {BitConverter.ToString(channelUintArr.SelectMany(BitConverter.GetBytes).ToArray())}");
|
||||
var base64 = Convert.ToBase64String(channelUintArr.SelectMany(BitConverter.GetBytes).ToArray());
|
||||
var channelBytes = new byte[4 * depth];
|
||||
Buffer.BlockCopy(channelUintArr, 0, channelBytes, 0, channelBytes.Length);
|
||||
// logger.Debug($"{channel.name} HexData: {BitConverter.ToString(channelBytes)}");
|
||||
var base64 = Convert.ToBase64String(channelBytes);
|
||||
channelDataList.Add(new ChannelCaptureData { name = channel.name, data = base64 });
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,14 @@ public class ExamController : ControllerBase
|
||||
public required string ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验文档内容(Markdown格式)
|
||||
/// 实验名称
|
||||
/// </summary>
|
||||
public required string DocContent { get; set; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验描述
|
||||
/// </summary>
|
||||
public required string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验创建时间
|
||||
@@ -38,6 +43,21 @@ public class ExamController : ControllerBase
|
||||
/// 实验最后更新时间
|
||||
/// </summary>
|
||||
public DateTime UpdatedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验标签
|
||||
/// </summary>
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 实验难度(1-5)
|
||||
/// </summary>
|
||||
public int Difficulty { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 普通用户是否可见
|
||||
/// </summary>
|
||||
public bool IsVisibleToUsers { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -50,6 +70,11 @@ public class ExamController : ControllerBase
|
||||
/// </summary>
|
||||
public required string ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验名称
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验创建时间
|
||||
/// </summary>
|
||||
@@ -61,25 +86,55 @@ public class ExamController : ControllerBase
|
||||
public DateTime UpdatedTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验标题(从文档内容中提取)
|
||||
/// 实验标签
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "";
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 实验难度(1-5)
|
||||
/// </summary>
|
||||
public int Difficulty { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 普通用户是否可见
|
||||
/// </summary>
|
||||
public bool IsVisibleToUsers { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描结果类
|
||||
/// 创建实验请求类
|
||||
/// </summary>
|
||||
public class ScanResult
|
||||
public class CreateExamRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 结果消息
|
||||
/// 实验ID
|
||||
/// </summary>
|
||||
public required string Message { get; set; }
|
||||
public required string ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新的实验数量
|
||||
/// 实验名称
|
||||
/// </summary>
|
||||
public int UpdateCount { get; set; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验描述
|
||||
/// </summary>
|
||||
public required string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验标签
|
||||
/// </summary>
|
||||
public string[] Tags { get; set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 实验难度(1-5)
|
||||
/// </summary>
|
||||
public int Difficulty { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 普通用户是否可见
|
||||
/// </summary>
|
||||
public bool IsVisibleToUsers { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,9 +157,12 @@ public class ExamController : ControllerBase
|
||||
var examSummaries = exams.Select(exam => new ExamSummary
|
||||
{
|
||||
ID = exam.ID,
|
||||
Name = exam.Name,
|
||||
CreatedTime = exam.CreatedTime,
|
||||
UpdatedTime = exam.UpdatedTime,
|
||||
Title = ExtractTitleFromMarkdown(exam.DocContent)
|
||||
Tags = exam.GetTagsList(),
|
||||
Difficulty = exam.Difficulty,
|
||||
IsVisibleToUsers = exam.IsVisibleToUsers
|
||||
}).ToArray();
|
||||
|
||||
logger.Info($"成功获取实验列表,共 {examSummaries.Length} 个实验");
|
||||
@@ -156,9 +214,13 @@ public class ExamController : ControllerBase
|
||||
var examInfo = new ExamInfo
|
||||
{
|
||||
ID = exam.ID,
|
||||
DocContent = exam.DocContent,
|
||||
Name = exam.Name,
|
||||
Description = exam.Description,
|
||||
CreatedTime = exam.CreatedTime,
|
||||
UpdatedTime = exam.UpdatedTime
|
||||
UpdatedTime = exam.UpdatedTime,
|
||||
Tags = exam.GetTagsList(),
|
||||
Difficulty = exam.Difficulty,
|
||||
IsVisibleToUsers = exam.IsVisibleToUsers
|
||||
};
|
||||
|
||||
logger.Info($"成功获取实验信息: {examId}");
|
||||
@@ -172,60 +234,58 @@ public class ExamController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重新扫描实验文件夹并更新数据库
|
||||
/// 创建新实验
|
||||
/// </summary>
|
||||
/// <returns>更新结果</returns>
|
||||
/// <param name="request">创建实验请求</param>
|
||||
/// <returns>创建结果</returns>
|
||||
[Authorize("Admin")]
|
||||
[HttpPost("scan")]
|
||||
[HttpPost]
|
||||
[EnableCors("Users")]
|
||||
[ProducesResponseType(typeof(ScanResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public IActionResult ScanExams()
|
||||
public IActionResult CreateExam([FromBody] CreateExamRequest 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 examFolderPath = Path.Combine(Directory.GetCurrentDirectory(), "exam");
|
||||
var updateCount = db.ScanAndUpdateExams(examFolderPath);
|
||||
var result = db.CreateExam(request.ID, request.Name, request.Description, request.Tags, request.Difficulty, request.IsVisibleToUsers);
|
||||
|
||||
var result = new ScanResult
|
||||
if (!result.IsSuccessful)
|
||||
{
|
||||
Message = $"扫描完成,更新了 {updateCount} 个实验",
|
||||
UpdateCount = updateCount
|
||||
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
|
||||
};
|
||||
|
||||
logger.Info($"手动扫描实验完成,更新了 {updateCount} 个实验");
|
||||
return Ok(result);
|
||||
logger.Info($"成功创建实验: {request.ID}");
|
||||
return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"扫描实验时出错: {ex.Message}");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, $"扫描实验失败: {ex.Message}");
|
||||
logger.Error($"创建实验 {request.ID} 时出错: {ex.Message}");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, $"创建实验失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 Markdown 内容中提取标题
|
||||
/// </summary>
|
||||
/// <param name="markdownContent">Markdown 内容</param>
|
||||
/// <returns>提取的标题</returns>
|
||||
private static string ExtractTitleFromMarkdown(string markdownContent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(markdownContent))
|
||||
return "";
|
||||
|
||||
var lines = markdownContent.Split('\n');
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var trimmedLine = line.Trim();
|
||||
if (trimmedLine.StartsWith("# "))
|
||||
{
|
||||
return trimmedLine.Substring(2).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Database;
|
||||
|
||||
namespace server.Controllers;
|
||||
|
||||
@@ -14,8 +15,6 @@ public class JtagController : ControllerBase
|
||||
{
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
private const string BITSTREAM_PATH = "bitstream/Jtag";
|
||||
|
||||
/// <summary>
|
||||
/// 控制器首页信息
|
||||
/// </summary>
|
||||
@@ -112,64 +111,12 @@ public class JtagController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 上传比特流文件到服务器
|
||||
/// </summary>
|
||||
/// <param name="address">目标设备地址</param>
|
||||
/// <param name="file">比特流文件</param>
|
||||
/// <returns>上传结果</returns>
|
||||
[HttpPost("UploadBitstream")]
|
||||
[EnableCors("Users")]
|
||||
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async ValueTask<IResult> UploadBitstream(string address, IFormFile file)
|
||||
{
|
||||
logger.Info($"User {User.Identity?.Name} uploading bitstream for device {address}");
|
||||
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
logger.Warn($"User {User.Identity?.Name} attempted to upload empty file for device {address}");
|
||||
return TypedResults.BadRequest("未选择文件");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 生成安全的文件名(避免路径遍历攻击)
|
||||
var fileName = Path.GetRandomFileName();
|
||||
var uploadsFolder = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
|
||||
|
||||
// 如果存在文件,则删除原文件再上传
|
||||
if (Directory.Exists(uploadsFolder))
|
||||
{
|
||||
Directory.Delete(uploadsFolder, true);
|
||||
logger.Info($"User {User.Identity?.Name} removed existing bitstream folder for device {address}");
|
||||
}
|
||||
Directory.CreateDirectory(uploadsFolder);
|
||||
|
||||
var filePath = Path.Combine(uploadsFolder, fileName);
|
||||
|
||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
logger.Info($"User {User.Identity?.Name} successfully uploaded bitstream for device {address}, file size: {file.Length} bytes");
|
||||
return TypedResults.Ok(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, $"User {User.Identity?.Name} failed to upload bitstream for device {address}");
|
||||
return TypedResults.InternalServerError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过 JTAG 下载比特流文件到 FPGA 设备
|
||||
/// </summary>
|
||||
/// <param name="address">JTAG 设备地址</param>
|
||||
/// <param name="port">JTAG 设备端口</param>
|
||||
/// <param name="bitstreamId">比特流ID</param>
|
||||
/// <returns>下载结果</returns>
|
||||
[HttpPost("DownloadBitstream")]
|
||||
[EnableCors("Users")]
|
||||
@@ -177,87 +124,111 @@ public class JtagController : ControllerBase
|
||||
[ProducesResponseType(typeof(string), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(Exception), StatusCodes.Status500InternalServerError)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
public async ValueTask<IResult> DownloadBitstream(string address, int port)
|
||||
public async ValueTask<IResult> DownloadBitstream(string address, int port, int bitstreamId)
|
||||
{
|
||||
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port}");
|
||||
|
||||
// 检查文件
|
||||
var fileDir = Path.Combine(Environment.CurrentDirectory, $"{BITSTREAM_PATH}/{address}");
|
||||
if (!Directory.Exists(fileDir))
|
||||
{
|
||||
logger.Warn($"User {User.Identity?.Name} attempted to download non-existent bitstream for device {address}");
|
||||
return TypedResults.BadRequest("Empty bitstream, Please upload it first");
|
||||
}
|
||||
logger.Info($"User {User.Identity?.Name} initiating bitstream download to device {address}:{port} using bitstream ID: {bitstreamId}");
|
||||
|
||||
try
|
||||
{
|
||||
// 读取文件
|
||||
var filePath = Directory.GetFiles(fileDir)[0];
|
||||
logger.Info($"User {User.Identity?.Name} reading bitstream file: {filePath}");
|
||||
|
||||
using (var fileStream = System.IO.File.Open(filePath, System.IO.FileMode.Open))
|
||||
// 获取当前用户名
|
||||
var username = User.Identity?.Name;
|
||||
if (string.IsNullOrEmpty(username))
|
||||
{
|
||||
if (fileStream is null || fileStream.Length <= 0)
|
||||
logger.Warn("Anonymous user attempted to download bitstream");
|
||||
return TypedResults.Unauthorized();
|
||||
}
|
||||
|
||||
// 从数据库获取用户信息
|
||||
using var db = new Database.AppDataConnection();
|
||||
var userResult = db.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);
|
||||
|
||||
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)
|
||||
{
|
||||
logger.Warn($"User {username} attempted to download non-existent bitstream ID: {bitstreamId}");
|
||||
return TypedResults.BadRequest("比特流不存在");
|
||||
}
|
||||
|
||||
var bitstream = bitstreamResult.Value.Value;
|
||||
|
||||
// 处理比特流数据
|
||||
var fileBytes = bitstream.Data;
|
||||
if (fileBytes == null || fileBytes.Length == 0)
|
||||
{
|
||||
logger.Warn($"User {username} found empty bitstream data for ID: {bitstreamId}");
|
||||
return TypedResults.BadRequest("比特流数据为空,请重新上传");
|
||||
}
|
||||
|
||||
logger.Info($"User {username} processing bitstream file of size: {fileBytes.Length} bytes");
|
||||
|
||||
// 定义缓冲区大小: 32KB
|
||||
byte[] buffer = new byte[32 * 1024];
|
||||
byte[] revBuffer = new byte[32 * 1024];
|
||||
long totalBytesProcessed = 0;
|
||||
|
||||
// 使用内存流处理文件
|
||||
using (var inputStream = new MemoryStream(fileBytes))
|
||||
using (var outputStream = new MemoryStream())
|
||||
{
|
||||
int bytesRead;
|
||||
while ((bytesRead = await inputStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
logger.Warn($"User {User.Identity?.Name} found invalid bitstream file for device {address}");
|
||||
return TypedResults.BadRequest("Wrong bitstream, Please upload it again");
|
||||
// 反转 32bits
|
||||
var retBuffer = Common.Number.ReverseBytes(buffer, 4);
|
||||
if (!retBuffer.IsSuccessful)
|
||||
{
|
||||
logger.Error($"User {username} failed to reverse bytes: {retBuffer.Error}");
|
||||
return TypedResults.InternalServerError(retBuffer.Error);
|
||||
}
|
||||
revBuffer = retBuffer.Value;
|
||||
|
||||
for (int i = 0; i < revBuffer.Length; i++)
|
||||
{
|
||||
revBuffer[i] = Common.Number.ReverseBits(revBuffer[i]);
|
||||
}
|
||||
|
||||
await outputStream.WriteAsync(revBuffer, 0, bytesRead);
|
||||
totalBytesProcessed += bytesRead;
|
||||
}
|
||||
|
||||
logger.Info($"User {User.Identity?.Name} processing bitstream file of size: {fileStream.Length} bytes");
|
||||
// 获取处理后的数据
|
||||
var processedBytes = outputStream.ToArray();
|
||||
logger.Info($"User {username} processed {totalBytesProcessed} bytes for device {address}");
|
||||
|
||||
// 定义缓冲区大小: 32KB
|
||||
byte[] buffer = new byte[32 * 1024];
|
||||
byte[] revBuffer = new byte[32 * 1024];
|
||||
long totalBytesRead = 0;
|
||||
// 下载比特流
|
||||
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
|
||||
var ret = await jtagCtrl.DownloadBitstream(processedBytes);
|
||||
|
||||
// 使用异步流读取文件
|
||||
using (var memoryStream = new MemoryStream())
|
||||
if (ret.IsSuccessful)
|
||||
{
|
||||
int bytesRead;
|
||||
while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
|
||||
{
|
||||
// 反转 32bits
|
||||
var retBuffer = Common.Number.ReverseBytes(buffer, 4);
|
||||
if (!retBuffer.IsSuccessful)
|
||||
{
|
||||
logger.Error($"User {User.Identity?.Name} failed to reverse bytes: {retBuffer.Error}");
|
||||
return TypedResults.InternalServerError(retBuffer.Error);
|
||||
}
|
||||
revBuffer = retBuffer.Value;
|
||||
|
||||
for (int i = 0; i < revBuffer.Length; i++)
|
||||
{
|
||||
revBuffer[i] = Common.Number.ReverseBits(revBuffer[i]);
|
||||
}
|
||||
|
||||
await memoryStream.WriteAsync(revBuffer, 0, bytesRead);
|
||||
totalBytesRead += bytesRead;
|
||||
}
|
||||
|
||||
// 将所有数据转换为字节数组(注意:如果文件非常大,可能不适合完全加载到内存)
|
||||
var fileBytes = memoryStream.ToArray();
|
||||
logger.Info($"User {User.Identity?.Name} processed {totalBytesRead} bytes for device {address}");
|
||||
|
||||
// 下载比特流
|
||||
var jtagCtrl = new Peripherals.JtagClient.Jtag(address, port);
|
||||
var ret = await jtagCtrl.DownloadBitstream(fileBytes);
|
||||
|
||||
if (ret.IsSuccessful)
|
||||
{
|
||||
logger.Info($"User {User.Identity?.Name} successfully downloaded bitstream to device {address}");
|
||||
return TypedResults.Ok(ret.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error($"User {User.Identity?.Name} failed to download bitstream to device {address}: {ret.Error}");
|
||||
return TypedResults.InternalServerError(ret.Error);
|
||||
}
|
||||
logger.Info($"User {username} successfully downloaded bitstream '{bitstream.ResourceName}' to device {address}");
|
||||
return TypedResults.Ok(ret.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error($"User {username} failed to download bitstream to device {address}: {ret.Error}");
|
||||
return TypedResults.InternalServerError(ret.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error(ex, $"User {User.Identity?.Name} encountered exception while downloading bitstream to device {address}");
|
||||
logger.Error(ex, $"User encountered exception while downloading bitstream to device {address}");
|
||||
return TypedResults.InternalServerError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ public class NetConfigController : ControllerBase
|
||||
// 固定的实验板IP,端口,MAC地址
|
||||
private const string BOARD_IP = "169.254.109.0";
|
||||
private const int BOARD_PORT = 1234;
|
||||
private const string BOARD_MAC = "12:34:56:78:9a:bc";
|
||||
|
||||
|
||||
// 本机网络信息
|
||||
private readonly IPAddress _localIP;
|
||||
private readonly byte[] _localMAC;
|
||||
@@ -33,14 +32,14 @@ public class NetConfigController : ControllerBase
|
||||
// 初始化本机IP地址
|
||||
_localIP = GetLocalIPAddress();
|
||||
_localIPString = _localIP?.ToString() ?? "未知";
|
||||
|
||||
|
||||
// 初始化本机MAC地址
|
||||
_localMAC = GetLocalMACAddress();
|
||||
_localMACString = _localMAC != null ? BitConverter.ToString(_localMAC).Replace("-", ":") : "未知";
|
||||
|
||||
|
||||
// 获取本机网络接口名称
|
||||
_localInterface = GetLocalNetworkInterface();
|
||||
|
||||
|
||||
logger.Info($"NetConfigController 初始化完成 - 本机IP: {_localIPString}, 本机MAC: {_localMACString}, 接口: {_localInterface}");
|
||||
}
|
||||
|
||||
@@ -53,7 +52,7 @@ public class NetConfigController : ControllerBase
|
||||
try
|
||||
{
|
||||
var boardIpSegments = BOARD_IP.Split('.').Take(3).ToArray();
|
||||
|
||||
|
||||
// 优先选择与实验板IP前三段相同的IP
|
||||
var sameSegmentIP = System.Net.NetworkInformation.NetworkInterface
|
||||
.GetAllNetworkInterfaces()
|
||||
@@ -130,7 +129,7 @@ public class NetConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
return await Arp.CheckOrAddAsync(BOARD_IP, BOARD_MAC, _localInterface);
|
||||
return await ArpClient.UpdateArpEntryAsync(BOARD_IP);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
377
server/src/Controllers/ResourceController.cs
Normal file
377
server/src/Controllers/ResourceController.cs
Normal file
@@ -0,0 +1,377 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DotNext;
|
||||
using Database;
|
||||
|
||||
namespace server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 资源控制器 - 提供统一的资源管理API
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ResourceController : ControllerBase
|
||||
{
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
/// <summary>
|
||||
/// 资源信息类
|
||||
/// </summary>
|
||||
public class ResourceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 资源ID
|
||||
/// </summary>
|
||||
public int ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源名称
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源类型
|
||||
/// </summary>
|
||||
public required string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源用途(template/user)
|
||||
/// </summary>
|
||||
public required string Purpose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传时间
|
||||
/// </summary>
|
||||
public DateTime UploadTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属实验ID(可选)
|
||||
/// </summary>
|
||||
public string? ExamID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MIME类型
|
||||
/// </summary>
|
||||
public string? MimeType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加资源请求类
|
||||
/// </summary>
|
||||
public class AddResourceRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 资源类型
|
||||
/// </summary>
|
||||
public required string ResourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源用途(template/user)
|
||||
/// </summary>
|
||||
public required string ResourcePurpose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属实验ID(可选)
|
||||
/// </summary>
|
||||
public string? ExamID { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加资源(文件上传)
|
||||
/// </summary>
|
||||
/// <param name="request">添加资源请求</param>
|
||||
/// <param name="file">资源文件</param>
|
||||
/// <returns>添加结果</returns>
|
||||
[Authorize]
|
||||
[HttpPost]
|
||||
[EnableCors("Users")]
|
||||
[ProducesResponseType(typeof(ResourceInfo), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取资源列表
|
||||
/// </summary>
|
||||
/// <param name="examId">实验ID(可选)</param>
|
||||
/// <param name="resourceType">资源类型(可选)</param>
|
||||
/// <param name="resourcePurpose">资源用途(可选)</param>
|
||||
/// <returns>资源列表</returns>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据资源ID下载资源
|
||||
/// </summary>
|
||||
/// <param name="resourceId">资源ID</param>
|
||||
/// <returns>资源文件</returns>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除资源
|
||||
/// </summary>
|
||||
/// <param name="resourceId">资源ID</param>
|
||||
/// <returns>删除结果</returns>
|
||||
[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ public class Board
|
||||
/// </summary>
|
||||
public enum BoardStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// 未启用状态,无法被使用
|
||||
/// </summary>
|
||||
Disabled,
|
||||
@@ -162,10 +162,16 @@ public class Exam
|
||||
public required string ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验文档内容(Markdown格式)
|
||||
/// 实验名称
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required string DocContent { get; set; }
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验描述
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required string Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 实验创建时间
|
||||
@@ -178,6 +184,155 @@ public class Exam
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public DateTime UpdatedTime { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 实验标签(以逗号分隔的字符串)
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public string Tags { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 实验难度(1-5,1为最简单)
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public int Difficulty { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 普通用户是否可见
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public bool IsVisibleToUsers { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取标签列表
|
||||
/// </summary>
|
||||
/// <returns>标签数组</returns>
|
||||
public string[] GetTagsList()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Tags))
|
||||
return Array.Empty<string>();
|
||||
|
||||
return Tags.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(tag => tag.Trim())
|
||||
.Where(tag => !string.IsNullOrEmpty(tag))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置标签列表
|
||||
/// </summary>
|
||||
/// <param name="tags">标签数组</param>
|
||||
public void SetTagsList(string[] tags)
|
||||
{
|
||||
Tags = string.Join(",", tags.Where(tag => !string.IsNullOrWhiteSpace(tag)).Select(tag => tag.Trim()));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资源类,统一管理实验资源、用户比特流等各类资源
|
||||
/// </summary>
|
||||
public class Resource
|
||||
{
|
||||
/// <summary>
|
||||
/// 资源的唯一标识符
|
||||
/// </summary>
|
||||
[PrimaryKey, Identity]
|
||||
public int ID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 上传资源的用户ID
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required Guid UserID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属实验ID(可选,如果不属于特定实验则为空)
|
||||
/// </summary>
|
||||
[Nullable]
|
||||
public string? ExamID { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源类型(images, markdown, bitstream, diagram, project等)
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required string ResourceType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源用途:template(模板)或 user(用户上传)
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required string ResourcePurpose { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源名称(包含文件扩展名)
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required string ResourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源的二进制数据
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public required byte[] Data { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 资源创建/上传时间
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public DateTime UploadTime { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 资源的MIME类型
|
||||
/// </summary>
|
||||
[NotNull]
|
||||
public string MimeType { get; set; } = "application/octet-stream";
|
||||
|
||||
/// <summary>
|
||||
/// 资源类型枚举
|
||||
/// </summary>
|
||||
public static class ResourceTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// 图片资源类型
|
||||
/// </summary>
|
||||
public const string Images = "images";
|
||||
|
||||
/// <summary>
|
||||
/// Markdown文档资源类型
|
||||
/// </summary>
|
||||
public const string Markdown = "markdown";
|
||||
|
||||
/// <summary>
|
||||
/// 比特流文件资源类型
|
||||
/// </summary>
|
||||
public const string Bitstream = "bitstream";
|
||||
|
||||
/// <summary>
|
||||
/// 原理图资源类型
|
||||
/// </summary>
|
||||
public const string Diagram = "diagram";
|
||||
|
||||
/// <summary>
|
||||
/// 项目文件资源类型
|
||||
/// </summary>
|
||||
public const string Project = "project";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 资源用途枚举
|
||||
/// </summary>
|
||||
public static class ResourcePurposes
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板资源,通常由管理员上传,供用户参考
|
||||
/// </summary>
|
||||
public const string Template = "template";
|
||||
|
||||
/// <summary>
|
||||
/// 用户上传的资源
|
||||
/// </summary>
|
||||
public const string User = "user";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -228,6 +383,7 @@ public class AppDataConnection : DataConnection
|
||||
this.CreateTable<User>();
|
||||
this.CreateTable<Board>();
|
||||
this.CreateTable<Exam>();
|
||||
this.CreateTable<Resource>();
|
||||
logger.Info("数据库表创建完成");
|
||||
}
|
||||
|
||||
@@ -240,6 +396,7 @@ public class AppDataConnection : DataConnection
|
||||
this.DropTable<User>();
|
||||
this.DropTable<Board>();
|
||||
this.DropTable<Exam>();
|
||||
this.DropTable<Resource>();
|
||||
logger.Warn("所有数据库表已删除");
|
||||
}
|
||||
|
||||
@@ -558,6 +715,31 @@ public class AppDataConnection : DataConnection
|
||||
return new(boards[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据用户名获取实验板信息
|
||||
/// </summary>
|
||||
/// <param name="userName">用户名</param>
|
||||
/// <returns>包含实验板信息的结果,如果未找到则返回空</returns>
|
||||
public Result<Optional<Board>> 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<Board>.None);
|
||||
}
|
||||
|
||||
logger.Debug($"成功获取实验板信息: {userName}");
|
||||
return new(boards[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有实验板信息
|
||||
/// </summary>
|
||||
@@ -674,75 +856,358 @@ public class AppDataConnection : DataConnection
|
||||
public ITable<Exam> ExamTable => this.GetTable<Exam>();
|
||||
|
||||
/// <summary>
|
||||
/// 扫描 exam 文件夹并更新实验数据库
|
||||
/// 资源表(统一管理实验资源、用户比特流等)
|
||||
/// </summary>
|
||||
/// <param name="examFolderPath">exam 文件夹的路径</param>
|
||||
/// <returns>更新的实验数量</returns>
|
||||
public int ScanAndUpdateExams(string examFolderPath)
|
||||
public ITable<Resource> ResourceTable => this.GetTable<Resource>();
|
||||
|
||||
/// <summary>
|
||||
/// 创建新实验
|
||||
/// </summary>
|
||||
/// <param name="id">实验ID</param>
|
||||
/// <param name="name">实验名称</param>
|
||||
/// <param name="description">实验描述</param>
|
||||
/// <param name="tags">实验标签</param>
|
||||
/// <param name="difficulty">实验难度</param>
|
||||
/// <param name="isVisibleToUsers">普通用户是否可见</param>
|
||||
/// <returns>创建的实验</returns>
|
||||
public Result<Exam> CreateExam(string id, string name, string description, string[]? tags = null, int difficulty = 1, bool isVisibleToUsers = true)
|
||||
{
|
||||
if (!Directory.Exists(examFolderPath))
|
||||
try
|
||||
{
|
||||
logger.Warn($"实验文件夹不存在: {examFolderPath}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int updateCount = 0;
|
||||
var subdirectories = Directory.GetDirectories(examFolderPath);
|
||||
|
||||
foreach (var examDir in subdirectories)
|
||||
{
|
||||
var examId = Path.GetFileName(examDir);
|
||||
var docPath = Path.Combine(examDir, "doc.md");
|
||||
|
||||
if (!File.Exists(docPath))
|
||||
// 检查实验ID是否已存在
|
||||
var existingExam = this.ExamTable.Where(e => e.ID == id).FirstOrDefault();
|
||||
if (existingExam != null)
|
||||
{
|
||||
logger.Warn($"实验 {examId} 缺少 doc.md 文件");
|
||||
continue;
|
||||
logger.Error($"实验ID已存在: {id}");
|
||||
return new(new Exception($"实验ID已存在: {id}"));
|
||||
}
|
||||
|
||||
try
|
||||
var exam = new Exam
|
||||
{
|
||||
var docContent = File.ReadAllText(docPath);
|
||||
var existingExam = this.ExamTable.Where(e => e.ID == examId).FirstOrDefault();
|
||||
ID = id,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Difficulty = Math.Max(1, Math.Min(5, difficulty)),
|
||||
IsVisibleToUsers = isVisibleToUsers,
|
||||
CreatedTime = DateTime.Now,
|
||||
UpdatedTime = DateTime.Now
|
||||
};
|
||||
|
||||
if (existingExam == null)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新实验信息
|
||||
/// </summary>
|
||||
/// <param name="id">实验ID</param>
|
||||
/// <param name="name">实验名称</param>
|
||||
/// <param name="description">实验描述</param>
|
||||
/// <param name="tags">实验标签</param>
|
||||
/// <param name="difficulty">实验难度</param>
|
||||
/// <param name="isVisibleToUsers">普通用户是否可见</param>
|
||||
/// <returns>更新的记录数</returns>
|
||||
public Result<int> UpdateExam(string id, string? name = null, string? description = null, string[]? tags = null, int? difficulty = null, bool? isVisibleToUsers = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
int result = 0;
|
||||
|
||||
if (name != null)
|
||||
{
|
||||
result += 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加资源
|
||||
/// </summary>
|
||||
/// <param name="userId">上传用户ID</param>
|
||||
/// <param name="resourceType">资源类型</param>
|
||||
/// <param name="resourcePurpose">资源用途(template 或 user)</param>
|
||||
/// <param name="resourceName">资源名称</param>
|
||||
/// <param name="data">资源二进制数据</param>
|
||||
/// <param name="examId">所属实验ID(可选)</param>
|
||||
/// <param name="mimeType">MIME类型(可选,将根据文件扩展名自动确定)</param>
|
||||
/// <returns>创建的资源</returns>
|
||||
public Result<Resource> AddResource(Guid userId, string resourceType, 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)
|
||||
{
|
||||
// 创建新实验
|
||||
var newExam = new Exam
|
||||
{
|
||||
ID = examId,
|
||||
DocContent = docContent,
|
||||
CreatedTime = DateTime.Now,
|
||||
UpdatedTime = DateTime.Now
|
||||
};
|
||||
this.Insert(newExam);
|
||||
logger.Info($"新实验已添加: {examId}");
|
||||
updateCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 更新现有实验
|
||||
var fileLastWrite = File.GetLastWriteTime(docPath);
|
||||
if (fileLastWrite > existingExam.UpdatedTime)
|
||||
{
|
||||
this.ExamTable
|
||||
.Where(e => e.ID == examId)
|
||||
.Set(e => e.DocContent, docContent)
|
||||
.Set(e => e.UpdatedTime, DateTime.Now)
|
||||
.Update();
|
||||
logger.Info($"实验已更新: {examId}");
|
||||
updateCount++;
|
||||
}
|
||||
logger.Error($"实验不存在: {examId}");
|
||||
return new(new Exception($"实验不存在: {examId}"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"处理实验 {examId} 时出错: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info($"实验扫描完成,共更新 {updateCount} 个实验");
|
||||
return updateCount;
|
||||
// 验证资源用途
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取资源信息列表(返回ID和名称)
|
||||
/// <param name="resourceType">资源类型</param>
|
||||
/// <param name="examId">实验ID(可选)</param>
|
||||
/// <param name="resourcePurpose">资源用途(可选)</param>
|
||||
/// <param name="userId">用户ID(可选)</param>
|
||||
/// </summary>
|
||||
/// <returns>资源信息列表</returns>
|
||||
public Result<(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取完整的资源列表
|
||||
/// </summary>
|
||||
/// <param name="examId">实验ID(可选)</param>
|
||||
/// <param name="resourceType">资源类型(可选)</param>
|
||||
/// <param name="resourcePurpose">资源用途(可选)</param>
|
||||
/// <param name="userId">用户ID(可选)</param>
|
||||
/// <returns>完整的资源对象列表</returns>
|
||||
public Result<List<Resource>> GetFullResourceList(string? examId = null, string? resourceType = null, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据资源ID获取资源
|
||||
/// </summary>
|
||||
/// <param name="resourceId">资源ID</param>
|
||||
/// <returns>资源数据</returns>
|
||||
public Result<Optional<Resource>> GetResourceById(int resourceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resource = this.ResourceTable.Where(r => r.ID == resourceId).FirstOrDefault();
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
logger.Info($"未找到资源: {resourceId}");
|
||||
return new(Optional<Resource>.None);
|
||||
}
|
||||
|
||||
logger.Info($"成功获取资源: {resourceId} ({resource.ResourceName})");
|
||||
return new(resource);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Error($"获取资源时出错: {ex.Message}");
|
||||
return new(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除资源
|
||||
/// </summary>
|
||||
/// <param name="resourceId">资源ID</param>
|
||||
/// <returns>删除的记录数</returns>
|
||||
public Result<int> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据文件扩展名获取MIME类型
|
||||
/// </summary>
|
||||
/// <param name="extension">文件扩展名</param>
|
||||
/// <param name="fileName">文件名(可选,用于特殊文件判断)</param>
|
||||
/// <returns>MIME类型</returns>
|
||||
private string GetMimeTypeFromExtension(string extension, string fileName = "")
|
||||
{
|
||||
// 特殊文件名处理
|
||||
if (!string.IsNullOrEmpty(fileName) && fileName.Equals("diagram.json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
return extension.ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".bmp" => "image/bmp",
|
||||
".svg" => "image/svg+xml",
|
||||
".sbit" => "application/octet-stream",
|
||||
".bit" => "application/octet-stream",
|
||||
".bin" => "application/octet-stream",
|
||||
".json" => "application/json",
|
||||
".zip" => "application/zip",
|
||||
".md" => "text/markdown",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -780,4 +1245,22 @@ public class AppDataConnection : DataConnection
|
||||
logger.Debug($"成功获取实验信息: {examId}");
|
||||
return new(exams[0]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据文件扩展名获取比特流MIME类型
|
||||
/// </summary>
|
||||
/// <param name="extension">文件扩展名</param>
|
||||
/// <returns>MIME类型</returns>
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
196
server/src/Hubs/JtagHub.cs
Normal file
196
server/src/Hubs/JtagHub.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using DotNext;
|
||||
using System.Collections.Concurrent;
|
||||
using TypedSignalR.Client;
|
||||
using Tapper;
|
||||
|
||||
namespace server.Hubs.JtagHub;
|
||||
|
||||
[Hub]
|
||||
public interface IJtagHub
|
||||
{
|
||||
Task<bool> SetBoundaryScanFreq(int freq);
|
||||
Task<bool> StartBoundaryScan(int freq = 100);
|
||||
Task<bool> StopBoundaryScan();
|
||||
}
|
||||
|
||||
[Receiver]
|
||||
public interface IJtagReceiver
|
||||
{
|
||||
Task OnReceiveBoundaryScanData(Dictionary<string, bool> msg);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[EnableCors("SignalR")]
|
||||
public class JtagHub : Hub<IJtagReceiver>, IJtagHub
|
||||
{
|
||||
private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
private static ConcurrentDictionary<string, int> FreqTable = new();
|
||||
private static ConcurrentDictionary<string, CancellationTokenSource> CancellationTokenSourceTable = new();
|
||||
|
||||
private readonly IHubContext<JtagHub, IJtagReceiver> _hubContext;
|
||||
|
||||
public JtagHub(IHubContext<JtagHub, IJtagReceiver> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
private Optional<Peripherals.JtagClient.Jtag> GetJtagClient(string userName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var db = new Database.AppDataConnection();
|
||||
var board = db.GetBoardByUserName(userName);
|
||||
if (!board.IsSuccessful)
|
||||
{
|
||||
logger.Error($"Find Board {board.Value.Value.ID} failed because {board.Error}");
|
||||
return new(null);
|
||||
}
|
||||
if (!board.Value.HasValue)
|
||||
{
|
||||
logger.Error($"Board {board.Value.Value.ID} not found");
|
||||
return new(null);
|
||||
}
|
||||
|
||||
var jtag = new Peripherals.JtagClient.Jtag(board.Value.Value.IpAddr, board.Value.Value.Port);
|
||||
return new(jtag);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.Error(error);
|
||||
return new(null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetBoundaryScanFreq(int freq)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
|
||||
if (userName is null)
|
||||
{
|
||||
logger.Error("Can't get user info");
|
||||
return false;
|
||||
}
|
||||
|
||||
FreqTable.AddOrUpdate(userName, freq, (key, value) => freq);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.Error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> StartBoundaryScan(int freq = 100)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
|
||||
if (userName is null)
|
||||
{
|
||||
logger.Error("No Such User");
|
||||
return false;
|
||||
}
|
||||
|
||||
await SetBoundaryScanFreq(freq);
|
||||
var cts = new CancellationTokenSource();
|
||||
CancellationTokenSourceTable.AddOrUpdate(userName, cts, (key, value) => cts);
|
||||
|
||||
_ = Task.Run(
|
||||
() => BoundaryScanLogicPorts(Context.ConnectionId, userName, cts.Token),
|
||||
cts.Token)
|
||||
.ContinueWith((task) =>
|
||||
{
|
||||
if (task.IsFaulted)
|
||||
{
|
||||
// 遍历所有异常
|
||||
foreach (var ex in task.Exception.InnerExceptions)
|
||||
{
|
||||
if (ex is OperationCanceledException)
|
||||
{
|
||||
logger.Info($"Boundary scan operation cancelled for user {userName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error($"Boundary scan operation failed for user {userName}: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (task.IsCanceled)
|
||||
{
|
||||
logger.Info($"Boundary scan operation cancelled for user {userName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info($"Boundary scan completed successfully for user {userName}");
|
||||
}
|
||||
});
|
||||
|
||||
logger.Info($"Boundary scan started for user {userName}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
logger.Error(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> StopBoundaryScan()
|
||||
{
|
||||
var userName = Context.User?.FindFirstValue(ClaimTypes.Name);
|
||||
if (userName is null)
|
||||
{
|
||||
logger.Error("No Such User");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CancellationTokenSourceTable.TryGetValue(userName, out var cts))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cts.Cancel();
|
||||
cts.Token.WaitHandle.WaitOne();
|
||||
|
||||
logger.Info($"Boundary scan stopped for user {userName}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task BoundaryScanLogicPorts(string connectionID, string userName, CancellationToken cancellationToken)
|
||||
{
|
||||
var jtagCtrl = GetJtagClient(userName).OrThrow(() => new InvalidOperationException("JTAG client not found"));
|
||||
var cntFail = 0;
|
||||
|
||||
while (true && cntFail < 5)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var ret = await jtagCtrl.BoundaryScanLogicalPorts();
|
||||
if (!ret.IsSuccessful)
|
||||
{
|
||||
logger.Error($"User {userName} boundary scan failed for device {jtagCtrl.address}: {ret.Error}");
|
||||
cntFail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await _hubContext.Clients.Client(connectionID).OnReceiveBoundaryScanData(ret.Value);
|
||||
// logger.Info($"User {userName} successfully completed boundary scan for device {jtagCtrl.address}");
|
||||
|
||||
await Task.Delay(FreqTable.TryGetValue(userName, out var freq) ? 1000 / freq : 1000 / 100, cancellationToken);
|
||||
}
|
||||
|
||||
if (cntFail >= 5)
|
||||
{
|
||||
logger.Error($"User {userName} boundary scan failed for device {jtagCtrl.address} after 5 attempts");
|
||||
throw new InvalidOperationException("Boundary scan failed");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public static class MsgBus
|
||||
/// <returns>无</returns>
|
||||
public async static void Init()
|
||||
{
|
||||
if (!Arp.IsAdministrator())
|
||||
if (!ArpClient.IsAdministrator())
|
||||
{
|
||||
logger.Error($"非管理员运行,ARP无法更新,请用管理员权限运行");
|
||||
// throw new Exception($"非管理员运行,ARP无法更新,请用管理员权限运行");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using DotNext;
|
||||
using Peripherals.PowerClient;
|
||||
using WebProtocol;
|
||||
|
||||
namespace Peripherals.CameraClient;
|
||||
|
||||
@@ -19,7 +20,7 @@ class Camera
|
||||
{
|
||||
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
readonly int timeout = 2000;
|
||||
readonly int timeout = 500;
|
||||
readonly int taskID;
|
||||
readonly int port;
|
||||
readonly string address;
|
||||
@@ -43,7 +44,7 @@ class Camera
|
||||
/// <param name="address">摄像头设备IP地址</param>
|
||||
/// <param name="port">摄像头设备端口</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
public Camera(string address, int port, int timeout = 2000)
|
||||
public Camera(string address, int port, int timeout = 500)
|
||||
{
|
||||
if (timeout < 0)
|
||||
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
|
||||
@@ -225,6 +226,7 @@ class Camera
|
||||
this.taskID, // taskID
|
||||
FrameAddr,
|
||||
(int)_currentFrameLength, // 使用当前分辨率的动态大小
|
||||
BurstType.ExtendBurst,
|
||||
this.timeout);
|
||||
|
||||
if (!result.IsSuccessful)
|
||||
@@ -462,6 +464,20 @@ class Camera
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置为960x540分辨率
|
||||
/// </summary>
|
||||
/// <returns>配置结果</returns>
|
||||
public async ValueTask<Result<bool>> ConfigureResolution960x540()
|
||||
{
|
||||
return await ConfigureResolution(
|
||||
hStart: 0, vStart: 0,
|
||||
dvpHo: 960, dvpVo: 540,
|
||||
hts: 1700, vts: 1500,
|
||||
hOffset: 16, vOffset: 4
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置为320x240分辨率
|
||||
/// </summary>
|
||||
@@ -543,6 +559,9 @@ class Camera
|
||||
case "640x480":
|
||||
result = await ConfigureResolution640x480();
|
||||
break;
|
||||
case "960x540":
|
||||
result = await ConfigureResolution960x540();
|
||||
break;
|
||||
case "1280x720":
|
||||
result = await ConfigureResolution1280x720();
|
||||
break;
|
||||
|
||||
118
server/src/Peripherals/HdmiInClient.cs
Normal file
118
server/src/Peripherals/HdmiInClient.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using System.Net;
|
||||
using DotNext;
|
||||
using Peripherals.PowerClient;
|
||||
using WebProtocol;
|
||||
|
||||
namespace Peripherals.HdmiInClient;
|
||||
|
||||
static class HdmiInAddr
|
||||
{
|
||||
public const UInt32 BASE = 0xA000_0000;
|
||||
public const UInt32 HdmiIn_CTRL = BASE + 0x0; //[0]: rstn, 0 is reset.
|
||||
public const UInt32 HdmiIn_READFIFO = BASE + 0x1;
|
||||
}
|
||||
|
||||
class HdmiIn
|
||||
{
|
||||
private static readonly NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger();
|
||||
|
||||
readonly int timeout = 500;
|
||||
readonly int taskID;
|
||||
readonly int port;
|
||||
readonly string address;
|
||||
private IPEndPoint ep;
|
||||
|
||||
// 动态分辨率参数
|
||||
private UInt16 _currentWidth = 960;
|
||||
private UInt16 _currentHeight = 540;
|
||||
private UInt32 _currentFrameLength = 960 * 540 * 2 / 4; // RGB565格式,2字节/像素,按4字节对齐
|
||||
|
||||
/// <summary>
|
||||
/// 初始化HDMI输入客户端
|
||||
/// </summary>
|
||||
/// <param name="address">HDMI输入设备IP地址</param>
|
||||
/// <param name="port">HDMI输入设备端口</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
public HdmiIn(string address, int port, int timeout = 500)
|
||||
{
|
||||
if (timeout < 0)
|
||||
throw new ArgumentException("Timeout couldn't be negative", nameof(timeout));
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
this.ep = new IPEndPoint(IPAddress.Parse(address), port);
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public async ValueTask<Result<bool>> EnableTrans(bool isEnable)
|
||||
{
|
||||
var ret = await UDPClientPool.WriteAddr(this.ep, this.taskID, HdmiInAddr.HdmiIn_CTRL, (isEnable ? 0x00000001u : 0x00000000u));
|
||||
if (!ret.IsSuccessful)
|
||||
{
|
||||
logger.Error($"Failed to write HdmiIn_CTRL to HdmiIn at {this.address}:{this.port}, error: {ret.Error}");
|
||||
return new(ret.Error);
|
||||
}
|
||||
if (!ret.Value)
|
||||
{
|
||||
logger.Error($"HdmiIn_CTRL write returned false for HdmiIn at {this.address}:{this.port}");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 读取一帧图像数据
|
||||
/// </summary>
|
||||
/// <returns>包含图像数据的字节数组</returns>
|
||||
public async ValueTask<Result<byte[]>> ReadFrame()
|
||||
{
|
||||
// 只在第一次或出错时清除UDP缓冲区,避免每帧都清除造成延迟
|
||||
// MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
|
||||
|
||||
logger.Trace($"Reading frame from HdmiIn {this.address}");
|
||||
|
||||
// 使用UDPClientPool读取图像帧数据
|
||||
var result = await UDPClientPool.ReadAddr4BytesAsync(
|
||||
this.ep,
|
||||
this.taskID, // taskID
|
||||
HdmiInAddr.HdmiIn_READFIFO,
|
||||
(int)_currentFrameLength, // 使用当前分辨率的动态大小
|
||||
BurstType.FixedBurst,
|
||||
this.timeout);
|
||||
|
||||
if (!result.IsSuccessful)
|
||||
{
|
||||
logger.Error($"Failed to read frame from HdmiIn {this.address}:{this.port}, error: {result.Error}");
|
||||
// 读取失败时清除缓冲区,为下次读取做准备
|
||||
try
|
||||
{
|
||||
MsgBus.UDPServer.ClearUDPData(this.address, this.taskID);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warn($"Failed to clear UDP data after read error: {ex.Message}");
|
||||
}
|
||||
return new(result.Error);
|
||||
}
|
||||
|
||||
logger.Trace($"Successfully read frame from HdmiIn {this.address}:{this.port}, data length: {result.Value.Length} bytes");
|
||||
return result.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前分辨率
|
||||
/// </summary>
|
||||
/// <returns>当前分辨率(宽度, 高度)</returns>
|
||||
public (int Width, int Height) GetCurrentResolution()
|
||||
{
|
||||
return (_currentWidth, _currentHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前帧长度
|
||||
/// </summary>
|
||||
/// <returns>当前帧长度</returns>
|
||||
public UInt32 GetCurrentFrameLength()
|
||||
{
|
||||
return _currentFrameLength;
|
||||
}
|
||||
}
|
||||
@@ -386,7 +386,10 @@ public class Jtag
|
||||
readonly int timeout;
|
||||
|
||||
readonly int port;
|
||||
readonly string address;
|
||||
/// <summary>
|
||||
/// Jtag控制器IP地址
|
||||
/// </summary>
|
||||
public readonly string address;
|
||||
private IPEndPoint ep;
|
||||
|
||||
/// <summary>
|
||||
@@ -436,7 +439,7 @@ public class Jtag
|
||||
if (retPackLen != 4)
|
||||
return new(new Exception($"RecvDataPackage BodyData Length not Equal to 4: Total {retPackLen} bytes"));
|
||||
|
||||
return Convert.ToUInt32(Common.Number.BytesToUInt64(retPackOpts.Data).Value);
|
||||
return Convert.ToUInt32(Common.Number.BytesToUInt32(retPackOpts.Data).Value);
|
||||
}
|
||||
|
||||
async ValueTask<Result<bool>> WriteFIFO
|
||||
@@ -609,13 +612,10 @@ public class Jtag
|
||||
if (ret.Value)
|
||||
{
|
||||
var array = new UInt32[UInt32Num];
|
||||
for (int i = 0; i < UInt32Num; i++)
|
||||
{
|
||||
var retData = await ReadFIFO(JtagAddr.READ_DATA);
|
||||
if (!retData.IsSuccessful)
|
||||
return new(new Exception("Read FIFO failed when Load DR"));
|
||||
array[i] = retData.Value;
|
||||
}
|
||||
var retData = await UDPClientPool.ReadAddr4Bytes(ep, 0, JtagAddr.READ_DATA, (int)UInt32Num);
|
||||
if (!retData.IsSuccessful)
|
||||
return new(new Exception("Read FIFO failed when Load DR"));
|
||||
Buffer.BlockCopy(retData.Value, 0, array, 0, (int)UInt32Num * 4);
|
||||
return array;
|
||||
}
|
||||
else
|
||||
@@ -785,7 +785,7 @@ public class Jtag
|
||||
{
|
||||
var paser = new BsdlParser.Parser();
|
||||
var portNum = paser.GetBoundaryRegsNum().Value;
|
||||
logger.Debug($"Get boundar scan registers number: {portNum}");
|
||||
logger.Debug($"Get boundary scan registers number: {portNum}");
|
||||
|
||||
// Clear Data
|
||||
MsgBus.UDPServer.ClearUDPData(this.address, 0);
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections;
|
||||
using System.Net;
|
||||
using Common;
|
||||
using DotNext;
|
||||
using WebProtocol;
|
||||
|
||||
namespace Peripherals.LogicAnalyzerClient;
|
||||
|
||||
@@ -475,6 +476,7 @@ public class Analyzer
|
||||
this.taskID,
|
||||
AnalyzerAddr.STORE_OFFSET_ADDR,
|
||||
capture_length,
|
||||
BurstType.ExtendBurst, // 使用扩展突发读取
|
||||
this.timeout
|
||||
);
|
||||
if (!ret.IsSuccessful)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using Common;
|
||||
using DotNext;
|
||||
using WebProtocol;
|
||||
|
||||
namespace Peripherals.OscilloscopeClient;
|
||||
|
||||
@@ -319,6 +320,7 @@ class Oscilloscope
|
||||
this.taskID,
|
||||
OscilloscopeAddr.RD_DATA_ADDR,
|
||||
(int)OscilloscopeAddr.RD_DATA_LENGTH / 32,
|
||||
BurstType.ExtendBurst, // 使用扩展突发读取
|
||||
this.timeout
|
||||
);
|
||||
if (!ret.IsSuccessful)
|
||||
|
||||
@@ -1109,6 +1109,7 @@ public class HttpVideoStreamService : BackgroundService
|
||||
return new List<(int, int, string)>
|
||||
{
|
||||
(640, 480, "640x480 (VGA)"),
|
||||
(960, 540, "960x540 (qHD)"),
|
||||
(1280, 720, "1280x720 (HD)"),
|
||||
(1280, 960, "1280x960 (SXGA)"),
|
||||
(1920, 1080, "1920x1080 (Full HD)")
|
||||
|
||||
@@ -336,7 +336,7 @@ public class UDPClientPool
|
||||
$"Device {address} receive data is {retData.Length} bytes instead of 4 bytes"));
|
||||
|
||||
// Check result
|
||||
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt64(retData).Value);
|
||||
var retCode = Convert.ToUInt32(Common.Number.BytesToUInt32(retData).Value);
|
||||
if (Common.Number.BitsCheck(retCode, result, resultMask)) return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
@@ -433,11 +433,12 @@ public class UDPClientPool
|
||||
/// <param name="endPoint">IP端点(IP地址与端口)</param>
|
||||
/// <param name="taskID">任务ID</param>
|
||||
/// <param name="devAddr">设备地址</param>
|
||||
/// <param name="burstType">突发类型</param>
|
||||
/// <param name="dataLength">要读取的数据长度(4字节)</param>
|
||||
/// <param name="timeout">超时时间(毫秒)</param>
|
||||
/// <returns>读取结果,包含接收到的字节数组</returns>
|
||||
public static async ValueTask<Result<byte[]>> ReadAddr4BytesAsync(
|
||||
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, int timeout = 1000)
|
||||
IPEndPoint endPoint, int taskID, UInt32 devAddr, int dataLength, BurstType burstType, int timeout = 1000)
|
||||
{
|
||||
var pkgList = new List<SendAddrPackage>();
|
||||
var resultData = new List<byte>();
|
||||
@@ -460,7 +461,7 @@ public class UDPClientPool
|
||||
|
||||
var opts = new SendAddrPackOptions
|
||||
{
|
||||
BurstType = BurstType.FixedBurst,
|
||||
BurstType = burstType,
|
||||
CommandID = Convert.ToByte(taskID),
|
||||
IsWrite = false,
|
||||
BurstLength = (byte)(currentSegmentSize - 1),
|
||||
|
||||
841
src/APIClient.ts
841
src/APIClient.ts
File diff suppressed because it is too large
Load Diff
39
src/App.vue
39
src/App.vue
@@ -12,6 +12,14 @@ const isDarkMode = ref(
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||
);
|
||||
|
||||
// Navbar显示状态管理
|
||||
const showNavbar = ref(true);
|
||||
|
||||
// 切换Navbar显示状态
|
||||
const toggleNavbar = () => {
|
||||
showNavbar.value = !showNavbar.value;
|
||||
};
|
||||
|
||||
// 初始化主题设置
|
||||
onMounted(() => {
|
||||
// 应用初始主题
|
||||
@@ -47,6 +55,12 @@ provide("theme", {
|
||||
toggleTheme,
|
||||
});
|
||||
|
||||
// 提供Navbar控制给子组件
|
||||
provide("navbar", {
|
||||
showNavbar,
|
||||
toggleNavbar,
|
||||
});
|
||||
|
||||
const currentRoutePath = computed(() => {
|
||||
return router.currentRoute.value.path;
|
||||
});
|
||||
@@ -56,8 +70,8 @@ useAlertProvider();
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="relative">
|
||||
<Navbar />
|
||||
<header class="relative" :class="{ 'navbar-hidden': !showNavbar }">
|
||||
<Navbar v-show="showNavbar" />
|
||||
<Dialog />
|
||||
<Alert />
|
||||
</header>
|
||||
@@ -79,4 +93,25 @@ useAlertProvider();
|
||||
|
||||
<style scoped>
|
||||
/* 特定于App.vue的样式 */
|
||||
header {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.navbar-hidden {
|
||||
transform: scaleY(0);
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Navbar显示/隐藏动画 */
|
||||
header .navbar {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
/* 当header被隐藏时,确保navbar也相应变化 */
|
||||
.navbar-hidden .navbar {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
118
src/TypedSignalR.Client/index.ts
Normal file
118
src/TypedSignalR.Client/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/* THIS (.ts) FILE IS GENERATED BY TypedSignalR.Client.TypeScript */
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
import type { HubConnection, IStreamResult, Subject } from '@microsoft/signalr';
|
||||
import type { IJtagHub, IJtagReceiver } from './server.Hubs.JtagHub';
|
||||
|
||||
|
||||
// components
|
||||
|
||||
export type Disposable = {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export type HubProxyFactory<T> = {
|
||||
createHubProxy(connection: HubConnection): T;
|
||||
}
|
||||
|
||||
export type ReceiverRegister<T> = {
|
||||
register(connection: HubConnection, receiver: T): Disposable;
|
||||
}
|
||||
|
||||
type ReceiverMethod = {
|
||||
methodName: string,
|
||||
method: (...args: any[]) => void
|
||||
}
|
||||
|
||||
class ReceiverMethodSubscription implements Disposable {
|
||||
|
||||
public constructor(
|
||||
private connection: HubConnection,
|
||||
private receiverMethod: ReceiverMethod[]) {
|
||||
}
|
||||
|
||||
public readonly dispose = () => {
|
||||
for (const it of this.receiverMethod) {
|
||||
this.connection.off(it.methodName, it.method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API
|
||||
|
||||
export type HubProxyFactoryProvider = {
|
||||
(hubType: "IJtagHub"): HubProxyFactory<IJtagHub>;
|
||||
}
|
||||
|
||||
export const getHubProxyFactory = ((hubType: string) => {
|
||||
if(hubType === "IJtagHub") {
|
||||
return IJtagHub_HubProxyFactory.Instance;
|
||||
}
|
||||
}) as HubProxyFactoryProvider;
|
||||
|
||||
export type ReceiverRegisterProvider = {
|
||||
(receiverType: "IJtagReceiver"): ReceiverRegister<IJtagReceiver>;
|
||||
}
|
||||
|
||||
export const getReceiverRegister = ((receiverType: string) => {
|
||||
if(receiverType === "IJtagReceiver") {
|
||||
return IJtagReceiver_Binder.Instance;
|
||||
}
|
||||
}) as ReceiverRegisterProvider;
|
||||
|
||||
// HubProxy
|
||||
|
||||
class IJtagHub_HubProxyFactory implements HubProxyFactory<IJtagHub> {
|
||||
public static Instance = new IJtagHub_HubProxyFactory();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
public readonly createHubProxy = (connection: HubConnection): IJtagHub => {
|
||||
return new IJtagHub_HubProxy(connection);
|
||||
}
|
||||
}
|
||||
|
||||
class IJtagHub_HubProxy implements IJtagHub {
|
||||
|
||||
public constructor(private connection: HubConnection) {
|
||||
}
|
||||
|
||||
public readonly setBoundaryScanFreq = async (freq: number): Promise<boolean> => {
|
||||
return await this.connection.invoke("SetBoundaryScanFreq", freq);
|
||||
}
|
||||
|
||||
public readonly startBoundaryScan = async (freq: number): Promise<boolean> => {
|
||||
return await this.connection.invoke("StartBoundaryScan", freq);
|
||||
}
|
||||
|
||||
public readonly stopBoundaryScan = async (): Promise<boolean> => {
|
||||
return await this.connection.invoke("StopBoundaryScan");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Receiver
|
||||
|
||||
class IJtagReceiver_Binder implements ReceiverRegister<IJtagReceiver> {
|
||||
|
||||
public static Instance = new IJtagReceiver_Binder();
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
public readonly register = (connection: HubConnection, receiver: IJtagReceiver): Disposable => {
|
||||
|
||||
const __onReceiveBoundaryScanData = (...args: [Partial<Record<string, boolean>>]) => receiver.onReceiveBoundaryScanData(...args);
|
||||
|
||||
connection.on("OnReceiveBoundaryScanData", __onReceiveBoundaryScanData);
|
||||
|
||||
const methodList: ReceiverMethod[] = [
|
||||
{ methodName: "OnReceiveBoundaryScanData", method: __onReceiveBoundaryScanData }
|
||||
]
|
||||
|
||||
return new ReceiverMethodSubscription(connection, methodList);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/TypedSignalR.Client/server.Hubs.JtagHub.ts
Normal file
31
src/TypedSignalR.Client/server.Hubs.JtagHub.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/* THIS (.ts) FILE IS GENERATED BY TypedSignalR.Client.TypeScript */
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
import type { IStreamResult, Subject } from '@microsoft/signalr';
|
||||
|
||||
export type IJtagHub = {
|
||||
/**
|
||||
* @param freq Transpiled from int
|
||||
* @returns Transpiled from System.Threading.Tasks.Task<bool>
|
||||
*/
|
||||
setBoundaryScanFreq(freq: number): Promise<boolean>;
|
||||
/**
|
||||
* @param freq Transpiled from int
|
||||
* @returns Transpiled from System.Threading.Tasks.Task<bool>
|
||||
*/
|
||||
startBoundaryScan(freq: number): Promise<boolean>;
|
||||
/**
|
||||
* @returns Transpiled from System.Threading.Tasks.Task<bool>
|
||||
*/
|
||||
stopBoundaryScan(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export type IJtagReceiver = {
|
||||
/**
|
||||
* @param msg Transpiled from System.Collections.Generic.Dictionary<string, bool>
|
||||
* @returns Transpiled from System.Threading.Tasks.Task
|
||||
*/
|
||||
onReceiveBoundaryScanData(msg: Partial<Record<string, boolean>>): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fixed left-1/2 top-30 z-999 -translate-x-1/2">
|
||||
<div class="fixed left-1/2 top-30 z-[9999] -translate-x-1/2">
|
||||
<transition
|
||||
name="alert"
|
||||
enter-active-class="alert-enter-active"
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
componentManager.prepareComponentProps(
|
||||
component.attrs || {},
|
||||
component.id,
|
||||
props.examId,
|
||||
)
|
||||
"
|
||||
@update:bindKey="
|
||||
@@ -175,9 +176,7 @@ import {
|
||||
ref,
|
||||
reactive,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
computed,
|
||||
watch,
|
||||
provide,
|
||||
} from "vue";
|
||||
import { useEventListener } from "@vueuse/core";
|
||||
@@ -188,7 +187,6 @@ import { useAlertStore } from "@/components/Alert";
|
||||
// 导入 diagram 管理器
|
||||
import {
|
||||
loadDiagramData,
|
||||
saveDiagramData,
|
||||
updatePartPosition,
|
||||
updatePartAttribute,
|
||||
parseConnectionPin,
|
||||
@@ -217,6 +215,7 @@ const emit = defineEmits(["toggle-doc-panel", "open-components"]);
|
||||
// 定义组件接受的属性
|
||||
const props = defineProps<{
|
||||
showDocPanel?: boolean; // 添加属性接收文档面板的显示状态
|
||||
examId?: string; // 新增examId属性
|
||||
}>();
|
||||
|
||||
// 获取componentManager实例
|
||||
@@ -606,14 +605,13 @@ function onComponentDrag(e: MouseEvent) {
|
||||
|
||||
// 停止拖拽组件
|
||||
function stopComponentDrag() {
|
||||
// 如果有组件被拖拽,保存当前状态
|
||||
// 如果有组件被拖拽,仅清除拖拽状态(不保存)
|
||||
if (draggingComponentId.value) {
|
||||
draggingComponentId.value = null;
|
||||
}
|
||||
|
||||
isComponentDragEventActive.value = false;
|
||||
|
||||
saveDiagramData(diagramData.value);
|
||||
// 移除自动保存功能 - 不再自动保存到localStorage
|
||||
}
|
||||
|
||||
// 更新组件属性
|
||||
@@ -977,7 +975,8 @@ function exportDiagram() {
|
||||
onMounted(async () => {
|
||||
// 加载图表数据
|
||||
try {
|
||||
diagramData.value = await loadDiagramData();
|
||||
// 传入examId参数,让diagramManager处理动态加载
|
||||
diagramData.value = await loadDiagramData(props.examId);
|
||||
|
||||
// 预加载所有组件模块
|
||||
const componentTypes = new Set<string>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref, shallowRef, computed, reactive } from "vue";
|
||||
import { createInjectionState } from "@vueuse/core";
|
||||
import {
|
||||
saveDiagramData,
|
||||
type DiagramData,
|
||||
type DiagramPart,
|
||||
} from "./diagramManager";
|
||||
@@ -302,7 +301,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
|
||||
|
||||
// 使用 updateDiagramDataDirectly 避免触发加载状态
|
||||
canvasInstance.updateDiagramDataDirectly(currentData);
|
||||
saveDiagramData(currentData);
|
||||
// 移除自动保存功能
|
||||
|
||||
console.log("组件添加完成:", newComponent);
|
||||
|
||||
@@ -431,7 +430,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
|
||||
"=== 更新图表数据完成,新组件数量:",
|
||||
currentData.parts.length,
|
||||
);
|
||||
saveDiagramData(currentData);
|
||||
// 移除自动保存功能
|
||||
|
||||
return { success: true, message: `已添加 ${templateData.name} 模板` };
|
||||
} else {
|
||||
@@ -504,7 +503,7 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
|
||||
|
||||
canvasInstance.updateDiagramDataDirectly(currentData);
|
||||
|
||||
saveDiagramData(currentData);
|
||||
// 移除自动保存功能
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -763,11 +762,15 @@ const [useProvideComponentManager, useComponentManager] = createInjectionState(
|
||||
function prepareComponentProps(
|
||||
attrs: Record<string, any>,
|
||||
componentId?: string,
|
||||
examId?: string,
|
||||
): Record<string, any> {
|
||||
const result: Record<string, any> = { ...attrs };
|
||||
if (componentId) {
|
||||
result.componentId = componentId;
|
||||
}
|
||||
if (examId) {
|
||||
result.examId = examId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface DiagramPart {
|
||||
// 连接类型定义 - 使用元组类型表示四元素数组
|
||||
export type ConnectionArray = [string, string, number, string[]];
|
||||
|
||||
import { AuthManager } from '@/utils/AuthManager';
|
||||
|
||||
// 解析连接字符串为组件ID和引脚ID
|
||||
export function parseConnectionPin(connectionPin: string): { componentId: string; pinId: string } {
|
||||
const [componentId, pinId] = connectionPin.split(':');
|
||||
@@ -80,22 +82,62 @@ export interface WireItem {
|
||||
showLabel: boolean;
|
||||
}
|
||||
|
||||
// 从本地存储加载图表数据
|
||||
export async function loadDiagramData(): Promise<DiagramData> {
|
||||
// 从本地存储或动态API加载图表数据
|
||||
export async function loadDiagramData(examId?: string): Promise<DiagramData> {
|
||||
try {
|
||||
// 先尝试从本地存储加载
|
||||
const savedData = localStorage.getItem('diagramData');
|
||||
if (savedData) {
|
||||
return JSON.parse(savedData);
|
||||
// 如果提供了examId,优先从API加载实验的diagram
|
||||
if (examId) {
|
||||
try {
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
|
||||
// 获取diagram类型的资源列表
|
||||
const resources = await resourceClient.getResourceList(examId, 'canvas', 'template');
|
||||
|
||||
if (resources && resources.length > 0) {
|
||||
// 获取第一个diagram资源
|
||||
const diagramResource = resources[0];
|
||||
|
||||
// 使用动态API获取资源文件内容
|
||||
const response = await resourceClient.getResourceById(diagramResource.id);
|
||||
|
||||
if (response && response.data) {
|
||||
const text = await response.data.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// 验证数据格式
|
||||
const validation = validateDiagramData(data);
|
||||
if (validation.isValid) {
|
||||
console.log('成功从API加载实验diagram:', examId);
|
||||
return data;
|
||||
} else {
|
||||
console.warn('API返回的diagram数据格式无效:', validation.errors);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('未找到实验diagram资源,使用默认加载方式');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('从API加载实验diagram失败,使用默认加载方式:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果本地存储没有,从文件加载
|
||||
// 如果没有examId或API加载失败,尝试从静态文件加载(不再使用本地存储)
|
||||
|
||||
// 从静态文件加载(作为备选方案)
|
||||
const response = await fetch('/src/components/diagram.json');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load diagram.json: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
|
||||
// 验证静态文件数据
|
||||
const validation = validateDiagramData(data);
|
||||
if (validation.isValid) {
|
||||
return data;
|
||||
} else {
|
||||
console.warn('静态diagram文件数据格式无效:', validation.errors);
|
||||
throw new Error('所有diagram数据源都无效');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading diagram data:', error);
|
||||
// 返回空的默认数据结构
|
||||
@@ -114,13 +156,10 @@ export function createEmptyDiagram(): DiagramData {
|
||||
};
|
||||
}
|
||||
|
||||
// 保存图表数据到本地存储
|
||||
// 保存图表数据(已禁用本地存储)
|
||||
export function saveDiagramData(data: DiagramData): void {
|
||||
try {
|
||||
localStorage.setItem('diagramData', JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('Error saving diagram data:', error);
|
||||
}
|
||||
// 本地存储功能已禁用 - 不再保存到localStorage
|
||||
console.debug('saveDiagramData called but localStorage saving is disabled');
|
||||
}
|
||||
|
||||
// 更新组件位置
|
||||
|
||||
@@ -588,9 +588,9 @@ const [useProvideLogicAnalyzer, useLogicAnalyzerState] = createInjectionState(
|
||||
};
|
||||
|
||||
const forceCapture = async () => {
|
||||
// 检查是否有其他操作正在进行
|
||||
if (operationMutex.isLocked()) {
|
||||
alert.warn("有其他操作正在进行中,请稍后再试", 3000);
|
||||
// 检查是否正在捕获
|
||||
if (!isCapturing.value) {
|
||||
alert.warn("当前没有正在进行的捕获操作", 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<button class="btn transition-transform duration-150 ease-in-out hover:scale-120">
|
||||
Button A
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup></script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
@import "@/assets/main.css";
|
||||
</style>
|
||||
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="card card-dash shadow-xl h-screen"
|
||||
:class="[sidebar.isClose ? 'w-31' : 'w-80']"
|
||||
>
|
||||
<div
|
||||
class="card-body flex relative transition-all duration-500 ease-in-out"
|
||||
>
|
||||
<!-- Avatar and Name -->
|
||||
<div class="relative" :class="sidebar.isClose ? 'h-50' : 'h-20'">
|
||||
<!-- Img -->
|
||||
<div
|
||||
class="avatar h-10 fixed top-10"
|
||||
:class="sidebar.isClose ? 'left-10' : 'left-7'"
|
||||
>
|
||||
<div class="rounded-full">
|
||||
<img src="../assets/user.svg" alt="User" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Text -->
|
||||
<Transition>
|
||||
<div v-if="!sidebar.isClose" class="mx-5 grow fixed left-20 top-11">
|
||||
<label class="text-2xl">用户名</label>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button -->
|
||||
<button
|
||||
class="btn btn-square rounded-lg p-2 m-3 fixed"
|
||||
:class="sidebar.isClose ? 'left-7 top-23' : 'left-60 top-7'"
|
||||
@click="sidebar.toggleSidebar"
|
||||
>
|
||||
<svg
|
||||
t="1741694970690"
|
||||
:class="sidebar.isClose ? 'rotate-0' : 'rotate-540'"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="4546"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M803.758 514.017c-0.001-0.311-0.013-0.622-0.018-0.933-0.162-23.974-9.386-47.811-27.743-65.903-0.084-0.082-0.172-0.157-0.256-0.239-0.154-0.154-0.296-0.315-0.451-0.468L417.861 94.096c-37.685-37.153-99.034-37.476-136.331-0.718-37.297 36.758-36.979 97.231 0.707 134.384l290.361 286.257-290.362 286.257c-37.685 37.153-38.004 97.625-0.707 134.383 37.297 36.758 98.646 36.435 136.331-0.718l357.43-352.378c0.155-0.153 0.297-0.314 0.451-0.468 0.084-0.082 0.172-0.157 0.256-0.239 18.354-18.089 27.578-41.922 27.743-65.892 0.004-0.315 0.017-0.631 0.018-0.947z"
|
||||
:fill="theme.isLightTheme() ? '#828282' : '#C0C3C8'"
|
||||
p-id="4547"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<ul class="menu h-full w-full">
|
||||
<li v-for="item in props.items" class="text-xl my-1">
|
||||
<a @click="router.push(item.page)">
|
||||
<svg
|
||||
t="1741694797806"
|
||||
class="icon h-[1.5em] w-[1.5em] opacity-50 mx-1"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="2622"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="200"
|
||||
height="200"
|
||||
>
|
||||
<path
|
||||
d="M192 192m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
|
||||
p-id="2623"
|
||||
></path>
|
||||
<path
|
||||
d="M192 512m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
|
||||
p-id="2624"
|
||||
></path>
|
||||
<path
|
||||
d="M192 832m-64 0a64 64 0 1 0 128 0 64 64 0 1 0-128 0Z"
|
||||
p-id="2625"
|
||||
></path>
|
||||
<path
|
||||
d="M864 160H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 480H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32zM864 800H352c-17.7 0-32 14.3-32 32s14.3 32 32 32h512c17.7 0 32-14.3 32-32s-14.3-32-32-32z"
|
||||
p-id="2626"
|
||||
></path>
|
||||
</svg>
|
||||
<Transition>
|
||||
<p class="break-keep" v-if="!sidebar.isClose">{{ item.text }}</p>
|
||||
</Transition>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<ul class="menu w-full">
|
||||
<li>
|
||||
<a @click="theme.toggleTheme" class="text-xl">
|
||||
<ThemeControlButton />
|
||||
<Transition>
|
||||
<p v-if="!sidebar.isClose" class="break-keep">改变主题</p>
|
||||
</Transition>
|
||||
<Transition>
|
||||
<ThemeControlToggle v-if="!sidebar.isClose" />
|
||||
</Transition>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import iconMenu from "../assets/menu.svg";
|
||||
import "../router";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import { useSidebarStore } from "@/stores/sidebar";
|
||||
import ThemeControlButton from "./ThemeControlButton.vue";
|
||||
import ThemeControlToggle from "./ThemeControlToggle.vue";
|
||||
import router from "../router";
|
||||
|
||||
const theme = useThemeStore();
|
||||
const sidebar = useSidebarStore();
|
||||
|
||||
type Item = {
|
||||
id: number;
|
||||
icon: string;
|
||||
text: string;
|
||||
page: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
items?: Array<Item>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
items: () => [
|
||||
{ id: 1, icon: iconMenu, text: "btn1", page: "/" },
|
||||
{ id: 2, icon: iconMenu, text: "btn2", page: "/" },
|
||||
{ id: 3, icon: iconMenu, text: "btn3", page: "/" },
|
||||
{ id: 4, icon: iconMenu, text: "btn4", page: "/" },
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
@reference "../assets/main.css";
|
||||
|
||||
* {
|
||||
@apply transition-all duration-500 ease-in-out;
|
||||
}
|
||||
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,305 +1,325 @@
|
||||
<template>
|
||||
<div
|
||||
class="tutorial-carousel relative"
|
||||
@wheel.prevent="handleWheel"
|
||||
@mouseenter="pauseAutoRotation"
|
||||
@mouseleave="resumeAutoRotation"
|
||||
> <!-- 例程卡片堆叠 -->
|
||||
<div class="card-stack relative mx-auto">
|
||||
<div
|
||||
v-for="(tutorial, index) in tutorials"
|
||||
:key="index"
|
||||
class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
|
||||
:class="getCardClass(index)"
|
||||
:style="getCardStyle(index)"
|
||||
@click="handleCardClick(index, tutorial.id)"
|
||||
>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="relative">
|
||||
<!-- 图片 --> <img
|
||||
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`"
|
||||
class="w-full object-contain"
|
||||
:alt="tutorial.title"
|
||||
style="width: 600px; height: 400px;"
|
||||
/>
|
||||
|
||||
<!-- 卡片蒙层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
|
||||
:class="{'opacity-10': index === currentIndex}"
|
||||
></div>
|
||||
|
||||
<!-- 标题覆盖层 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
|
||||
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
|
||||
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航指示器 -->
|
||||
<div class="indicators flex justify-center gap-2 mt-4">
|
||||
<button
|
||||
v-for="(_, index) in tutorials"
|
||||
:key="index"
|
||||
@click="setActiveCard(index)"
|
||||
class="w-3 h-3 rounded-full transition-all duration-300"
|
||||
:class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// 接口定义
|
||||
interface Tutorial {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail?: string;
|
||||
docPath: string;
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
autoRotationInterval?: number;
|
||||
}>();
|
||||
|
||||
// 配置默认值
|
||||
const autoRotationInterval = props.autoRotationInterval || 5000; // 默认5秒
|
||||
|
||||
// 状态管理
|
||||
const tutorials = ref<Tutorial[]>([]);
|
||||
const currentIndex = ref(0);
|
||||
const router = useRouter();
|
||||
let autoRotationTimer: number | null = null;
|
||||
|
||||
// 处理卡片点击
|
||||
const handleCardClick = (index: number, tutorialId: string) => {
|
||||
if (index === currentIndex.value) {
|
||||
goToTutorial(tutorialId);
|
||||
} else {
|
||||
setActiveCard(index);
|
||||
}
|
||||
};
|
||||
|
||||
// 从 public/doc 目录加载例程信息
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 尝试从API获取教程目录
|
||||
let tutorialIds: string[] = [];
|
||||
try {
|
||||
const response = await fetch('/api/tutorial');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
tutorialIds = data.tutorials || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('无法从API获取教程目录,使用默认值:', error);
|
||||
}
|
||||
|
||||
// 如果API调用失败或返回空列表,使用默认值
|
||||
if (tutorialIds.length === 0) {
|
||||
console.log('使用默认教程列表');
|
||||
tutorialIds = ['01', '02', '03', '04', '05', '06', '11', '12', '13']; // 默认例程
|
||||
} else {
|
||||
console.log('使用API获取的教程列表:', tutorialIds);
|
||||
}
|
||||
|
||||
// 为每个例程创建对象并尝试获取文档标题
|
||||
const tutorialPromises = tutorialIds.map(async (id) => {
|
||||
// 尝试读取doc.md获取标题
|
||||
let title = `例程 ${id}`;
|
||||
let description = "点击加载此例程";
|
||||
let thumbnail = `/doc/${id}/cover.png`; // 默认使用第一张图片作为缩略图
|
||||
|
||||
try {
|
||||
// 尝试读取文档内容获取标题
|
||||
const response = await fetch(`/doc/${id}/doc.md`);
|
||||
if (response.ok) {
|
||||
const text = await response.text();
|
||||
// 从Markdown提取标题
|
||||
const titleMatch = text.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// 提取第一段作为描述
|
||||
const descMatch = text.match(/\n\n([^#\n][^\n]+)/);
|
||||
if (descMatch && descMatch[1]) {
|
||||
description = descMatch[1].substring(0, 100).trim();
|
||||
if (description.length === 100) description += '...';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`无法读取例程${id}的文档内容:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
thumbnail,
|
||||
docPath: `/doc/${id}/doc.md`
|
||||
};
|
||||
});
|
||||
|
||||
tutorials.value = await Promise.all(tutorialPromises);
|
||||
|
||||
// 启动自动旋转
|
||||
startAutoRotation();
|
||||
} catch (error) {
|
||||
console.error('加载例程失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// 在组件销毁时清除计时器
|
||||
onUnmounted(() => {
|
||||
if (autoRotationTimer) {
|
||||
clearInterval(autoRotationTimer);
|
||||
}
|
||||
});
|
||||
|
||||
// 鼠标滚轮处理
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY > 0) {
|
||||
nextCard();
|
||||
} else {
|
||||
prevCard();
|
||||
}
|
||||
};
|
||||
|
||||
// 下一张卡片
|
||||
const nextCard = () => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
|
||||
};
|
||||
|
||||
// 上一张卡片
|
||||
const prevCard = () => {
|
||||
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
|
||||
};
|
||||
|
||||
// 设置活动卡片
|
||||
const setActiveCard = (index: number) => {
|
||||
currentIndex.value = index;
|
||||
};
|
||||
|
||||
// 自动旋转
|
||||
const startAutoRotation = () => {
|
||||
autoRotationTimer = window.setInterval(() => {
|
||||
nextCard();
|
||||
}, autoRotationInterval);
|
||||
};
|
||||
|
||||
// 暂停自动旋转
|
||||
const pauseAutoRotation = () => {
|
||||
if (autoRotationTimer) {
|
||||
clearInterval(autoRotationTimer);
|
||||
autoRotationTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复自动旋转
|
||||
const resumeAutoRotation = () => {
|
||||
if (!autoRotationTimer) {
|
||||
startAutoRotation();
|
||||
}
|
||||
};
|
||||
|
||||
// 前往例程
|
||||
const goToTutorial = (tutorialId: string) => {
|
||||
// 跳转到工程页面,并通过 query 参数传递文档路径
|
||||
router.push({
|
||||
path: '/project',
|
||||
query: { tutorial: tutorialId }
|
||||
});
|
||||
};
|
||||
|
||||
// 计算卡片类和样式
|
||||
const getCardClass = (index: number) => {
|
||||
const isActive = index === currentIndex.value;
|
||||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||||
|
||||
return {
|
||||
'z-30': isActive,
|
||||
'z-20': isPrev || isNext,
|
||||
'z-10': !isActive && !isPrev && !isNext,
|
||||
'hover:scale-105': isActive,
|
||||
'cursor-pointer': true
|
||||
};
|
||||
};
|
||||
|
||||
const getCardStyle = (index: number) => {
|
||||
const isActive = index === currentIndex.value;
|
||||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||||
|
||||
// 基本样式
|
||||
let style = {
|
||||
transform: 'scale(1) translateY(0) rotate(0deg)',
|
||||
opacity: '1',
|
||||
filter: 'blur(0)'
|
||||
};
|
||||
|
||||
// 活动卡片
|
||||
if (isActive) {
|
||||
return style;
|
||||
}
|
||||
|
||||
// 上一张卡片
|
||||
if (isPrev) {
|
||||
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
|
||||
style.opacity = '0.7';
|
||||
style.filter = 'blur(1px)';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 下一张卡片
|
||||
if (isNext) {
|
||||
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
|
||||
style.opacity = '0.7';
|
||||
style.filter = 'blur(1px)';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 其他卡片
|
||||
style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
|
||||
style.opacity = '0.4';
|
||||
style.filter = 'blur(2px)';
|
||||
return style;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-carousel {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
perspective: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-stack {
|
||||
width: 600px;
|
||||
height: 440px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background-color: hsl(var(--b2));
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tutorial-card:hover {
|
||||
box-shadow: 0 0 15px rgba(var(--p), 0.5);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div
|
||||
class="tutorial-carousel relative"
|
||||
@wheel.prevent="handleWheel"
|
||||
@mouseenter="pauseAutoRotation"
|
||||
@mouseleave="resumeAutoRotation"
|
||||
> <!-- 例程卡片堆叠 -->
|
||||
<div class="card-stack relative mx-auto">
|
||||
<div
|
||||
v-for="(tutorial, index) in tutorials"
|
||||
:key="index"
|
||||
class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
|
||||
:class="getCardClass(index)"
|
||||
:style="getCardStyle(index)"
|
||||
@click="handleCardClick(index, tutorial.id)"
|
||||
>
|
||||
<!-- 卡片内容 -->
|
||||
<div class="relative">
|
||||
<!-- 图片 --> <img
|
||||
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`"
|
||||
class="w-full object-contain"
|
||||
:alt="tutorial.title"
|
||||
style="width: 600px; height: 400px;"
|
||||
/>
|
||||
|
||||
<!-- 卡片蒙层 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
|
||||
:class="{'opacity-10': index === currentIndex}"
|
||||
></div>
|
||||
|
||||
<!-- 标题覆盖层 -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
|
||||
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
|
||||
<!-- 标签显示 -->
|
||||
<div v-if="tutorial.tags && tutorial.tags.length > 0" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="tag in tutorial.tags.slice(0, 3)"
|
||||
:key="tag"
|
||||
class="badge badge-outline badge-xs text-xs"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导航指示器 -->
|
||||
<div class="indicators flex justify-center gap-2 mt-4">
|
||||
<button
|
||||
v-for="(_, index) in tutorials"
|
||||
:key="index"
|
||||
@click="setActiveCard(index)"
|
||||
class="w-3 h-3 rounded-full transition-all duration-300"
|
||||
:class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AuthManager } from '@/utils/AuthManager';
|
||||
import type { ExamSummary } from '@/APIClient';
|
||||
|
||||
// 接口定义
|
||||
interface Tutorial {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
autoRotationInterval?: number;
|
||||
}>();
|
||||
|
||||
// 配置默认值
|
||||
const autoRotationInterval = props.autoRotationInterval || 5000; // 默认5秒
|
||||
|
||||
// 状态管理
|
||||
const tutorials = ref<Tutorial[]>([]);
|
||||
const currentIndex = ref(0);
|
||||
const router = useRouter();
|
||||
let autoRotationTimer: number | null = null;
|
||||
|
||||
// 处理卡片点击
|
||||
const handleCardClick = (index: number, tutorialId: string) => {
|
||||
if (index === currentIndex.value) {
|
||||
goToExam(tutorialId);
|
||||
} else {
|
||||
setActiveCard(index);
|
||||
}
|
||||
};
|
||||
|
||||
// 从数据库加载实验数据
|
||||
onMounted(async () => {
|
||||
try {
|
||||
console.log('正在从数据库加载实验数据...');
|
||||
|
||||
// 创建认证客户端
|
||||
const client = AuthManager.createAuthenticatedExamClient();
|
||||
|
||||
// 获取实验列表
|
||||
const examList: ExamSummary[] = await client.getExamList();
|
||||
|
||||
// 筛选可见的实验并转换为Tutorial格式
|
||||
const visibleExams = examList
|
||||
.filter(exam => exam.isVisibleToUsers)
|
||||
.slice(0, 6); // 限制轮播显示最多6个实验
|
||||
|
||||
if (visibleExams.length === 0) {
|
||||
console.warn('没有找到可见的实验');
|
||||
return;
|
||||
}
|
||||
|
||||
// 转换数据格式并获取封面图片
|
||||
const tutorialPromises = visibleExams.map(async (exam) => {
|
||||
let thumbnail: string | undefined;
|
||||
|
||||
try {
|
||||
// 获取实验的封面资源(模板资源)
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template');
|
||||
if (resourceList && resourceList.length > 0) {
|
||||
// 使用第一个封面资源
|
||||
const coverResource = resourceList[0];
|
||||
const fileResponse = await resourceClient.getResourceById(coverResource.id);
|
||||
// 创建Blob URL作为缩略图
|
||||
thumbnail = URL.createObjectURL(fileResponse.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`无法获取实验${exam.id}的封面图片:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.name,
|
||||
description: '点击查看实验详情',
|
||||
thumbnail,
|
||||
tags: exam.tags || []
|
||||
};
|
||||
});
|
||||
|
||||
tutorials.value = await Promise.all(tutorialPromises);
|
||||
|
||||
console.log('成功加载实验数据:', tutorials.value.length, '个实验');
|
||||
|
||||
// 启动自动旋转
|
||||
startAutoRotation();
|
||||
} catch (error) {
|
||||
console.error('加载实验数据失败:', error);
|
||||
|
||||
// 如果加载失败,显示默认的占位内容
|
||||
tutorials.value = [{
|
||||
id: 'placeholder',
|
||||
title: '实验数据加载中...',
|
||||
description: '请稍后或刷新页面重试',
|
||||
thumbnail: undefined,
|
||||
tags: []
|
||||
}];
|
||||
}
|
||||
});
|
||||
|
||||
// 在组件销毁时清除计时器和Blob URLs
|
||||
onUnmounted(() => {
|
||||
if (autoRotationTimer) {
|
||||
clearInterval(autoRotationTimer);
|
||||
}
|
||||
|
||||
// 清理创建的Blob URLs
|
||||
tutorials.value.forEach(tutorial => {
|
||||
if (tutorial.thumbnail && tutorial.thumbnail.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(tutorial.thumbnail);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 鼠标滚轮处理
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY > 0) {
|
||||
nextCard();
|
||||
} else {
|
||||
prevCard();
|
||||
}
|
||||
};
|
||||
|
||||
// 下一张卡片
|
||||
const nextCard = () => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
|
||||
};
|
||||
|
||||
// 上一张卡片
|
||||
const prevCard = () => {
|
||||
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
|
||||
};
|
||||
|
||||
// 设置活动卡片
|
||||
const setActiveCard = (index: number) => {
|
||||
currentIndex.value = index;
|
||||
};
|
||||
|
||||
// 自动旋转
|
||||
const startAutoRotation = () => {
|
||||
autoRotationTimer = window.setInterval(() => {
|
||||
nextCard();
|
||||
}, autoRotationInterval);
|
||||
};
|
||||
|
||||
// 暂停自动旋转
|
||||
const pauseAutoRotation = () => {
|
||||
if (autoRotationTimer) {
|
||||
clearInterval(autoRotationTimer);
|
||||
autoRotationTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复自动旋转
|
||||
const resumeAutoRotation = () => {
|
||||
if (!autoRotationTimer) {
|
||||
startAutoRotation();
|
||||
}
|
||||
};
|
||||
|
||||
// 前往实验
|
||||
const goToExam = (examId: string) => {
|
||||
// 跳转到实验列表页面并传递examId参数,页面将自动打开对应的实验详情模态框
|
||||
router.push({
|
||||
path: '/exam',
|
||||
query: { examId: examId }
|
||||
});
|
||||
};
|
||||
|
||||
// 计算卡片类和样式
|
||||
const getCardClass = (index: number) => {
|
||||
const isActive = index === currentIndex.value;
|
||||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||||
|
||||
return {
|
||||
'z-30': isActive,
|
||||
'z-20': isPrev || isNext,
|
||||
'z-10': !isActive && !isPrev && !isNext,
|
||||
'hover:scale-105': isActive,
|
||||
'cursor-pointer': true
|
||||
};
|
||||
};
|
||||
|
||||
const getCardStyle = (index: number) => {
|
||||
const isActive = index === currentIndex.value;
|
||||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||||
|
||||
// 基本样式
|
||||
let style = {
|
||||
transform: 'scale(1) translateY(0) rotate(0deg)',
|
||||
opacity: '1',
|
||||
filter: 'blur(0)'
|
||||
};
|
||||
|
||||
// 活动卡片
|
||||
if (isActive) {
|
||||
return style;
|
||||
}
|
||||
|
||||
// 上一张卡片
|
||||
if (isPrev) {
|
||||
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
|
||||
style.opacity = '0.7';
|
||||
style.filter = 'blur(1px)';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 下一张卡片
|
||||
if (isNext) {
|
||||
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
|
||||
style.opacity = '0.7';
|
||||
style.filter = 'blur(1px)';
|
||||
return style;
|
||||
}
|
||||
|
||||
// 其他卡片
|
||||
style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
|
||||
style.opacity = '0.4';
|
||||
style.filter = 'blur(2px)';
|
||||
return style;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-carousel {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
perspective: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-stack {
|
||||
width: 600px;
|
||||
height: 440px;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.tutorial-card {
|
||||
width: 600px;
|
||||
height: 400px;
|
||||
background-color: hsl(var(--b2));
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.tutorial-card:hover {
|
||||
box-shadow: 0 0 15px rgba(var(--p), 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,64 @@
|
||||
<template>
|
||||
<div class="flex flex-col bg-base-100 justify-center items-center">
|
||||
<div class="flex flex-col bg-base-100 justify-center items-center gap-4">
|
||||
<!-- Title -->
|
||||
<h1 class="font-bold text-2xl">上传比特流文件</h1>
|
||||
<h1 class="font-bold text-2xl">比特流文件</h1>
|
||||
|
||||
<!-- 示例比特流下载区域 (仅在有examId时显示) -->
|
||||
<div v-if="examId && availableBitstreams.length > 0" class="w-full">
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend text-sm">示例比特流文件</legend>
|
||||
<div class="space-y-2">
|
||||
<div v-for="bitstream in availableBitstreams" :key="bitstream.id" class="flex items-center justify-between p-2 border-2 border-base-300 rounded-lg">
|
||||
<span class="text-sm">{{ bitstream.name }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="downloadExampleBitstream(bitstream)"
|
||||
class="btn btn-sm btn-secondary"
|
||||
:disabled="isDownloading || isProgramming"
|
||||
>
|
||||
<div v-if="isDownloading">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
下载中...
|
||||
</div>
|
||||
<div v-else>
|
||||
下载示例
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="programExampleBitstream(bitstream)"
|
||||
class="btn btn-sm btn-primary"
|
||||
:disabled="isDownloading || isProgramming || !uploadEvent"
|
||||
>
|
||||
<div v-if="isProgramming">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
烧录中...
|
||||
</div>
|
||||
<div v-else>
|
||||
直接烧录
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div v-if="examId && availableBitstreams.length > 0" class="divider">或</div>
|
||||
|
||||
<!-- Input File -->
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend text-sm">选择或拖拽上传文件</legend>
|
||||
<legend class="fieldset-legend text-sm">上传自定义比特流文件</legend>
|
||||
<input type="file" ref="fileInput" class="file-input w-full" @change="handleFileChange" />
|
||||
<label class="fieldset-label">文件最大容量: {{ maxMemory }}MB</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div class="card-actions w-full">
|
||||
<button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading">
|
||||
<button @click="handleClick" class="btn btn-primary grow" :disabled="isUploading || isProgramming">
|
||||
<div v-if="isUploading">
|
||||
<span class="loading loading-spinner"></span>
|
||||
下载中...
|
||||
上传中...
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ buttonText }}
|
||||
@@ -27,17 +70,20 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, useTemplateRef, onMounted } from "vue";
|
||||
import { AuthManager } from "@/utils/AuthManager"; // Adjust the path based on your project structure
|
||||
import { useDialogStore } from "@/stores/dialog";
|
||||
import { isNull, isUndefined } from "lodash";
|
||||
|
||||
interface Props {
|
||||
uploadEvent?: (file: File) => Promise<boolean>;
|
||||
downloadEvent?: () => Promise<boolean>;
|
||||
uploadEvent?: (file: File, examId: string) => Promise<number | null>;
|
||||
downloadEvent?: (bitstreamId: number) => Promise<boolean>;
|
||||
maxMemory?: number;
|
||||
examId?: string; // 新增examId属性
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxMemory: 4,
|
||||
examId: '',
|
||||
});
|
||||
|
||||
const emits = defineEmits<{
|
||||
@@ -47,6 +93,10 @@ const emits = defineEmits<{
|
||||
const dialog = useDialogStore();
|
||||
|
||||
const isUploading = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
const isProgramming = ref(false);
|
||||
const availableBitstreams = ref<{id: number, name: string}[]>([]);
|
||||
|
||||
const buttonText = computed(() => {
|
||||
return isUndefined(props.downloadEvent) ? "上传" : "上传并下载";
|
||||
});
|
||||
@@ -56,14 +106,97 @@ const bitstream = defineModel("bitstreamFile", {
|
||||
type: File,
|
||||
default: undefined,
|
||||
});
|
||||
onMounted(() => {
|
||||
|
||||
// 初始化时加载示例比特流
|
||||
onMounted(async () => {
|
||||
if (!isUndefined(bitstream.value) && !isNull(fileInput.value)) {
|
||||
let fileList = new DataTransfer();
|
||||
fileList.items.add(bitstream.value);
|
||||
fileInput.value.files = fileList.files;
|
||||
}
|
||||
|
||||
await loadAvailableBitstreams();
|
||||
});
|
||||
|
||||
// 加载可用的比特流文件列表
|
||||
async function loadAvailableBitstreams() {
|
||||
console.log('加载可用比特流文件,examId:', props.examId);
|
||||
if (!props.examId) {
|
||||
availableBitstreams.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
// 使用新的ResourceClient API获取比特流模板资源列表
|
||||
const resources = await resourceClient.getResourceList(props.examId, 'bitstream', 'template');
|
||||
availableBitstreams.value = resources.map(r => ({ id: r.id, name: r.name })) || [];
|
||||
} catch (error) {
|
||||
console.error('加载比特流列表失败:', error);
|
||||
availableBitstreams.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 下载示例比特流
|
||||
async function downloadExampleBitstream(bitstream: {id: number, name: string}) {
|
||||
if (isDownloading.value) return;
|
||||
|
||||
isDownloading.value = true;
|
||||
try {
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
|
||||
// 使用新的ResourceClient API获取资源文件
|
||||
const response = await resourceClient.getResourceById(bitstream.id);
|
||||
|
||||
if (response && response.data) {
|
||||
// 创建下载链接
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = response.fileName || bitstream.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
dialog.info("示例比特流下载成功");
|
||||
} else {
|
||||
dialog.error("下载失败:响应数据为空");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载示例比特流失败:', error);
|
||||
dialog.error("下载示例比特流失败");
|
||||
} finally {
|
||||
isDownloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 直接烧录示例比特流
|
||||
async function programExampleBitstream(bitstream: {id: number, name: string}) {
|
||||
if (isProgramming.value) return;
|
||||
|
||||
isProgramming.value = true;
|
||||
try {
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
|
||||
if (props.downloadEvent) {
|
||||
const downloadSuccess = await props.downloadEvent(bitstream.id);
|
||||
if (downloadSuccess) {
|
||||
dialog.info("示例比特流烧录成功");
|
||||
} else {
|
||||
dialog.error("烧录失败");
|
||||
}
|
||||
} else {
|
||||
dialog.info("示例比特流props.downloadEvent未定义 无法烧录");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('烧录示例比特流失败:', error);
|
||||
dialog.error("烧录示例比特流失败");
|
||||
} finally {
|
||||
isProgramming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0]; // 获取选中的第一个文件
|
||||
@@ -85,6 +218,7 @@ function checkFile(file: File): boolean {
|
||||
}
|
||||
|
||||
async function handleClick(event: Event): Promise<void> {
|
||||
console.log("上传按钮被点击");
|
||||
if (isNull(bitstream.value) || isUndefined(bitstream.value)) {
|
||||
dialog.error(`未选择文件`);
|
||||
return;
|
||||
@@ -97,19 +231,21 @@ async function handleClick(event: Event): Promise<void> {
|
||||
}
|
||||
|
||||
isUploading.value = true;
|
||||
let uploadedBitstreamId: number | null = null;
|
||||
try {
|
||||
const ret = await props.uploadEvent(bitstream.value);
|
||||
console.log("开始上传比特流文件:", bitstream.value.name);
|
||||
const bitstreamId = await props.uploadEvent(bitstream.value, props.examId || '');
|
||||
console.log("上传结果,ID:", bitstreamId);
|
||||
if (isUndefined(props.downloadEvent)) {
|
||||
if (ret) {
|
||||
dialog.info("上传成功");
|
||||
emits("finishedUpload", bitstream.value);
|
||||
} else dialog.error("上传失败");
|
||||
return;
|
||||
}
|
||||
if (!ret) {
|
||||
console.log("上传成功,下载未定义");
|
||||
isUploading.value = false;
|
||||
return;
|
||||
}
|
||||
if (bitstreamId === null || bitstreamId === undefined) {
|
||||
isUploading.value = false;
|
||||
return;
|
||||
}
|
||||
uploadedBitstreamId = bitstreamId;
|
||||
} catch (e) {
|
||||
dialog.error("上传失败");
|
||||
console.error(e);
|
||||
@@ -118,9 +254,14 @@ async function handleClick(event: Event): Promise<void> {
|
||||
|
||||
// Download
|
||||
try {
|
||||
const ret = await props.downloadEvent();
|
||||
if (ret) dialog.info("下载成功");
|
||||
else dialog.error("下载失败");
|
||||
console.log("开始下载比特流,ID:", uploadedBitstreamId);
|
||||
if (uploadedBitstreamId === null || uploadedBitstreamId === undefined) {
|
||||
dialog.error("uploadedBitstreamId is null or undefined");
|
||||
} else {
|
||||
const ret = await props.downloadEvent(uploadedBitstreamId);
|
||||
if (ret) dialog.info("下载成功");
|
||||
else dialog.error("下载失败");
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.error("下载失败");
|
||||
console.error(e);
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
to="#ComponentCapabilities"
|
||||
v-if="selectecComponentID === props.componentId"
|
||||
>
|
||||
<MotherBoardCaps :jtagFreq="jtagFreq" @change-jtag-freq="changeJtagFreq" />
|
||||
<MotherBoardCaps :jtagFreq="jtagFreq" :exam-id="examId" @change-jtag-freq="changeJtagFreq" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +41,7 @@ import { toNumber } from "lodash";
|
||||
export interface MotherBoardProps {
|
||||
size: number;
|
||||
componentId?: string;
|
||||
examId?: string; // 新增examId属性
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -8,20 +8,35 @@
|
||||
<p>
|
||||
IDCode: 0x{{ jtagIDCode.toString(16).padStart(8, "0").toUpperCase() }}
|
||||
</p>
|
||||
<button class="btn btn-circle w-6 h-6" :disabled="isGettingIDCode" :onclick="getIDCode">
|
||||
<RefreshCcwIcon class="icon" :class="{ 'animate-spin': isGettingIDCode }" />
|
||||
<button
|
||||
class="btn btn-circle w-6 h-6"
|
||||
:disabled="isGettingIDCode"
|
||||
:onclick="getIDCode"
|
||||
>
|
||||
<RefreshCcwIcon
|
||||
class="icon"
|
||||
:class="{ 'animate-spin': isGettingIDCode }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<UploadCard class="bg-base-200" :upload-event="eqps.jtagUploadBitstream"
|
||||
:download-event="eqps.jtagDownloadBitstream" :bitstream-file="eqps.jtagBitstream"
|
||||
@update:bitstream-file="handleBitstreamChange">
|
||||
<UploadCard
|
||||
:exam-id="props.examId"
|
||||
:upload-event="eqps.jtagUploadBitstream"
|
||||
:download-event="handleDownloadBitstream"
|
||||
:bitstream-file="eqps.jtagBitstream"
|
||||
@update:bitstream-file="handleBitstreamChange"
|
||||
>
|
||||
</UploadCard>
|
||||
<div class="divider"></div>
|
||||
<div class="w-full">
|
||||
<legend class="fieldset-legend text-sm mb-0.3">Jtag运行频率</legend>
|
||||
<select class="select w-full" @change="handleSelectJtagSpeed" :value="props.jtagFreq">
|
||||
<select
|
||||
class="select w-full"
|
||||
@change="handleSelectJtagSpeed"
|
||||
:value="props.jtagFreq"
|
||||
>
|
||||
<option v-for="option in selectJtagSpeedOptions" :value="option.id">
|
||||
{{ option.text }}
|
||||
</option>
|
||||
@@ -30,12 +45,23 @@
|
||||
<div class="flex flex-row items-center">
|
||||
<fieldset class="fieldset w-70">
|
||||
<legend class="fieldset-legend text-sm">边界扫描刷新率 / Hz</legend>
|
||||
<input type="number" class="input validator" required placeholder="Type a number between 1 to 1000" min="1"
|
||||
max="1000" v-model="jtagBoundaryScanFreq" title="Type a number between 1 to 1000" />
|
||||
<input
|
||||
type="number"
|
||||
class="input validator"
|
||||
required
|
||||
placeholder="Type a number between 1 to 1000"
|
||||
min="1"
|
||||
max="1000"
|
||||
v-model="jtagBoundaryScanFreq"
|
||||
title="Type a number between 1 to 1000"
|
||||
/>
|
||||
<p class="validator-hint">输入一个1 ~ 1000的数</p>
|
||||
</fieldset>
|
||||
<button class="btn btn-primary grow mx-4" :class="eqps.enableJtagBoundaryScan ? '' : 'btn-soft'"
|
||||
:onclick="toggleJtagBoundaryScan">
|
||||
<button
|
||||
class="btn btn-primary grow mx-4"
|
||||
:class="eqps.enableJtagBoundaryScan ? '' : 'btn-soft'"
|
||||
:onclick="toggleJtagBoundaryScan"
|
||||
>
|
||||
{{ eqps.enableJtagBoundaryScan ? "关闭边界扫描" : "启动边界扫描" }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -43,8 +69,12 @@
|
||||
<h1 class="font-bold text-center text-2xl">外设</h1>
|
||||
<div class="flex flex-row justify-center">
|
||||
<div class="flex flex-row">
|
||||
<input type="checkbox" class="checkbox" :checked="eqps.enableMatrixKey"
|
||||
@change="handleMatrixkeyCheckboxChange" />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
:checked="eqps.enableMatrixKey"
|
||||
@change="handleMatrixkeyCheckboxChange"
|
||||
/>
|
||||
<p class="mx-2">启用矩阵键盘</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +91,7 @@ import { RefreshCcwIcon } from "lucide-vue-next";
|
||||
|
||||
interface CapsProps {
|
||||
jtagFreq?: string;
|
||||
examId?: string; // 新增examId属性
|
||||
}
|
||||
|
||||
const emits = defineEmits<{
|
||||
@@ -97,6 +128,11 @@ function handleBitstreamChange(file: File | undefined) {
|
||||
eqps.jtagBitstream = file;
|
||||
}
|
||||
|
||||
async function handleDownloadBitstream(bitstreamId: number): Promise<boolean> {
|
||||
console.log("开始下载比特流,ID:", bitstreamId);
|
||||
return await eqps.jtagDownloadBitstream(bitstreamId);
|
||||
}
|
||||
|
||||
function handleSelectJtagSpeed(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
eqps.jtagSetSpeed(target.selectedIndex);
|
||||
@@ -117,7 +153,7 @@ async function handleMatrixkeyCheckboxChange(event: Event) {
|
||||
}
|
||||
|
||||
async function toggleJtagBoundaryScan() {
|
||||
eqps.enableJtagBoundaryScan = !eqps.enableJtagBoundaryScan;
|
||||
eqps.jtagBoundaryScanSetOnOff(!eqps.enableJtagBoundaryScan);
|
||||
}
|
||||
|
||||
const isGettingIDCode = ref(false);
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import { ref, computed, reactive } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { isBoolean } from 'lodash';
|
||||
import { ref, computed, reactive } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { isBoolean } from "lodash";
|
||||
|
||||
// 约束电平状态类型
|
||||
export type ConstraintLevel = 'high' | 'low' | 'undefined';
|
||||
|
||||
export const useConstraintsStore = defineStore('constraints', () => {
|
||||
export type ConstraintLevel = "high" | "low" | "undefined";
|
||||
|
||||
export const useConstraintsStore = defineStore("constraints", () => {
|
||||
// 约束状态存储
|
||||
const constraintStates = reactive<Record<string, ConstraintLevel>>({});
|
||||
|
||||
// 约束颜色映射
|
||||
const constraintColors = {
|
||||
high: '#ff3333', // 高电平为红色
|
||||
low: '#3333ff', // 低电平为蓝色
|
||||
undefined: '#999999' // 未定义为灰色
|
||||
high: "#ff3333", // 高电平为红色
|
||||
low: "#3333ff", // 低电平为蓝色
|
||||
undefined: "#999999", // 未定义为灰色
|
||||
};
|
||||
|
||||
// 获取约束状态
|
||||
function getConstraintState(constraint: string): ConstraintLevel {
|
||||
if (!constraint) return 'undefined';
|
||||
return constraintStates[constraint] || 'undefined';
|
||||
if (!constraint) return "undefined";
|
||||
return constraintStates[constraint] || "undefined";
|
||||
}
|
||||
|
||||
// 设置约束状态
|
||||
@@ -30,7 +29,9 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
}
|
||||
|
||||
// 批量设置约束状态
|
||||
function batchSetConstraintStates(states: Record<string, ConstraintLevel> | Record<string, boolean>) {
|
||||
function batchSetConstraintStates(
|
||||
states: Record<string, ConstraintLevel> | Partial<Record<string, boolean>>,
|
||||
) {
|
||||
// 收集发生变化的约束
|
||||
const changedConstraints: [string, ConstraintLevel][] = [];
|
||||
|
||||
@@ -38,6 +39,8 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
Object.entries(states).forEach(([constraint, level]) => {
|
||||
if (isBoolean(level)) {
|
||||
level = level ? "high" : "low";
|
||||
} else {
|
||||
level = "low";
|
||||
}
|
||||
|
||||
if (constraintStates[constraint] !== level) {
|
||||
@@ -48,7 +51,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
|
||||
// 通知所有变化
|
||||
changedConstraints.forEach(([constraint, level]) => {
|
||||
stateChangeCallbacks.forEach(callback => callback(constraint, level));
|
||||
stateChangeCallbacks.forEach((callback) => callback(constraint, level));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +63,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
|
||||
// 清除所有约束状态
|
||||
function clearAllConstraintStates() {
|
||||
Object.keys(constraintStates).forEach(key => {
|
||||
Object.keys(constraintStates).forEach((key) => {
|
||||
delete constraintStates[key];
|
||||
});
|
||||
}
|
||||
@@ -71,9 +74,14 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
}
|
||||
|
||||
// 注册约束状态变化回调
|
||||
const stateChangeCallbacks: ((constraint: string, level: ConstraintLevel) => void)[] = [];
|
||||
const stateChangeCallbacks: ((
|
||||
constraint: string,
|
||||
level: ConstraintLevel,
|
||||
) => void)[] = [];
|
||||
|
||||
function onConstraintStateChange(callback: (constraint: string, level: ConstraintLevel) => void) {
|
||||
function onConstraintStateChange(
|
||||
callback: (constraint: string, level: ConstraintLevel) => void,
|
||||
) {
|
||||
stateChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = stateChangeCallbacks.indexOf(callback);
|
||||
@@ -86,7 +94,7 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
// 触发约束变化
|
||||
function notifyConstraintChange(constraint: string, level: ConstraintLevel) {
|
||||
setConstraintState(constraint, level);
|
||||
stateChangeCallbacks.forEach(callback => callback(constraint, level));
|
||||
stateChangeCallbacks.forEach((callback) => callback(constraint, level));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -98,6 +106,5 @@ export const useConstraintsStore = defineStore('constraints', () => {
|
||||
getAllConstraintStates,
|
||||
onConstraintStateChange,
|
||||
notifyConstraintChange,
|
||||
}
|
||||
})
|
||||
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { ref, reactive, watchPostEffect } from "vue";
|
||||
import { ref, reactive, watchPostEffect, onMounted, onUnmounted } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { isString, toNumber } from "lodash";
|
||||
import { isString, toNumber, isUndefined, type Dictionary } from "lodash";
|
||||
import z from "zod";
|
||||
import { isNumber } from "mathjs";
|
||||
import { JtagClient, MatrixKeyClient, PowerClient } from "@/APIClient";
|
||||
import { Mutex, withTimeout } from "async-mutex";
|
||||
import { useConstraintsStore } from "@/stores/constraints";
|
||||
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 type { ResourceInfo } from "@/APIClient";
|
||||
import type { IJtagHub } from "@/TypedSignalR.Client/server.Hubs.JtagHub";
|
||||
|
||||
export const useEquipments = defineStore("equipments", () => {
|
||||
// Global Stores
|
||||
@@ -22,13 +25,39 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
// Jtag
|
||||
const jtagBitstream = ref<File>();
|
||||
const jtagBoundaryScanFreq = ref(100);
|
||||
const jtagBoundaryScanErrorCount = ref(0); // 边界扫描连续错误计数
|
||||
const maxJtagBoundaryScanErrors = 5; // 最大允许连续错误次数
|
||||
const jtagUserBitstreams = ref<ResourceInfo[]>([]);
|
||||
const jtagClientMutex = withTimeout(
|
||||
new Mutex(),
|
||||
1000,
|
||||
new Error("JtagClient Mutex Timeout!"),
|
||||
);
|
||||
const jtagHubConnection = ref<HubConnection>();
|
||||
const jtagHubProxy = ref<IJtagHub>();
|
||||
|
||||
onMounted(async () => {
|
||||
// 每次挂载都重新创建连接
|
||||
jtagHubConnection.value =
|
||||
AuthManager.createAuthenticatedJtagHubConnection();
|
||||
jtagHubProxy.value = getHubProxyFactory("IJtagHub").createHubProxy(
|
||||
jtagHubConnection.value,
|
||||
);
|
||||
|
||||
getReceiverRegister("IJtagReceiver").register(jtagHubConnection.value, {
|
||||
onReceiveBoundaryScanData: async (msg) => {
|
||||
constrainsts.batchSetConstraintStates(msg);
|
||||
},
|
||||
});
|
||||
await jtagHubConnection.value.start();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 断开连接,清理资源
|
||||
if (jtagHubConnection.value) {
|
||||
jtagHubConnection.value.stop();
|
||||
jtagHubConnection.value = undefined;
|
||||
jtagHubProxy.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// Matrix Key
|
||||
const matrixKeyStates = reactive(new Array<boolean>(16).fill(false));
|
||||
@@ -50,41 +79,6 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
const enableMatrixKey = ref(false);
|
||||
const enablePower = ref(false);
|
||||
|
||||
// Watch
|
||||
watchPostEffect(async () => {
|
||||
if (true === enableJtagBoundaryScan.value) {
|
||||
// 重新启用时重置错误计数器
|
||||
jtagBoundaryScanErrorCount.value = 0;
|
||||
jtagBoundaryScan();
|
||||
}
|
||||
});
|
||||
|
||||
// Parse and Set
|
||||
function setAddr(address: string | undefined): boolean {
|
||||
if (isString(address) && z.string().ip("4").safeParse(address).success) {
|
||||
boardAddr.value = address;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function setPort(port: string | number | undefined): boolean {
|
||||
if (isString(port) && port.length != 0) {
|
||||
const portNumber = toNumber(port);
|
||||
if (z.number().nonnegative().max(65535).safeParse(portNumber).success) {
|
||||
boardPort.value = portNumber;
|
||||
return true;
|
||||
}
|
||||
} else if (isNumber(port)) {
|
||||
if (z.number().nonnegative().max(65535).safeParse(port).success) {
|
||||
boardPort.value = port;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function setMatrixKey(
|
||||
keyNum: number | string | undefined,
|
||||
keyValue: boolean,
|
||||
@@ -105,73 +99,76 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function jtagBoundaryScan() {
|
||||
const release = await jtagClientMutex.acquire();
|
||||
try {
|
||||
// 自动开启电源
|
||||
await powerSetOnOff(true);
|
||||
|
||||
const jtagClient = AuthManager.createAuthenticatedJtagClient();
|
||||
const portStates = await jtagClient.boundaryScanLogicalPorts(
|
||||
boardAddr.value,
|
||||
boardPort.value,
|
||||
);
|
||||
|
||||
constrainsts.batchSetConstraintStates(portStates);
|
||||
|
||||
// 扫描成功,重置错误计数器
|
||||
jtagBoundaryScanErrorCount.value = 0;
|
||||
} catch (error) {
|
||||
jtagBoundaryScanErrorCount.value++;
|
||||
|
||||
console.error(`边界扫描错误 (${jtagBoundaryScanErrorCount.value}/${maxJtagBoundaryScanErrors}):`, error);
|
||||
|
||||
// 如果错误次数超过最大允许次数,才停止扫描并显示错误
|
||||
if (jtagBoundaryScanErrorCount.value >= maxJtagBoundaryScanErrors) {
|
||||
dialog.error("边界扫描发生连续错误,已自动停止");
|
||||
enableJtagBoundaryScan.value = false;
|
||||
jtagBoundaryScanErrorCount.value = 0; // 重置错误计数器
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
|
||||
if (enableJtagBoundaryScan.value)
|
||||
setTimeout(jtagBoundaryScan, 1000 / jtagBoundaryScanFreq.value);
|
||||
async function jtagBoundaryScanSetOnOff(enable: boolean) {
|
||||
if (isUndefined(jtagHubProxy.value)) {
|
||||
console.error("JtagHub Not Initialize...");
|
||||
return;
|
||||
}
|
||||
|
||||
if (enable) {
|
||||
const ret = await jtagHubProxy.value.startBoundaryScan(
|
||||
jtagBoundaryScanFreq.value,
|
||||
);
|
||||
if (!ret) {
|
||||
console.error("Failed to start boundary scan");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const ret = await jtagHubProxy.value.stopBoundaryScan();
|
||||
if (!ret) {
|
||||
console.error("Failed to stop boundary scan");
|
||||
return;
|
||||
}
|
||||
}
|
||||
enableJtagBoundaryScan.value = enable;
|
||||
}
|
||||
|
||||
async function jtagUploadBitstream(bitstream: File): Promise<boolean> {
|
||||
async function jtagUploadBitstream(bitstream: File, examId?: string): Promise<number | null> {
|
||||
try {
|
||||
// 自动开启电源
|
||||
await powerSetOnOff(true);
|
||||
|
||||
const jtagClient = AuthManager.createAuthenticatedJtagClient();
|
||||
const resp = await jtagClient.uploadBitstream(
|
||||
boardAddr.value,
|
||||
|
||||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||||
const resp = await resourceClient.addResource(
|
||||
'bitstream',
|
||||
'user',
|
||||
examId || null,
|
||||
toFileParameterOrUndefined(bitstream),
|
||||
);
|
||||
return resp;
|
||||
|
||||
// 如果上传成功,设置为当前选中的比特流
|
||||
if (resp && resp.id !== undefined && resp.id !== null) {
|
||||
return resp.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
dialog.error("上传错误");
|
||||
console.error(e);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function jtagDownloadBitstream(): Promise<boolean> {
|
||||
async function jtagDownloadBitstream(bitstreamId?: number): Promise<boolean> {
|
||||
if (bitstreamId === null || bitstreamId === undefined) {
|
||||
dialog.error("请先选择要下载的比特流");
|
||||
return false;
|
||||
}
|
||||
|
||||
const release = await jtagClientMutex.acquire();
|
||||
try {
|
||||
// 自动开启电源
|
||||
await powerSetOnOff(true);
|
||||
|
||||
|
||||
const jtagClient = AuthManager.createAuthenticatedJtagClient();
|
||||
const resp = await jtagClient.downloadBitstream(
|
||||
boardAddr.value,
|
||||
boardPort.value,
|
||||
bitstreamId,
|
||||
);
|
||||
return resp;
|
||||
} catch (e) {
|
||||
dialog.error("上传错误");
|
||||
dialog.error("下载错误");
|
||||
console.error(e);
|
||||
return false;
|
||||
} finally {
|
||||
@@ -184,7 +181,7 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
try {
|
||||
// 自动开启电源
|
||||
await powerSetOnOff(true);
|
||||
|
||||
|
||||
const jtagClient = AuthManager.createAuthenticatedJtagClient();
|
||||
const resp = await jtagClient.getDeviceIDCode(
|
||||
boardAddr.value,
|
||||
@@ -204,7 +201,7 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
try {
|
||||
// 自动开启电源
|
||||
await powerSetOnOff(true);
|
||||
|
||||
|
||||
const jtagClient = AuthManager.createAuthenticatedJtagClient();
|
||||
const resp = await jtagClient.setSpeed(
|
||||
boardAddr.value,
|
||||
@@ -224,7 +221,8 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
const release = await matrixKeypadClientMutex.acquire();
|
||||
console.log("set Key !!!!!!!!!!!!");
|
||||
try {
|
||||
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const matrixKeypadClient =
|
||||
AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const resp = await matrixKeypadClient.setMatrixKeyStatus(
|
||||
boardAddr.value,
|
||||
boardPort.value,
|
||||
@@ -243,7 +241,8 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
const release = await matrixKeypadClientMutex.acquire();
|
||||
try {
|
||||
if (enable) {
|
||||
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const matrixKeypadClient =
|
||||
AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const resp = await matrixKeypadClient.enabelMatrixKey(
|
||||
boardAddr.value,
|
||||
boardPort.value,
|
||||
@@ -251,7 +250,8 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
enableMatrixKey.value = resp;
|
||||
return resp;
|
||||
} else {
|
||||
const matrixKeypadClient = AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const matrixKeypadClient =
|
||||
AuthManager.createAuthenticatedMatrixKeyClient();
|
||||
const resp = await matrixKeypadClient.disableMatrixKey(
|
||||
boardAddr.value,
|
||||
boardPort.value,
|
||||
@@ -290,16 +290,14 @@ export const useEquipments = defineStore("equipments", () => {
|
||||
return {
|
||||
boardAddr,
|
||||
boardPort,
|
||||
setAddr,
|
||||
setPort,
|
||||
setMatrixKey,
|
||||
|
||||
// Jtag
|
||||
enableJtagBoundaryScan,
|
||||
jtagBoundaryScanSetOnOff,
|
||||
jtagBitstream,
|
||||
jtagBoundaryScanFreq,
|
||||
jtagBoundaryScanErrorCount,
|
||||
jtagClientMutex,
|
||||
jtagUserBitstreams,
|
||||
jtagUploadBitstream,
|
||||
jtagDownloadBitstream,
|
||||
jtagGetIDCode,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useSidebarStore = defineStore('sidebar', () => {
|
||||
const isClose = ref(false);
|
||||
|
||||
function closeSidebar() {
|
||||
isClose.value = true;
|
||||
console.info("Close sidebar");
|
||||
}
|
||||
|
||||
function openSidebar() {
|
||||
isClose.value = false;
|
||||
console.info("Open sidebar");
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isClose.value) {
|
||||
openSidebar();
|
||||
// themeSidebar.value = "card-dash sidebar-base sidebar-open"
|
||||
} else {
|
||||
closeSidebar();
|
||||
// themeSidebar.value = "card-dash sidebar-base sidebar-close"
|
||||
}
|
||||
}
|
||||
|
||||
return { isClose, closeSidebar, openSidebar, toggleSidebar }
|
||||
})
|
||||
|
||||
@@ -14,8 +14,12 @@ import {
|
||||
OscilloscopeApiClient,
|
||||
DebuggerClient,
|
||||
ExamClient,
|
||||
ResourceClient,
|
||||
} from "@/APIClient";
|
||||
import router from "@/router";
|
||||
import { HubConnectionBuilder } from "@microsoft/signalr";
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
import { isNull } from "lodash";
|
||||
|
||||
// 支持的客户端类型联合类型
|
||||
type SupportedClient =
|
||||
@@ -33,7 +37,8 @@ type SupportedClient =
|
||||
| NetConfigClient
|
||||
| OscilloscopeApiClient
|
||||
| DebuggerClient
|
||||
| ExamClient;
|
||||
| ExamClient
|
||||
| ResourceClient;
|
||||
|
||||
export class AuthManager {
|
||||
// 存储token到localStorage
|
||||
@@ -117,7 +122,7 @@ export class AuthManager {
|
||||
if (!token) return null;
|
||||
|
||||
const instance = axios.create();
|
||||
instance.interceptors.request.use(config => {
|
||||
instance.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)["Authorization"] = `Bearer ${token}`;
|
||||
return config;
|
||||
@@ -183,11 +188,11 @@ export class AuthManager {
|
||||
public static createAuthenticatedNetConfigClient(): NetConfigClient {
|
||||
return AuthManager.createAuthenticatedClient(NetConfigClient);
|
||||
}
|
||||
|
||||
|
||||
public static createAuthenticatedOscilloscopeApiClient(): OscilloscopeApiClient {
|
||||
return AuthManager.createAuthenticatedClient(OscilloscopeApiClient);
|
||||
}
|
||||
|
||||
|
||||
public static createAuthenticatedDebuggerClient(): DebuggerClient {
|
||||
return AuthManager.createAuthenticatedClient(DebuggerClient);
|
||||
}
|
||||
@@ -196,6 +201,25 @@ export class AuthManager {
|
||||
return AuthManager.createAuthenticatedClient(ExamClient);
|
||||
}
|
||||
|
||||
public static createAuthenticatedResourceClient(): ResourceClient {
|
||||
return AuthManager.createAuthenticatedClient(ResourceClient);
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
}
|
||||
|
||||
// 登录函数
|
||||
public static async login(
|
||||
username: string,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,26 +25,37 @@
|
||||
</h1>
|
||||
|
||||
<p class="py-6 text-lg opacity-80 leading-relaxed">
|
||||
Prototype and simulate electronic circuits in your browser with our
|
||||
modern, intuitive interface. Create, test, and share your FPGA
|
||||
designs seamlessly.
|
||||
在浏览器中进行FPGA原型设计和电路仿真,使用现代直观的界面。创建、测试和分享您的FPGA设计,体验从基础学习到高级项目的完整开发流程。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-4 actions-container">
|
||||
<div class="flex flex-col sm:flex-row gap-4 actions-container">
|
||||
<router-link
|
||||
to="/project"
|
||||
class="btn btn-primary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1"
|
||||
class="btn btn-primary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1 flex-1 sm:flex-none"
|
||||
>
|
||||
<BookOpen class="h-5 w-5 mr-2" />
|
||||
进入工程界面
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/exam"
|
||||
class="btn btn-secondary text-base-100 shadow-lg transform transition-all duration-300 hover:scale-105 hover:shadow-xl hover:-translate-y-1 flex-1 sm:flex-none"
|
||||
>
|
||||
<GraduationCap class="h-5 w-5 mr-2" />
|
||||
实验列表
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
class="mt-8 p-4 bg-base-300 rounded-lg shadow-inner opacity-80 transition-all duration-300 hover:opacity-100 hover:shadow-md"
|
||||
>
|
||||
<p class="text-sm">
|
||||
<span class="font-semibold text-primary">提示:</span>
|
||||
您可以在工程界面中创建、编辑和测试您的FPGA项目,使用我们简洁直观的界面轻松进行硬件设计。
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm">
|
||||
<span class="font-semibold text-primary">工程界面:</span>
|
||||
自由创建和编辑FPGA项目,使用可视化画布进行电路设计和仿真测试。
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
<span class="font-semibold text-secondary">实验列表:</span>
|
||||
浏览结构化的学习实验,从基础概念到高级应用的系统性学习路径。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +66,7 @@
|
||||
<script lang="ts" setup>
|
||||
import "@/router";
|
||||
import TutorialCarousel from "@/components/TutorialCarousel.vue";
|
||||
import { BookOpen } from "lucide-vue-next";
|
||||
import { BookOpen, GraduationCap } from "lucide-vue-next";
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col gap-7">
|
||||
<div class="tabs tabs-box flex-shrink-0 shadow-xl mx-5">
|
||||
<div class="tabs tabs-lift flex-shrink-0 mx-5">
|
||||
<label class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
|
||||
@@ -479,10 +479,10 @@ async function startCapture() {
|
||||
const arr = [];
|
||||
for (let i = 0; i < bin.length; i += 4) {
|
||||
arr.push(
|
||||
(bin.charCodeAt(i) << 24) |
|
||||
(bin.charCodeAt(i + 1) << 16) |
|
||||
(bin.charCodeAt(i + 2) << 8) |
|
||||
bin.charCodeAt(i + 3),
|
||||
bin.charCodeAt(i) |
|
||||
(bin.charCodeAt(i + 1) << 8) |
|
||||
(bin.charCodeAt(i + 2) << 16) |
|
||||
(bin.charCodeAt(i + 3) << 24),
|
||||
);
|
||||
}
|
||||
// 截取采样深度
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<DiagramCanvas
|
||||
ref="diagramCanvas"
|
||||
:showDocPanel="showDocPanel"
|
||||
:exam-id="(route.query.examId as string) || ''"
|
||||
@open-components="openComponentsMenu"
|
||||
@toggle-doc-panel="toggleDocPanel"
|
||||
/>
|
||||
@@ -36,13 +37,13 @@
|
||||
<!-- 拖拽分割线 -->
|
||||
<SplitterResizeHandle
|
||||
id="splitter-group-h-resize-handle"
|
||||
class="w-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors"
|
||||
class="w-1 bg-base-300"
|
||||
/>
|
||||
<!-- 右侧编辑区域 -->
|
||||
<SplitterPanel
|
||||
id="splitter-group-h-panel-properties"
|
||||
:min-size="20"
|
||||
class="bg-base-200 h-full overflow-hidden flex flex-col"
|
||||
class="bg-base-100 h-full overflow-hidden flex flex-col"
|
||||
>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<!-- 使用条件渲染显示不同的面板 -->
|
||||
@@ -59,7 +60,10 @@
|
||||
v-show="showDocPanel"
|
||||
class="doc-panel overflow-y-auto h-full"
|
||||
>
|
||||
<MarkdownRenderer :content="documentContent" />
|
||||
<MarkdownRenderer
|
||||
:content="documentContent"
|
||||
:examId="(route.query.examId as string) || ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
@@ -70,7 +74,7 @@
|
||||
<SplitterResizeHandle
|
||||
v-show="!isBottomBarFullscreen"
|
||||
id="splitter-group-v-resize-handle"
|
||||
class="h-2 bg-base-100 hover:bg-primary hover:opacity-70 transition-colors"
|
||||
class="h-1 bg-base-300"
|
||||
/>
|
||||
|
||||
<!-- 功能底栏 -->
|
||||
@@ -100,11 +104,32 @@
|
||||
@close="handleRequestBoardClose"
|
||||
@success="handleRequestBoardSuccess"
|
||||
/>
|
||||
|
||||
<!-- Navbar切换浮动按钮 -->
|
||||
<div
|
||||
class="navbar-toggle-btn"
|
||||
:class="{ 'with-navbar': navbarControl.showNavbar.value }"
|
||||
>
|
||||
<button
|
||||
@click="navbarControl.toggleNavbar"
|
||||
class="btn btn-circle btn-primary shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
:class="{ 'btn-outline': navbarControl.showNavbar.value }"
|
||||
:title="navbarControl.showNavbar.value ? '隐藏顶部导航栏' : '显示顶部导航栏'"
|
||||
>
|
||||
<!-- 使用SVG图标表示菜单/关闭状态 -->
|
||||
<svg v-if="navbarControl.showNavbar.value" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch, inject, type Ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useLocalStorage } from '@vueuse/core'; // 添加VueUse导入
|
||||
import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from "reka-ui";
|
||||
@@ -115,7 +140,6 @@ import MarkdownRenderer from "@/components/MarkdownRenderer.vue";
|
||||
import BottomBar from "@/views/Project/BottomBar.vue";
|
||||
import RequestBoardDialog from "@/views/Project/RequestBoardDialog.vue";
|
||||
import { useProvideComponentManager } from "@/components/LabCanvas";
|
||||
import type { DiagramData } from "@/components/LabCanvas";
|
||||
import { useAlertStore } from "@/components/Alert";
|
||||
import { AuthManager } from "@/utils/AuthManager";
|
||||
import { useEquipments } from "@/stores/equipments";
|
||||
@@ -133,6 +157,12 @@ const equipments = useEquipments();
|
||||
|
||||
const alert = useAlertStore();
|
||||
|
||||
// --- Navbar控制 ---
|
||||
const navbarControl = inject('navbar') as {
|
||||
showNavbar: Ref<boolean>;
|
||||
toggleNavbar: () => void;
|
||||
};
|
||||
|
||||
// --- 使用VueUse保存分栏状态 ---
|
||||
// 左右分栏比例(默认60%)
|
||||
const horizontalSplitterSize = useLocalStorage('project-horizontal-splitter-size', 60);
|
||||
@@ -182,29 +212,37 @@ async function toggleDocPanel() {
|
||||
// 加载文档内容
|
||||
async function loadDocumentContent() {
|
||||
try {
|
||||
// 从路由参数中获取教程ID
|
||||
const tutorialId = (route.query.tutorial as string) || "02"; // 默认加载02例程
|
||||
|
||||
// 构建文档路径
|
||||
let docPath = `/doc/${tutorialId}/doc.md`;
|
||||
|
||||
// 检查当前路径是否包含下划线(例如 02_key 格式)
|
||||
// 如果不包含,那么使用更新的命名格式
|
||||
if (!tutorialId.includes("_")) {
|
||||
docPath = `/doc/${tutorialId}/doc.md`;
|
||||
// 检查是否有实验ID参数
|
||||
const examId = route.query.examId as string;
|
||||
if (examId) {
|
||||
// 如果有实验ID,从API加载实验文档
|
||||
console.log('加载实验文档:', examId);
|
||||
const client = AuthManager.createAuthenticatedResourceClient();
|
||||
|
||||
// 获取markdown类型的模板资源列表
|
||||
const resources = await client.getResourceList(examId, 'doc', 'template');
|
||||
|
||||
if (resources && resources.length > 0) {
|
||||
// 获取第一个markdown资源
|
||||
const markdownResource = resources[0];
|
||||
|
||||
// 使用新的ResourceClient API获取资源文件内容
|
||||
const response = await client.getResourceById(markdownResource.id);
|
||||
|
||||
if (!response || !response.data) {
|
||||
throw new Error('获取markdown文件失败');
|
||||
}
|
||||
|
||||
const content = await response.data.text();
|
||||
|
||||
// 更新文档内容,暂时不处理图片路径,由MarkdownRenderer处理
|
||||
documentContent.value = content;
|
||||
} else {
|
||||
documentContent.value = "# 暂无实验文档\n\n该实验尚未提供文档内容。";
|
||||
}
|
||||
} else {
|
||||
documentContent.value = "# 无文档";
|
||||
}
|
||||
|
||||
// 获取文档内容
|
||||
const response = await fetch(docPath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load document: ${response.status}`);
|
||||
}
|
||||
|
||||
// 更新文档内容,并替换图片路径
|
||||
documentContent.value = (await response.text()).replace(
|
||||
/.\/images/gi,
|
||||
`/doc/${tutorialId}/images`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("加载文档失败:", error);
|
||||
documentContent.value = "# 文档加载失败\n\n无法加载请求的文档。";
|
||||
@@ -268,8 +306,8 @@ async function checkAndInitializeBoard() {
|
||||
|
||||
// 根据实验板信息更新equipment store
|
||||
function updateEquipmentFromBoard(board: Board) {
|
||||
equipments.setAddr(board.ipAddr);
|
||||
equipments.setPort(board.port);
|
||||
equipments.boardAddr = board.ipAddr;
|
||||
equipments.boardPort = board.port;
|
||||
|
||||
console.log(`实验板信息已更新到equipment store:`, {
|
||||
address: board.ipAddr,
|
||||
@@ -312,8 +350,8 @@ onMounted(async () => {
|
||||
// 检查并初始化用户实验板
|
||||
await checkAndInitializeBoard();
|
||||
|
||||
// 检查是否有例程参数,如果有则自动打开文档面板
|
||||
if (route.query.tutorial) {
|
||||
// 检查是否有例程参数或实验ID参数,如果有则自动打开文档面板
|
||||
if (route.query.tutorial || route.query.examId) {
|
||||
showDocPanel.value = true;
|
||||
await loadDocumentContent();
|
||||
}
|
||||
@@ -344,7 +382,7 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保滚动行为仅在需要时出现 */
|
||||
/* 确保整个页面禁止滚动 */
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
@@ -376,7 +414,42 @@ body {
|
||||
:deep(.markdown-content) {
|
||||
padding: 1rem;
|
||||
background-color: hsl(var(--b1));
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Navbar切换浮动按钮样式 */
|
||||
.navbar-toggle-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* 当Navbar显示时,调整按钮位置 */
|
||||
.navbar-toggle-btn.with-navbar {
|
||||
top: 80px; /* 调整到Navbar下方 */
|
||||
}
|
||||
|
||||
.navbar-toggle-btn button {
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(var(--p), 0.9);
|
||||
border: 2px solid rgba(var(--p), 0.3);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.navbar-toggle-btn button:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(var(--p), 1);
|
||||
}
|
||||
|
||||
.navbar-toggle-btn button.btn-outline {
|
||||
background: rgba(var(--b1), 0.9);
|
||||
color: hsl(var(--p));
|
||||
border: 2px solid rgba(var(--p), 0.5);
|
||||
}
|
||||
|
||||
.navbar-toggle-btn button.btn-outline:hover {
|
||||
background: rgba(var(--p), 0.1);
|
||||
border: 2px solid rgba(var(--p), 0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import tailwindcss from '@tailwindcss/postcss'
|
||||
import autoprefixer from 'autoprefixer'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import RekaResolver from 'reka-ui/resolver'
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||
import vueDevTools from "vite-plugin-vue-devtools";
|
||||
import tailwindcss from "@tailwindcss/postcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import RekaResolver from "reka-ui/resolver";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -16,49 +16,44 @@ export default defineConfig({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
// 将所有 wokwi- 开头的标签视为自定义元素
|
||||
isCustomElement: (tag) => tag.startsWith('wokwi-')
|
||||
}
|
||||
}
|
||||
isCustomElement: (tag) => tag.startsWith("wokwi-"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
Components(
|
||||
{
|
||||
Components({
|
||||
dts: true,
|
||||
resolvers: [
|
||||
RekaResolver()
|
||||
RekaResolver(),
|
||||
|
||||
// RekaResolver({
|
||||
// prefix: '' // use the prefix option to add Prefix to the imported components
|
||||
// })
|
||||
],
|
||||
}
|
||||
)
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
autoprefixer()
|
||||
]
|
||||
}
|
||||
plugins: [tailwindcss(), autoprefixer()],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'wwwroot',
|
||||
outDir: "wwwroot",
|
||||
emptyOutDir: true, // also necessary
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/swagger": {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true
|
||||
}
|
||||
target: "http://localhost:5000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
port: 5173,
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user