mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-04 07:40:51 +00:00
commit
135bb6b0c8
66
bun.lock
66
bun.lock
@ -1967,6 +1967,62 @@
|
|||||||
|
|
||||||
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.1.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.1.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-accordion/@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.10", "https://registry.npmmirror.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.10.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O2mcG3gZNkJ/Ena34HurA3llPOEA/M4dJtIRMa6y/cknRDC8XY5UZBInKTsUwW5cUue9A4k0wi1XU5fKBzKe1w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.13", "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.1.13.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.9", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.6", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.8", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-slot": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ARFmqUyhIVS3+riWzwGTe7JLjqwqgnODBUZdqpWar/z1WFs9z76fuOs/2BOWCR+YboRn4/WN9aoaGVwqNRr8VA=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.6", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.6.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.6", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.6.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.2", "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.2.2.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-y7TBO4xN4Y94FvcWIOIh18fM4R1A8S4q1jhoz4PNzOoHsFcN8pogcFmZrTYAm4F9VRUrWP/Mw7xSKybIeRI+CQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||||
|
|
||||||
"@types/ssh2/@types/node": ["@types/node@18.19.100", "https://registry.npmmirror.com/@types/node/-/node-18.19.100.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="],
|
"@types/ssh2/@types/node": ["@types/node@18.19.100", "https://registry.npmmirror.com/@types/node/-/node-18.19.100.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-ojmMP8SZBKprc3qGrGk8Ujpo80AXkrP7G2tOT4VWr5jlr5DHjsJF+emXJz+Wm0glmy4Js62oKMdZZ6B9Y+tEcA=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||||
@ -2035,6 +2091,16 @@
|
|||||||
|
|
||||||
"@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.9", "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.9.tgz", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-way197PiTvNp+WBP7svMJasHl+vibhWGQDb6Mgf5mhEWJkgb85z7Lfl9TUdkqpWsf8GRNmoopx9ZxCyDzmgRMQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.6", "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.6.tgz", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-r9zpYNUQY+2jWHWZGyddQLL9YHkM/XvSFHVcWs7bdVuxMAnCwTAuy6Pf47Z4nw7dYcUou1vg/VgjjrrH03VeBw=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.8", "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.1.8.tgz", { "dependencies": { "@radix-ui/react-primitive": "2.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-hQsTUIn7p7fxCPvao/q6wpbxmCwgLrlz+nOrJgC+RwfZqWY/WN+UMqkXzrtKbPrF82P43eCTl3ekeKuyAQbFeg=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-menu/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-tooltip/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||||
|
|
||||||
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||||
|
2
prisma/migrations/20250621055555_lg/migration.sql
Normal file
2
prisma/migrations/20250621055555_lg/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Role" ADD VALUE 'TEACHER';
|
@ -11,6 +11,7 @@ generator client {
|
|||||||
enum Role {
|
enum Role {
|
||||||
ADMIN
|
ADMIN
|
||||||
GUEST
|
GUEST
|
||||||
|
TEACHER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Difficulty {
|
enum Difficulty {
|
||||||
|
197
src/app/(protected)/dashboard/actions/student-dashboard.ts
Normal file
197
src/app/(protected)/dashboard/actions/student-dashboard.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function getStudentDashboardData() {
|
||||||
|
try {
|
||||||
|
console.log("=== 开始获取学生仪表板数据 ===");
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
console.log("Session 获取成功:", !!session);
|
||||||
|
console.log("Session user:", session?.user);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
console.log("用户未登录或session无效");
|
||||||
|
throw new Error("未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
console.log("当前用户ID:", userId);
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, email: true, role: true },
|
||||||
|
});
|
||||||
|
console.log("当前用户信息:", currentUser);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有已发布的题目(包含英文标题)
|
||||||
|
const allProblems = await prisma.problem.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("已发布题目数量:", allProblems.length);
|
||||||
|
console.log(
|
||||||
|
"题目列表:",
|
||||||
|
allProblems.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
displayId: p.displayId,
|
||||||
|
title: p.localizations[0]?.content || "无标题",
|
||||||
|
difficulty: p.difficulty,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取当前学生的所有提交记录(包含题目英文标题)
|
||||||
|
const userSubmissions = await prisma.submission.findMany({
|
||||||
|
where: { userId: userId },
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
locale: "en", // 或者根据需求使用其他语言
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("当前用户提交记录数量:", userSubmissions.length);
|
||||||
|
console.log(
|
||||||
|
"提交记录详情:",
|
||||||
|
userSubmissions.map((s) => ({
|
||||||
|
problemId: s.problemId,
|
||||||
|
problemDisplayId: s.problem.displayId,
|
||||||
|
title: s.problem.localizations[0]?.content || "无标题",
|
||||||
|
difficulty: s.problem.difficulty,
|
||||||
|
status: s.status,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 计算题目完成情况
|
||||||
|
const completedProblems = new Set<string | number>();
|
||||||
|
const attemptedProblems = new Set<string | number>();
|
||||||
|
const wrongSubmissions = new Map<string | number, number>(); // problemId -> count
|
||||||
|
|
||||||
|
userSubmissions.forEach((submission) => {
|
||||||
|
attemptedProblems.add(submission.problemId);
|
||||||
|
|
||||||
|
if (submission.status === "AC") {
|
||||||
|
completedProblems.add(submission.problemId);
|
||||||
|
} else {
|
||||||
|
// 统计错误提交次数
|
||||||
|
const currentCount = wrongSubmissions.get(submission.problemId) || 0;
|
||||||
|
wrongSubmissions.set(submission.problemId, currentCount + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("尝试过的题目数:", attemptedProblems.size);
|
||||||
|
console.log("完成的题目数:", completedProblems.size);
|
||||||
|
console.log("错误提交统计:", Object.fromEntries(wrongSubmissions));
|
||||||
|
|
||||||
|
// 题目完成比例数据
|
||||||
|
const completionData = {
|
||||||
|
total: allProblems.length,
|
||||||
|
completed: completedProblems.size,
|
||||||
|
percentage:
|
||||||
|
allProblems.length > 0
|
||||||
|
? Math.round((completedProblems.size / allProblems.length) * 100)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错题比例数据 - 基于已完成的题目计算
|
||||||
|
const wrongProblems = new Set<string | number>();
|
||||||
|
|
||||||
|
// 统计在已完成的题目中,哪些题目曾经有过错误提交
|
||||||
|
userSubmissions.forEach((submission) => {
|
||||||
|
if (
|
||||||
|
submission.status !== "AC" &&
|
||||||
|
completedProblems.has(submission.problemId)
|
||||||
|
) {
|
||||||
|
wrongProblems.add(submission.problemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorData = {
|
||||||
|
total: completedProblems.size, // 已完成的题目总数
|
||||||
|
wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数
|
||||||
|
percentage:
|
||||||
|
completedProblems.size > 0
|
||||||
|
? Math.round((wrongProblems.size / completedProblems.size) * 100)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 易错题列表(按错误次数排序)
|
||||||
|
const difficultProblems = Array.from(wrongSubmissions.entries())
|
||||||
|
.map(([problemId, errorCount]) => {
|
||||||
|
const problem = allProblems.find((p) => p.id === problemId);
|
||||||
|
|
||||||
|
// 从 problem.localizations 中获取标题
|
||||||
|
const title =
|
||||||
|
problem?.localizations?.find((loc) => loc.type === "TITLE")
|
||||||
|
?.content || "未知题目";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: problem?.displayId || problemId,
|
||||||
|
title: title, // 使用从 localizations 获取的标题
|
||||||
|
difficulty: problem?.difficulty || "未知",
|
||||||
|
errorCount: errorCount as number,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.errorCount - a.errorCount)
|
||||||
|
.slice(0, 10); // 只显示前10个
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
completionData,
|
||||||
|
errorData,
|
||||||
|
difficultProblems,
|
||||||
|
pieChartData: [
|
||||||
|
{ name: "已完成", value: completionData.completed },
|
||||||
|
{
|
||||||
|
name: "未完成",
|
||||||
|
value: completionData.total - completionData.completed,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errorPieChartData: [
|
||||||
|
{ name: "正确", value: errorData.total - errorData.wrong },
|
||||||
|
{ name: "错误", value: errorData.wrong },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("=== 返回的数据 ===");
|
||||||
|
console.log("完成情况:", completionData);
|
||||||
|
console.log("错误情况:", errorData);
|
||||||
|
console.log("易错题数量:", difficultProblems.length);
|
||||||
|
console.log("=== 数据获取完成 ===");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取学生仪表板数据失败:", error);
|
||||||
|
throw new Error(
|
||||||
|
`获取数据失败: ${error instanceof Error ? error.message : "未知错误"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
221
src/app/(protected)/dashboard/actions/teacher-dashboard.ts
Normal file
221
src/app/(protected)/dashboard/actions/teacher-dashboard.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { Locale, Status, ProblemLocalization } from "@/generated/client";
|
||||||
|
|
||||||
|
const getLocalizedTitle = (
|
||||||
|
localizations: ProblemLocalization[],
|
||||||
|
locale: Locale
|
||||||
|
) => {
|
||||||
|
if (!localizations || localizations.length === 0) {
|
||||||
|
return "Unknown Title";
|
||||||
|
}
|
||||||
|
|
||||||
|
const localization = localizations.find(
|
||||||
|
(localization) => localization.locale === locale
|
||||||
|
);
|
||||||
|
|
||||||
|
return localization?.content ?? localizations[0].content ?? "Unknown Title";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProblemCompletionData {
|
||||||
|
problemId: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
problemTitle: string;
|
||||||
|
completed: number;
|
||||||
|
uncompleted: number;
|
||||||
|
total: number;
|
||||||
|
completedPercent: number;
|
||||||
|
uncompletedPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DifficultProblemData {
|
||||||
|
id: string;
|
||||||
|
className: string;
|
||||||
|
problemCount: number;
|
||||||
|
problemTitle: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProblemCompletionData(): Promise<
|
||||||
|
ProblemCompletionData[]
|
||||||
|
> {
|
||||||
|
// 获取所有提交记录,按题目分组统计
|
||||||
|
const submissions = await prisma.submission.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
problem: {
|
||||||
|
include: {
|
||||||
|
localizations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
// 按题目分组统计完成情况(统计独立用户数)
|
||||||
|
const problemStats = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
completedUsers: Set<string>;
|
||||||
|
totalUsers: Set<string>;
|
||||||
|
title: string;
|
||||||
|
displayId: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
submissions.forEach((submission) => {
|
||||||
|
const localizations = submission.problem.localizations;
|
||||||
|
const title = getLocalizedTitle(localizations, locale as Locale);
|
||||||
|
const problemId = submission.problemId;
|
||||||
|
const problemTitle = title;
|
||||||
|
const problemDisplayId = submission.problem.displayId;
|
||||||
|
const userId = submission.userId;
|
||||||
|
const isCompleted = submission.status === Status.AC; // 只有 Accepted 才算完成
|
||||||
|
|
||||||
|
if (!problemStats.has(problemId)) {
|
||||||
|
problemStats.set(problemId, {
|
||||||
|
completedUsers: new Set(),
|
||||||
|
totalUsers: new Set(),
|
||||||
|
title: problemTitle,
|
||||||
|
displayId: problemDisplayId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = problemStats.get(problemId)!;
|
||||||
|
stats.totalUsers.add(userId);
|
||||||
|
if (isCompleted) {
|
||||||
|
stats.completedUsers.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果没有数据,返回空数组
|
||||||
|
if (problemStats.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为图表数据格式,按题目displayId排序
|
||||||
|
const problemDataArray = Array.from(problemStats.entries()).map(
|
||||||
|
([problemId, stats]) => {
|
||||||
|
const completed = stats.completedUsers.size;
|
||||||
|
const total = stats.totalUsers.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
problemId: problemId,
|
||||||
|
problemDisplayId: stats.displayId,
|
||||||
|
problemTitle: stats.title,
|
||||||
|
completed: completed,
|
||||||
|
uncompleted: total - completed,
|
||||||
|
total: total,
|
||||||
|
completedPercent: total > 0 ? (completed / total) * 100 : 0,
|
||||||
|
uncompletedPercent: total > 0 ? ((total - completed) / total) * 100 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按题目编号排序
|
||||||
|
return problemDataArray.sort(
|
||||||
|
(a, b) => a.problemDisplayId - b.problemDisplayId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDifficultProblemsData(): Promise<
|
||||||
|
DifficultProblemData[]
|
||||||
|
> {
|
||||||
|
// 获取所有测试用例结果
|
||||||
|
const testcaseResults = await prisma.testcaseResult.findMany({
|
||||||
|
include: {
|
||||||
|
testcase: {
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
include: {
|
||||||
|
localizations: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submission: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按问题分组统计错误率
|
||||||
|
const problemStats = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
totalAttempts: number;
|
||||||
|
wrongAttempts: number;
|
||||||
|
title: string;
|
||||||
|
displayId: number;
|
||||||
|
users: Set<string>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
testcaseResults.forEach((result) => {
|
||||||
|
const problemId = result.testcase.problemId;
|
||||||
|
const problemTitle =
|
||||||
|
result.testcase.problem.localizations?.find((loc) => loc.type === "TITLE")
|
||||||
|
?.content || "无标题";
|
||||||
|
const problemDisplayId = result.testcase.problem.displayId;
|
||||||
|
const userId = result.submission.userId;
|
||||||
|
const isWrong = !result.isCorrect;
|
||||||
|
|
||||||
|
if (!problemStats.has(problemId)) {
|
||||||
|
problemStats.set(problemId, {
|
||||||
|
totalAttempts: 0,
|
||||||
|
wrongAttempts: 0,
|
||||||
|
title: problemTitle,
|
||||||
|
displayId: problemDisplayId,
|
||||||
|
users: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = problemStats.get(problemId)!;
|
||||||
|
stats.totalAttempts++;
|
||||||
|
stats.users.add(userId);
|
||||||
|
if (isWrong) {
|
||||||
|
stats.wrongAttempts++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算错误率并筛选易错题(错误率 > 30% 且至少有3次尝试)
|
||||||
|
const difficultProblems = Array.from(problemStats.entries())
|
||||||
|
.map(([problemId, stats]) => ({
|
||||||
|
id: problemId,
|
||||||
|
className: `题目 ${stats.title}`,
|
||||||
|
problemCount: stats.wrongAttempts,
|
||||||
|
problemTitle: stats.title,
|
||||||
|
problemDisplayId: stats.displayId,
|
||||||
|
errorRate: (stats.wrongAttempts / stats.totalAttempts) * 100,
|
||||||
|
uniqueUsers: stats.users.size,
|
||||||
|
totalAttempts: stats.totalAttempts,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(problem) =>
|
||||||
|
problem.errorRate > 30 && // 错误率超过30%
|
||||||
|
problem.totalAttempts >= 3 // 至少有3次尝试
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.errorRate - a.errorRate) // 按错误率降序排列
|
||||||
|
.slice(0, 10); // 取前10个最难的题目
|
||||||
|
|
||||||
|
return difficultProblems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardStats() {
|
||||||
|
const [problemData, difficultProblems] = await Promise.all([
|
||||||
|
getProblemCompletionData(),
|
||||||
|
getDifficultProblemsData(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
problemData,
|
||||||
|
difficultProblems,
|
||||||
|
totalProblems: problemData.length,
|
||||||
|
totalDifficultProblems: difficultProblems.length,
|
||||||
|
};
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ProblemEditLayout } from "@/features/admin/ui/layouts/problem-edit-layout";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -13,7 +12,7 @@ const Layout = async ({ children, params }: LayoutProps) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProblemEditLayout>{children}</ProblemEditLayout>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
118
src/app/(protected)/dashboard/layout.tsx
Normal file
118
src/app/(protected)/dashboard/layout.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
SidebarInset,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarTrigger,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
|
import { AdminSidebar } from "@/components/sidebar/admin-sidebar";
|
||||||
|
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
|
||||||
|
import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WrongProblem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Layout({ children }: LayoutProps) {
|
||||||
|
const session = await auth();
|
||||||
|
const user = session?.user;
|
||||||
|
if (!user) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的完整信息(包括角色)
|
||||||
|
const fullUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullUser) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户角色决定显示哪个侧边栏
|
||||||
|
const renderSidebar = () => {
|
||||||
|
switch (fullUser.role) {
|
||||||
|
case "ADMIN":
|
||||||
|
return <AdminSidebar user={user} />;
|
||||||
|
case "TEACHER":
|
||||||
|
return <TeacherSidebar user={user} />;
|
||||||
|
case "GUEST":
|
||||||
|
default:
|
||||||
|
// 学生(GUEST)需要查询错题数据
|
||||||
|
return <AppSidebar user={user} wrongProblems={[]} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只有学生才需要查询错题数据
|
||||||
|
let wrongProblemsData: WrongProblem[] = [];
|
||||||
|
if (fullUser.role === "GUEST") {
|
||||||
|
// 查询未完成(未AC)题目的最新一次提交
|
||||||
|
const wrongProblems = await prisma.problem.findMany({
|
||||||
|
where: {
|
||||||
|
submissions: {
|
||||||
|
some: { userId: user.id },
|
||||||
|
},
|
||||||
|
NOT: {
|
||||||
|
submissions: {
|
||||||
|
some: { userId: user.id, status: "AC" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
localizations: {
|
||||||
|
where: { locale: "zh", type: "TITLE" },
|
||||||
|
select: { content: true },
|
||||||
|
},
|
||||||
|
submissions: {
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组装传递给 AppSidebar 的数据格式
|
||||||
|
wrongProblemsData = wrongProblems.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.localizations[0]?.content || `题目${p.displayId}`,
|
||||||
|
status: p.submissions[0]?.status || "-",
|
||||||
|
url: `/problems/${p.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarProvider>
|
||||||
|
{fullUser.role === "GUEST" ? (
|
||||||
|
<AppSidebar user={user} wrongProblems={wrongProblemsData} />
|
||||||
|
) : (
|
||||||
|
renderSidebar()
|
||||||
|
)}
|
||||||
|
<SidebarInset>
|
||||||
|
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 px-4">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
<DynamicBreadcrumb />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function changePassword(formData: FormData) {
|
||||||
|
const oldPassword = formData.get("oldPassword") as string;
|
||||||
|
const newPassword = formData.get("newPassword") as string;
|
||||||
|
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
throw new Error("旧密码和新密码不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前登录用户
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("用户未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询当前用户信息
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw new Error("用户密码未设置");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证旧密码
|
||||||
|
const passwordHash: string = user.password as string;
|
||||||
|
const isMatch = await bcrypt.compare(oldPassword, passwordHash);
|
||||||
|
if (!isMatch) {
|
||||||
|
throw new Error("旧密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密新密码
|
||||||
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { password: hashedPassword },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("修改密码失败:", error);
|
||||||
|
throw new Error("修改密码失败");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getUserInfo() {
|
||||||
|
try {
|
||||||
|
// 获取当前会话
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("用户未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据当前用户ID获取用户信息
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
image: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取用户信息失败:", error);
|
||||||
|
throw new Error("获取用户信息失败");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,3 @@
|
|||||||
|
export { getUserInfo } from "./getUserInfo";
|
||||||
|
export { updateUserInfo } from "./updateUserInfo";
|
||||||
|
export { changePassword } from "./changePassword";
|
@ -0,0 +1,33 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function updateUserInfo(formData: FormData) {
|
||||||
|
const name = formData.get("name") as string;
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
|
||||||
|
if (!name || !email) {
|
||||||
|
throw new Error("缺少必要字段:name, email");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前会话
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("用户未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { name, email },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedUser;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新用户信息失败:", error);
|
||||||
|
throw new Error("更新用户信息失败");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword";
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
|
const getPasswordStrength = (password: string) => {
|
||||||
|
if (password.length < 6) return "weak";
|
||||||
|
if (/[A-Za-z]/.test(password) && /\d/.test(password)) return "medium";
|
||||||
|
return "strong";
|
||||||
|
};
|
||||||
|
|
||||||
|
const strengthText = getPasswordStrength(newPassword);
|
||||||
|
let strengthColor = "";
|
||||||
|
let strengthLabel = "";
|
||||||
|
|
||||||
|
switch (strengthText) {
|
||||||
|
case "weak":
|
||||||
|
strengthColor = "bg-red-500";
|
||||||
|
strengthLabel = "弱";
|
||||||
|
break;
|
||||||
|
case "medium":
|
||||||
|
strengthColor = "bg-yellow-500";
|
||||||
|
strengthLabel = "中等";
|
||||||
|
break;
|
||||||
|
case "strong":
|
||||||
|
strengthColor = "bg-green-500";
|
||||||
|
strengthLabel = "强";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert("两次输入的密码不一致!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("oldPassword", oldPassword);
|
||||||
|
formData.append("newPassword", newPassword);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await changePassword(formData);
|
||||||
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => setShowSuccess(false), 3000);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "修改密码失败";
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-6">
|
||||||
|
<div className="h-full w-full bg-card shadow-lg rounded-xl p-8 flex flex-col">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">修改密码</h1>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-5 flex-1 flex flex-col"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">旧密码</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">新密码</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{newPassword && (
|
||||||
|
<p className="mt-1 text-xs">
|
||||||
|
密码强度:
|
||||||
|
<span
|
||||||
|
className={`inline-block w-12 h-2 rounded ${strengthColor}`}
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<span className="text-sm">{strengthLabel}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">确认新密码</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{newPassword &&
|
||||||
|
confirmPassword &&
|
||||||
|
newPassword !== confirmPassword && (
|
||||||
|
<p className="mt-1 text-xs text-red-500">密码不一致</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full font-semibold py-2 px-4 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="fixed bottom-5 right-5 bg-green-500 text-white px-4 py-2 rounded shadow-lg animate-fade-in-down">
|
||||||
|
✅ 密码修改成功!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
50
src/app/(protected)/dashboard/management/page.tsx
Normal file
50
src/app/(protected)/dashboard/management/page.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import ProfilePage from "./profile/page";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import ChangePasswordPage from "./change-password/page";
|
||||||
|
|
||||||
|
export default function ManagementDefaultPage() {
|
||||||
|
const [activePage, setActivePage] = useState("profile");
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activePage) {
|
||||||
|
case "profile":
|
||||||
|
return <ProfilePage />;
|
||||||
|
case "change-password":
|
||||||
|
return <ChangePasswordPage />;
|
||||||
|
default:
|
||||||
|
return <ProfilePage />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{/* 顶部导航栏 */}
|
||||||
|
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||||
|
{/* 页面切换按钮 */}
|
||||||
|
<div className="ml-auto flex space-x-2">
|
||||||
|
<Button
|
||||||
|
className={cn("px-3 py-1 rounded-md text-sm transition-colors")}
|
||||||
|
variant={activePage === "profile" ? "default" : "secondary"}
|
||||||
|
onClick={() => setActivePage("profile")}
|
||||||
|
>
|
||||||
|
登录信息
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={cn("px-3 py-1 rounded-md text-sm transition-colors")}
|
||||||
|
variant={activePage === "change-password" ? "default" : "secondary"}
|
||||||
|
onClick={() => setActivePage("change-password")}
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 主体内容 */}
|
||||||
|
<main className="flex-1 overflow-auto">{renderContent()}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
170
src/app/(protected)/dashboard/management/profile/page.tsx
Normal file
170
src/app/(protected)/dashboard/management/profile/page.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { getUserInfo } from "@/app/(protected)/dashboard/management/actions/getUserInfo";
|
||||||
|
import { updateUserInfo } from "@/app/(protected)/dashboard/management/actions/updateUserInfo";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
email: string;
|
||||||
|
emailVerified?: Date | null;
|
||||||
|
image: string | null;
|
||||||
|
role: "GUEST" | "USER" | "ADMIN" | "TEACHER";
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchUser() {
|
||||||
|
try {
|
||||||
|
const data = await getUserInfo();
|
||||||
|
setUser(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取用户信息失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const nameInput = document.getElementById(
|
||||||
|
"name"
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
const emailInput = document.getElementById(
|
||||||
|
"email"
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
if (!nameInput || !emailInput) {
|
||||||
|
alert("表单元素缺失");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("name", nameInput.value);
|
||||||
|
formData.append("email", emailInput.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await updateUserInfo(formData);
|
||||||
|
setUser(updatedUser);
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "更新用户信息失败";
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) return <p>加载中...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full p-6">
|
||||||
|
<div className="h-full w-full bg-card shadow-lg rounded-xl p-8 flex flex-col">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">用户信息</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 mb-6">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-2xl font-bold">
|
||||||
|
👤
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
defaultValue={user?.name || ""}
|
||||||
|
className="mt-1 block w-full border rounded-md p-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{user?.name || "未提供"}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<p>角色:{user?.role}</p>
|
||||||
|
<p>
|
||||||
|
邮箱验证时间:
|
||||||
|
{user.emailVerified
|
||||||
|
? new Date(user.emailVerified).toLocaleString()
|
||||||
|
: "未验证"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="border-border mb-6" />
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">用户ID</label>
|
||||||
|
<p className="mt-1 text-lg font-medium">{user.id}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">邮箱地址</label>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={user.email}
|
||||||
|
className="mt-1 block w-full border rounded-md p-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-lg font-medium">{user.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">注册时间</label>
|
||||||
|
<p className="mt-1 text-lg font-medium">
|
||||||
|
{new Date(user.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium">最后更新时间</label>
|
||||||
|
<p className="mt-1 text-lg font-medium">
|
||||||
|
{new Date(user.updatedAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end space-x-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
className="px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
编辑信息
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
422
src/app/(protected)/dashboard/page.tsx
Normal file
422
src/app/(protected)/dashboard/page.tsx
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import {
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
|
Target,
|
||||||
|
Activity,
|
||||||
|
GraduationCapIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers?: number;
|
||||||
|
totalProblems?: number;
|
||||||
|
totalSubmissions?: number;
|
||||||
|
totalStudents?: number;
|
||||||
|
completedProblems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
time: Date;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的完整信息
|
||||||
|
const fullUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullUser) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户角色获取不同的统计数据
|
||||||
|
let stats: Stats = {};
|
||||||
|
let recentActivity: Activity[] = [];
|
||||||
|
|
||||||
|
if (fullUser.role === "ADMIN") {
|
||||||
|
// 管理员统计
|
||||||
|
const [totalUsers, totalProblems, totalSubmissions, recentUsers] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.problem.count(),
|
||||||
|
prisma.submission.count(),
|
||||||
|
prisma.user.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
role: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalUsers, totalProblems, totalSubmissions };
|
||||||
|
recentActivity = recentUsers.map((user) => ({
|
||||||
|
type: "新用户注册",
|
||||||
|
title: user.name || user.email,
|
||||||
|
description: `角色: ${user.role}`,
|
||||||
|
time: user.createdAt,
|
||||||
|
}));
|
||||||
|
} else if (fullUser.role === "TEACHER") {
|
||||||
|
// 教师统计
|
||||||
|
const [totalStudents, totalProblems, totalSubmissions, recentSubmissions] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.user.count({ where: { role: "GUEST" } }),
|
||||||
|
prisma.problem.count({ where: { isPublished: true } }),
|
||||||
|
prisma.submission.count(),
|
||||||
|
prisma.submission.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
displayId: true,
|
||||||
|
localizations: {
|
||||||
|
where: { type: "TITLE", locale: "zh" },
|
||||||
|
select: { content: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalStudents, totalProblems, totalSubmissions };
|
||||||
|
recentActivity = recentSubmissions.map((sub) => ({
|
||||||
|
type: "学生提交",
|
||||||
|
title: `${sub.user.name || sub.user.email} 提交了题目 ${
|
||||||
|
sub.problem.displayId
|
||||||
|
}`,
|
||||||
|
description:
|
||||||
|
sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
|
||||||
|
time: sub.createdAt,
|
||||||
|
status: sub.status,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 学生统计
|
||||||
|
const [
|
||||||
|
totalProblems,
|
||||||
|
completedProblems,
|
||||||
|
totalSubmissions,
|
||||||
|
recentSubmissions,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.problem.count({ where: { isPublished: true } }),
|
||||||
|
prisma.submission.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
status: "AC",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.submission.count({ where: { userId: user.id } }),
|
||||||
|
prisma.submission.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
displayId: true,
|
||||||
|
localizations: {
|
||||||
|
where: { type: "TITLE", locale: "zh" },
|
||||||
|
select: { content: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalProblems, completedProblems, totalSubmissions };
|
||||||
|
recentActivity = recentSubmissions.map((sub) => ({
|
||||||
|
type: "我的提交",
|
||||||
|
title: `题目 ${sub.problem.displayId}`,
|
||||||
|
description:
|
||||||
|
sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
|
||||||
|
time: sub.createdAt,
|
||||||
|
status: sub.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleConfig = () => {
|
||||||
|
switch (fullUser.role) {
|
||||||
|
case "ADMIN":
|
||||||
|
return {
|
||||||
|
title: "系统管理后台",
|
||||||
|
description: "管理整个系统的用户、题目和统计数据",
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
label: "总用户数",
|
||||||
|
value: stats.totalUsers,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "总题目数",
|
||||||
|
value: stats.totalProblems,
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "总提交数",
|
||||||
|
value: stats.totalSubmissions,
|
||||||
|
icon: Activity,
|
||||||
|
color: "text-purple-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "管理员管理",
|
||||||
|
href: "/dashboard/management",
|
||||||
|
icon: Target,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "用户管理",
|
||||||
|
href: "/dashboard/usermanagement/guest",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "教师管理",
|
||||||
|
href: "/dashboard/usermanagement/teacher",
|
||||||
|
icon: GraduationCapIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "题目管理",
|
||||||
|
href: "/dashboard/usermanagement/problem",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
case "TEACHER":
|
||||||
|
return {
|
||||||
|
title: "教师教学平台",
|
||||||
|
description: "查看学生学习情况,管理教学资源",
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
label: "学生数量",
|
||||||
|
value: stats.totalStudents,
|
||||||
|
icon: Users,
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "题目数量",
|
||||||
|
value: stats.totalProblems,
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "提交数量",
|
||||||
|
value: stats.totalSubmissions,
|
||||||
|
icon: Activity,
|
||||||
|
color: "text-purple-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "用户管理",
|
||||||
|
href: "/dashboard/usermanagement/guest",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "题目管理",
|
||||||
|
href: "/dashboard/usermanagement/problem",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "完成情况",
|
||||||
|
href: "/dashboard/teacher/dashboard",
|
||||||
|
icon: BarChart3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "我的学习中心",
|
||||||
|
description: "继续您的编程学习之旅",
|
||||||
|
stats: [
|
||||||
|
{
|
||||||
|
label: "总题目数",
|
||||||
|
value: stats.totalProblems,
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "text-blue-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "已完成",
|
||||||
|
value: stats.completedProblems,
|
||||||
|
icon: CheckCircle,
|
||||||
|
color: "text-green-600",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "提交次数",
|
||||||
|
value: stats.totalSubmissions,
|
||||||
|
icon: Activity,
|
||||||
|
color: "text-purple-600",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "我的进度",
|
||||||
|
href: "/dashboard/student/dashboard",
|
||||||
|
icon: TrendingUp,
|
||||||
|
},
|
||||||
|
{ label: "开始做题", href: "/problemset", icon: BookOpen },
|
||||||
|
{ label: "个人设置", href: "/dashboard/management", icon: Target },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getRoleConfig();
|
||||||
|
const completionRate =
|
||||||
|
fullUser.role === "GUEST"
|
||||||
|
? (stats.totalProblems || 0) > 0
|
||||||
|
? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100
|
||||||
|
: 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 欢迎区域 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{config.title}</h1>
|
||||||
|
<p className="text-muted-foreground">{config.description}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{fullUser.role}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
欢迎回来,{fullUser.name || fullUser.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{config.stats.map((stat, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
{stat.label}
|
||||||
|
</CardTitle>
|
||||||
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学生进度条 */}
|
||||||
|
{fullUser.role === "GUEST" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-5 w-5" />
|
||||||
|
学习进度
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "}
|
||||||
|
道题目
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Progress value={completionRate} className="w-full" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
完成率: {completionRate.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 快速操作 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>快速操作</CardTitle>
|
||||||
|
<CardDescription>常用功能快速访问</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
{config.actions.map((action, index) => (
|
||||||
|
<Link key={index} href={action.href}>
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<action.icon className="mr-2 h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近活动 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近活动</CardTitle>
|
||||||
|
<CardDescription>查看最新的系统活动</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivity.length > 0 ? (
|
||||||
|
recentActivity.map((activity, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{activity.status === "AC" ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
) : activity.status ? (
|
||||||
|
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Clock className="h-5 w-5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{activity.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{activity.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{new Date(activity.time).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无活动</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
246
src/app/(protected)/dashboard/student/dashboard/page.tsx
Normal file
246
src/app/(protected)/dashboard/student/dashboard/page.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { getStudentDashboardData } from "@/app/(protected)/dashboard/actions/student-dashboard";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
completionData: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
errorData: {
|
||||||
|
total: number;
|
||||||
|
wrong: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
difficultProblems: Array<{
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
errorCount: number;
|
||||||
|
}>;
|
||||||
|
pieChartData: Array<{ name: string; value: number }>;
|
||||||
|
errorPieChartData: Array<{ name: string; value: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentDashboard() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
console.log("开始获取学生仪表板数据...");
|
||||||
|
setLoading(true);
|
||||||
|
const dashboardData = await getStudentDashboardData();
|
||||||
|
console.log("获取到的数据:", dashboardData);
|
||||||
|
console.log("完成情况:", dashboardData.completionData);
|
||||||
|
console.log("错误情况:", dashboardData.errorData);
|
||||||
|
console.log("易错题:", dashboardData.difficultProblems);
|
||||||
|
setData(dashboardData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("获取数据时出错:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "获取数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center text-red-500">错误: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
completionData,
|
||||||
|
errorData,
|
||||||
|
difficultProblems,
|
||||||
|
pieChartData,
|
||||||
|
errorPieChartData,
|
||||||
|
} = data;
|
||||||
|
const COLORS = ["#4CAF50", "#FFC107"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">学生仪表板</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 题目完成比例模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>题目完成比例</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>
|
||||||
|
已完成题目:{completionData.completed}/{completionData.total}
|
||||||
|
</span>
|
||||||
|
<span className="text-green-500">
|
||||||
|
{completionData.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={completionData.percentage} className="h-2" />
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieChartData.map(
|
||||||
|
(
|
||||||
|
entry: { name: string; value: number },
|
||||||
|
index: number
|
||||||
|
) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 错题比例模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>错题比例</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>
|
||||||
|
错题数量:{errorData.wrong}/{errorData.total}
|
||||||
|
</span>
|
||||||
|
<span className="text-yellow-500">{errorData.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={errorData.percentage} className="h-2" />
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={errorPieChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{errorPieChartData.map(
|
||||||
|
(
|
||||||
|
entry: { name: string; value: number },
|
||||||
|
index: number
|
||||||
|
) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 易错题练习模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>易错题练习</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>易错题数量:{difficultProblems.length}</span>
|
||||||
|
</div>
|
||||||
|
{difficultProblems.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>题目ID</TableHead>
|
||||||
|
<TableHead>题目名称</TableHead>
|
||||||
|
<TableHead>难度</TableHead>
|
||||||
|
<TableHead>错误次数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{difficultProblems.map(
|
||||||
|
(problem: {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
errorCount: number;
|
||||||
|
}) => (
|
||||||
|
<TableRow key={problem.id}>
|
||||||
|
<TableCell>{problem.id}</TableCell>
|
||||||
|
<TableCell>{problem.title}</TableCell>
|
||||||
|
<TableCell>{problem.difficulty}</TableCell>
|
||||||
|
<TableCell>{problem.errorCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
暂无易错题数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
274
src/app/(protected)/dashboard/teacher/dashboard/page.tsx
Normal file
274
src/app/(protected)/dashboard/teacher/dashboard/page.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
LabelList,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { TrendingUp } from "lucide-react";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
getDashboardStats,
|
||||||
|
ProblemCompletionData,
|
||||||
|
DifficultProblemData,
|
||||||
|
} from "@/app/(protected)/dashboard/actions/teacher-dashboard";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 5; // 每页显示的题目数量
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
completed: {
|
||||||
|
label: "已完成",
|
||||||
|
color: "#4CAF50", // 使用更鲜明的颜色
|
||||||
|
},
|
||||||
|
uncompleted: {
|
||||||
|
label: "未完成",
|
||||||
|
color: "#FFA726", // 使用更鲜明的颜色
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export default function TeacherDashboard() {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [chartData, setChartData] = useState<ProblemCompletionData[]>([]);
|
||||||
|
const [difficultProblems, setDifficultProblems] = useState<
|
||||||
|
DifficultProblemData[]
|
||||||
|
>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getDashboardStats();
|
||||||
|
setChartData(data.problemData);
|
||||||
|
setDifficultProblems(data.difficultProblems);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "获取数据失败");
|
||||||
|
console.error("Failed to fetch dashboard data:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(chartData.length / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// 获取当前页的数据
|
||||||
|
const currentPageData = chartData.slice(
|
||||||
|
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
currentPage * ITEMS_PER_PAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-red-500">错误: {error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 题目完成情况模块 */}
|
||||||
|
<Card className="min-h-[450px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>题目完成情况</CardTitle>
|
||||||
|
<CardDescription>各题目完成及未完成人数图表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-muted-foreground">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChartContainer config={chartConfig} className="height-{400px}">
|
||||||
|
<BarChart
|
||||||
|
data={currentPageData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
right: 30,
|
||||||
|
left: 40,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
barCategoryGap={20}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="problemDisplayId"
|
||||||
|
type="category"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent />}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="completedPercent"
|
||||||
|
name="已完成"
|
||||||
|
fill={chartConfig.completed.color}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="completed"
|
||||||
|
position="right"
|
||||||
|
fill="#000"
|
||||||
|
formatter={(value: number) => `${value}人`}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
<Bar
|
||||||
|
dataKey="uncompletedPercent"
|
||||||
|
name="未完成"
|
||||||
|
fill={chartConfig.uncompleted.color}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="uncompleted"
|
||||||
|
position="right"
|
||||||
|
fill="#000"
|
||||||
|
formatter={(value: number) => `${value}人`}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
{/* 分页控制 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.max(prev - 1, 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
第 {currentPage} 页,共 {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||||
|
<div className="flex gap-2 leading-none font-medium">
|
||||||
|
完成度趋势 <TrendingUp className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground leading-none">
|
||||||
|
显示各题目完成情况(已完成/未完成)
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 学生易错题模块 */}
|
||||||
|
<Card className="min-h-[450px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>学生易错题</CardTitle>
|
||||||
|
<CardDescription>各班级易错题数量及列表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>出错率较高题目数量:{difficultProblems.length}</span>
|
||||||
|
</div>
|
||||||
|
{difficultProblems.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-muted-foreground">
|
||||||
|
暂无易错题数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>题目编号</TableHead>
|
||||||
|
<TableHead>题目名称</TableHead>
|
||||||
|
<TableHead>错误次数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{difficultProblems.map((problem) => (
|
||||||
|
<TableRow key={problem.id}>
|
||||||
|
<TableCell>
|
||||||
|
{problem.problemDisplayId ||
|
||||||
|
problem.id.substring(0, 8)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{problem.problemTitle}</TableCell>
|
||||||
|
<TableCell>{problem.problemCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getDashboardStats } from "@/app/(protected)/dashboard/actions/teacher-dashboard";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
problemData: Array<{
|
||||||
|
problemId: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
problemTitle: string;
|
||||||
|
completed: number;
|
||||||
|
uncompleted: number;
|
||||||
|
total: number;
|
||||||
|
completedPercent: number;
|
||||||
|
uncompletedPercent: number;
|
||||||
|
}>;
|
||||||
|
difficultProblems: Array<{
|
||||||
|
id: string;
|
||||||
|
className: string;
|
||||||
|
problemCount: number;
|
||||||
|
problemTitle: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
}>;
|
||||||
|
totalProblems: number;
|
||||||
|
totalDifficultProblems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestDataPage() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getDashboardStats();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "获取数据失败");
|
||||||
|
console.error("Failed to fetch data:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>错误: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">数据测试页面</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">题目完成数据</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify(data?.problemData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">易错题数据</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify(data?.difficultProblems, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">统计信息</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify(
|
||||||
|
{
|
||||||
|
totalProblems: data?.totalProblems,
|
||||||
|
totalDifficultProblems: data?.totalDifficultProblems,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { Problem } from "@/generated/client";
|
||||||
|
|
||||||
|
export async function createProblem(
|
||||||
|
data: Omit<Problem, "id" | "createdAt" | "updatedAt">
|
||||||
|
) {
|
||||||
|
await prisma.problem.create({ data });
|
||||||
|
revalidatePath("/usermanagement/problem");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProblem(id: string) {
|
||||||
|
await prisma.problem.delete({ where: { id } });
|
||||||
|
revalidatePath("/usermanagement/problem");
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Role } from "@/generated/client";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { User } from "@/generated/client";
|
||||||
|
|
||||||
|
type UserType = "admin" | "teacher" | "guest";
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
userType: UserType,
|
||||||
|
data: Omit<User, "id" | "createdAt" | "updatedAt"> & { password?: string }
|
||||||
|
) {
|
||||||
|
let password = data.password;
|
||||||
|
if (password) {
|
||||||
|
password = await bcrypt.hash(password, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = userType.toUpperCase() as Role;
|
||||||
|
await prisma.user.create({ data: { ...data, password, role } });
|
||||||
|
revalidatePath(`/usermanagement/${userType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
userType: UserType,
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<User, "id" | "createdAt" | "updatedAt">>
|
||||||
|
) {
|
||||||
|
const updateData = { ...data };
|
||||||
|
|
||||||
|
// 如果包含密码字段且不为空,则进行加密
|
||||||
|
if (data.password && data.password.trim() !== "") {
|
||||||
|
updateData.password = await bcrypt.hash(data.password, 10);
|
||||||
|
} else {
|
||||||
|
// 如果密码为空,则从更新数据中移除密码字段,保持原密码不变
|
||||||
|
delete updateData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id }, data: updateData });
|
||||||
|
revalidatePath(`/usermanagement/${userType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userType: UserType, id: string) {
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
revalidatePath(`/usermanagement/${userType}`);
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import GenericLayout from "../components/GenericLayout";
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { adminConfig } from "@/features/user-management/config/admin";
|
||||||
|
import GenericPage from "@/features/user-management/components/generic-page";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <GenericPage userType="admin" config={adminConfig} />;
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
import ProtectedLayout from "./ProtectedLayout";
|
||||||
|
|
||||||
|
interface GenericLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenericLayout({
|
||||||
|
children,
|
||||||
|
allowedRoles,
|
||||||
|
}: GenericLayoutProps) {
|
||||||
|
return (
|
||||||
|
<ProtectedLayout allowedRoles={allowedRoles}>{children}</ProtectedLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
interface ProtectedLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProtectedLayout({
|
||||||
|
children,
|
||||||
|
allowedRoles,
|
||||||
|
}: ProtectedLayoutProps) {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="w-full h-full">{children}</div>;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import GenericLayout from "../components/GenericLayout";
|
||||||
|
|
||||||
|
export default function GuestLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GenericLayout allowedRoles={["ADMIN", "TEACHER"]}>
|
||||||
|
{children}
|
||||||
|
</GenericLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { guestConfig } from "@/features/user-management/config/guest";
|
||||||
|
import GenericPage from "@/features/user-management/components/generic-page";
|
||||||
|
|
||||||
|
export default function GuestPage() {
|
||||||
|
return <GenericPage userType="guest" config={guestConfig} />;
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
import GenericLayout from "../components/GenericLayout";
|
||||||
|
|
||||||
|
export default function ProblemLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GenericLayout allowedRoles={["ADMIN", "TEACHER"]}>
|
||||||
|
{children}
|
||||||
|
</GenericLayout>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { problemConfig } from "@/features/user-management/config/problem";
|
||||||
|
import GenericPage from "@/features/user-management/components/generic-page";
|
||||||
|
|
||||||
|
export default function ProblemPage() {
|
||||||
|
return <GenericPage userType="problem" config={problemConfig} />;
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import GenericLayout from "../components/GenericLayout";
|
||||||
|
|
||||||
|
export default function TeacherLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { teacherConfig } from "@/features/user-management/config/teacher";
|
||||||
|
import GenericPage from "@/features/user-management/components/generic-page";
|
||||||
|
|
||||||
|
export default function TeacherPage() {
|
||||||
|
return <GenericPage userType="teacher" config={teacherConfig} />;
|
||||||
|
}
|
@ -1,11 +1,15 @@
|
|||||||
import { AdminProtectedLayout } from "@/features/admin/ui/layouts/admin-protected-layout";
|
import { ProtectedLayout } from "@/features/dashboard/layouts/protected-layout";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout = ({ children }: LayoutProps) => {
|
const Layout = ({ children }: LayoutProps) => {
|
||||||
return <AdminProtectedLayout>{children}</AdminProtectedLayout>;
|
return (
|
||||||
|
<ProtectedLayout roles={["ADMIN", "TEACHER", "GUEST"]}>
|
||||||
|
{children}
|
||||||
|
</ProtectedLayout>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
39
src/components/UncompletedProject/sharedialog.tsx
Normal file
39
src/components/UncompletedProject/sharedialog.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogClose,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function ShareDialogContent({ link }: { link: string }) {
|
||||||
|
return (
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share link</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Anyone who has this link will be able to view this.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="grid flex-1 gap-2">
|
||||||
|
<Label htmlFor="link" className="sr-only">
|
||||||
|
Link
|
||||||
|
</Label>
|
||||||
|
<Input id="link" defaultValue={link} readOnly />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="sm:justify-start">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
);
|
||||||
|
}
|
154
src/components/UncompletedProject/wrongbook-dialog.tsx
Normal file
154
src/components/UncompletedProject/wrongbook-dialog.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
Copy,
|
||||||
|
Check as CheckIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function WrongbookDialog({
|
||||||
|
problems,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
problems: { id: string; name: string; status: string; url?: string }[];
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [copiedId, setCopiedId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopyLink = async (item: { id: string; url?: string }) => {
|
||||||
|
const link = `${window.location.origin}/problems/${item.id}`;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
setCopiedId(item.id);
|
||||||
|
setTimeout(() => setCopiedId(null), 2000); // 2秒后重置状态
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy link:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<button className="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">
|
||||||
|
全部错题
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6">
|
||||||
|
<DialogTitle className="text-lg font-bold">全部错题集</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">操作</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">
|
||||||
|
题目名称
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{problems.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30 transition"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyLink(item)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{copiedId === item.id ? (
|
||||||
|
<CheckIcon className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Link
|
||||||
|
href={item.url || `/problems/${item.id}`}
|
||||||
|
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{(() => {
|
||||||
|
if (item.status === "AC") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-green-500 text-white"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
} else if (item.status === "WA") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-red-500 text-white"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
["RE", "CE", "MLE", "TLE"].includes(item.status)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-orange-500 text-white"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="bg-gray-200 text-gray-700"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<Info className="w-3 h-3 mr-1" />
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,129 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AudioWaveform,
|
|
||||||
Bot,
|
|
||||||
Command,
|
|
||||||
Frame,
|
|
||||||
GalleryVerticalEnd,
|
|
||||||
Map,
|
|
||||||
PieChart,
|
|
||||||
Settings2,
|
|
||||||
SquareTerminal,
|
|
||||||
} from "lucide-react";
|
|
||||||
import * as React from "react";
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarRail,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import { NavMain } from "@/components/nav-main";
|
|
||||||
import { NavProjects } from "@/components/nav-projects";
|
|
||||||
import { TeamSwitcher } from "@/components/team-switcher";
|
|
||||||
import { NavUser, type NavUserProps } from "@/components/nav-user";
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
teams: [
|
|
||||||
{
|
|
||||||
name: "Acme Inc",
|
|
||||||
logo: GalleryVerticalEnd,
|
|
||||||
plan: "Enterprise",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Acme Corp.",
|
|
||||||
logo: AudioWaveform,
|
|
||||||
plan: "Startup",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Evil Corp.",
|
|
||||||
logo: Command,
|
|
||||||
plan: "Free",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: "Problemset",
|
|
||||||
url: "/dashboard/problemset",
|
|
||||||
icon: SquareTerminal,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Models",
|
|
||||||
url: "#",
|
|
||||||
icon: Bot,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Genesis",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Explorer",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Quantum",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Settings",
|
|
||||||
url: "/dashboard/settings",
|
|
||||||
icon: Settings2,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "General",
|
|
||||||
url: "/general",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Language Server",
|
|
||||||
url: "/language-server",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "Design Engineering",
|
|
||||||
url: "#",
|
|
||||||
icon: Frame,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Sales & Marketing",
|
|
||||||
url: "#",
|
|
||||||
icon: PieChart,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Travel",
|
|
||||||
url: "#",
|
|
||||||
icon: Map,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
|
||||||
user: NavUserProps["user"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({
|
|
||||||
user,
|
|
||||||
...props
|
|
||||||
}: AppSidebarProps) {
|
|
||||||
return (
|
|
||||||
<Sidebar collapsible="icon" {...props}>
|
|
||||||
<SidebarHeader>
|
|
||||||
<TeamSwitcher teams={data.teams} />
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent>
|
|
||||||
<NavMain items={data.navMain} />
|
|
||||||
<NavProjects projects={data.projects} />
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarFooter>
|
|
||||||
<NavUser user={user} />
|
|
||||||
</SidebarFooter>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
16
src/components/dashboard-button.tsx
Normal file
16
src/components/dashboard-button.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { LayoutDashboardIcon } from "lucide-react";
|
||||||
|
import { DropdownMenuItem } from "./ui/dropdown-menu";
|
||||||
|
|
||||||
|
export const DashboardButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/dashboard")}>
|
||||||
|
<LayoutDashboardIcon />
|
||||||
|
Dashboard
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
};
|
102
src/components/dynamic-breadcrumb.tsx
Normal file
102
src/components/dynamic-breadcrumb.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicBreadcrumb() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = [];
|
||||||
|
|
||||||
|
// 添加首页
|
||||||
|
breadcrumbs.push({ label: "首页", href: "/" });
|
||||||
|
|
||||||
|
let currentPath = "";
|
||||||
|
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
currentPath += `/${segment}`;
|
||||||
|
|
||||||
|
// 根据路径段生成标签
|
||||||
|
let label = segment;
|
||||||
|
|
||||||
|
// 路径映射
|
||||||
|
const pathMap: Record<string, string> = {
|
||||||
|
dashboard: "仪表板",
|
||||||
|
management: "管理面板",
|
||||||
|
profile: "用户信息",
|
||||||
|
"change-password": "修改密码",
|
||||||
|
problems: "题目",
|
||||||
|
problemset: "题目集",
|
||||||
|
admin: "管理后台",
|
||||||
|
teacher: "教师平台",
|
||||||
|
student: "学生平台",
|
||||||
|
usermanagement: "用户管理",
|
||||||
|
userdashboard: "用户仪表板",
|
||||||
|
protected: "受保护",
|
||||||
|
app: "应用",
|
||||||
|
auth: "认证",
|
||||||
|
"sign-in": "登录",
|
||||||
|
"sign-up": "注册",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是数字,可能是题目ID,显示为"题目详情"
|
||||||
|
if (/^\d+$/.test(segment)) {
|
||||||
|
label = "详情";
|
||||||
|
} else if (pathMap[segment]) {
|
||||||
|
label = pathMap[segment];
|
||||||
|
} else {
|
||||||
|
// 将 kebab-case 转换为中文
|
||||||
|
label = segment
|
||||||
|
.split("-")
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一个项目不添加链接
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
breadcrumbs.push({ label });
|
||||||
|
} else {
|
||||||
|
breadcrumbs.push({ label, href: currentPath });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return breadcrumbs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbs = generateBreadcrumbs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{breadcrumbs.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center">
|
||||||
|
<BreadcrumbItem className="hidden md:block">
|
||||||
|
{item.href ? (
|
||||||
|
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{index < breadcrumbs.length - 1 && (
|
||||||
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
}
|
@ -4,6 +4,7 @@ import {
|
|||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
@ -17,55 +18,47 @@ import {
|
|||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
export interface NavMainProps {
|
export function NavMain({
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
items: {
|
items: {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon?: LucideIcon;
|
icon: LucideIcon;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
items?: {
|
items?: {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
}
|
}) {
|
||||||
|
|
||||||
export function NavMain({ items }: NavMainProps) {
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) =>
|
{items.map((item) => (
|
||||||
!item.items ? (
|
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
<SidebarMenuButton asChild tooltip={item.title}>
|
||||||
<a href={item.url}>
|
<a href={item.url}>
|
||||||
{item.icon && <item.icon />}
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
{item.items?.length ? (
|
||||||
) : (
|
<>
|
||||||
<Collapsible
|
|
||||||
key={item.title}
|
|
||||||
asChild
|
|
||||||
defaultOpen={item.isActive}
|
|
||||||
className="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton tooltip={item.title}>
|
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||||
{item.icon && <item.icon />}
|
<ChevronRight />
|
||||||
<span>{item.title}</span>
|
<span className="sr-only">Toggle</span>
|
||||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
</SidebarMenuAction>
|
||||||
</SidebarMenuButton>
|
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
{item.items.map((subItem) => (
|
{item.items?.map((subItem) => (
|
||||||
<SidebarMenuSubItem key={subItem.title}>
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild>
|
||||||
<a href={`${item.url}${subItem.url}`}>
|
<a href={subItem.url}>
|
||||||
<span>{subItem.title}</span>
|
<span>{subItem.title}</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
@ -73,10 +66,11 @@ export function NavMain({ items }: NavMainProps) {
|
|||||||
))}
|
))}
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
)
|
))}
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
|
@ -1,20 +1,15 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BookX,
|
||||||
Folder,
|
Folder,
|
||||||
Forward,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Trash2,
|
Share,
|
||||||
type LucideIcon,
|
Check,
|
||||||
} from "lucide-react"
|
X,
|
||||||
|
Info,
|
||||||
import {
|
AlertTriangle,
|
||||||
DropdownMenu,
|
} from "lucide-react";
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
@ -23,29 +18,85 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
|
import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog";
|
||||||
|
import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog";
|
||||||
|
|
||||||
export function NavProjects({
|
export function NavProjects({
|
||||||
projects,
|
projects,
|
||||||
}: {
|
}: {
|
||||||
projects: {
|
projects: {
|
||||||
name: string
|
id: string;
|
||||||
url: string
|
name: string;
|
||||||
icon: LucideIcon
|
status: string;
|
||||||
}[]
|
url?: string;
|
||||||
|
}[];
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar();
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
const [shareLink, setShareLink] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
<SidebarGroupLabel>待完成项目</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{projects.map((item) => (
|
{projects.slice(0, 1).map((item) => (
|
||||||
<SidebarMenuItem key={item.name}>
|
<SidebarMenuItem key={item.id}>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<a href={item.url}>
|
<a href={item.url}>
|
||||||
<item.icon />
|
<BookX />
|
||||||
<span>{item.name}</span>
|
<span className="flex w-full items-center">
|
||||||
|
<span
|
||||||
|
className="truncate max-w-[120px] flex-1"
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
{(() => {
|
||||||
|
if (item.status === "AC") {
|
||||||
|
return (
|
||||||
|
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-green-500 bg-green-500 text-white">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (item.status === "WA") {
|
||||||
|
return (
|
||||||
|
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-red-500 bg-red-500 text-white">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
["RE", "CE", "MLE", "TLE"].includes(item.status)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-orange-500 bg-orange-500 text-white">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-gray-400 bg-gray-100 text-gray-700">
|
||||||
|
<Info className="w-3 h-3" />
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -60,30 +111,48 @@ export function NavProjects({
|
|||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
align={isMobile ? "end" : "start"}
|
align={isMobile ? "end" : "start"}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (item.url) {
|
||||||
|
router.push(item.url);
|
||||||
|
} else {
|
||||||
|
router.push(`/problems/${item.id}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Folder className="text-muted-foreground" />
|
<Folder className="text-muted-foreground" />
|
||||||
<span>View Project</span>
|
<span>查看</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<Forward className="text-muted-foreground" />
|
onClick={(e) => {
|
||||||
<span>Share Project</span>
|
e.stopPropagation();
|
||||||
</DropdownMenuItem>
|
setShareLink(
|
||||||
<DropdownMenuSeparator />
|
`${window.location.origin}/problems/${item.id}`
|
||||||
<DropdownMenuItem>
|
);
|
||||||
<Trash2 className="text-muted-foreground" />
|
setShareOpen(true);
|
||||||
<span>Delete Project</span>
|
}}
|
||||||
|
>
|
||||||
|
<Share className="text-muted-foreground mr-2" />
|
||||||
|
<span>复制链接</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
<WrongbookDialog problems={projects}>
|
||||||
<MoreHorizontal className="text-sidebar-foreground/70" />
|
<SidebarMenuButton>
|
||||||
<span>More</span>
|
<MoreHorizontal />
|
||||||
|
<span>更多</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
</WrongbookDialog>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
<Dialog open={shareOpen} onOpenChange={setShareOpen}>
|
||||||
|
<ShareDialogContent link={shareLink} />
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
39
src/components/nav-secondary.tsx
Normal file
39
src/components/nav-secondary.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export function NavSecondary({
|
||||||
|
items,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
items: {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}[];
|
||||||
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
|
return (
|
||||||
|
<SidebarGroup {...props}>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton asChild size="sm">
|
||||||
|
<a href={item.url}>
|
||||||
|
<item.icon />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
@ -1,13 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
|
||||||
BadgeCheck,
|
|
||||||
Bell,
|
|
||||||
ChevronsUpDown,
|
|
||||||
CreditCard,
|
|
||||||
LogOut,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
@ -23,20 +15,37 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { BadgeCheck, ChevronsUpDown, UserPen, LogOut } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
export interface NavUserProps {
|
export function NavUser({
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
|
}) {
|
||||||
|
const { isMobile } = useSidebar();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await signOut({
|
||||||
|
callbackUrl: "/sign-in",
|
||||||
|
redirect: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavUser({
|
function handleAccount() {
|
||||||
user,
|
if (user && user.email) {
|
||||||
}: NavUserProps) {
|
router.replace("/dashboard/management");
|
||||||
const { isMobile } = useSidebar();
|
} else {
|
||||||
|
router.replace("/sign-in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@ -78,28 +87,17 @@ export function NavUser({
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handleAccount}>
|
||||||
<Sparkles />
|
|
||||||
Upgrade to Pro
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<BadgeCheck />
|
<BadgeCheck />
|
||||||
Account
|
Account
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={() => router.push("/sign-in")}>
|
||||||
<CreditCard />
|
<UserPen />
|
||||||
Billing
|
Switch User
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Bell />
|
|
||||||
Notifications
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut />
|
<LogOut />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
83
src/components/sidebar/admin-sidebar.tsx
Normal file
83
src/components/sidebar/admin-sidebar.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { User } from "next-auth";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { NavMain } from "@/components/nav-main";
|
||||||
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import { LifeBuoy, Send, Shield } from "lucide-react";
|
||||||
|
import { NavSecondary } from "@/components/nav-secondary";
|
||||||
|
|
||||||
|
const adminData = {
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "管理面板",
|
||||||
|
url: "/dashboard",
|
||||||
|
icon: Shield,
|
||||||
|
isActive: true,
|
||||||
|
items: [
|
||||||
|
{ title: "管理员管理", url: "/dashboard/usermanagement/admin" },
|
||||||
|
{ title: "用户管理", url: "/dashboard/usermanagement/guest" },
|
||||||
|
{ title: "教师管理", url: "/dashboard/usermanagement/teacher" },
|
||||||
|
{ title: "题目管理", url: "/dashboard/usermanagement/problem" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navSecondary: [
|
||||||
|
{ title: "帮助", url: `${siteConfig.url.repo.github}/issues`, icon: LifeBuoy },
|
||||||
|
{ title: "反馈", url: `${siteConfig.url.repo.github}/pulls`, icon: Send },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSidebar({
|
||||||
|
user,
|
||||||
|
...props
|
||||||
|
}: AdminSidebarProps & React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const userInfo = {
|
||||||
|
name: user.name ?? "管理员",
|
||||||
|
email: user.email ?? "",
|
||||||
|
avatar: user.image ?? "/avatars/default.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<a href="#">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Shield className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Admin</span>
|
||||||
|
<span className="truncate text-xs">管理后台</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={adminData.navMain} />
|
||||||
|
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser user={userInfo} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
134
src/components/sidebar/app-sidebar.tsx
Normal file
134
src/components/sidebar/app-sidebar.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { User } from "next-auth";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { NavMain } from "@/components/nav-main";
|
||||||
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import { NavProjects } from "@/components/nav-projects";
|
||||||
|
import { NavSecondary } from "@/components/nav-secondary";
|
||||||
|
import { Command, LifeBuoy, Send, Shield } from "lucide-react";
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "管理面板",
|
||||||
|
url: "/dashboard",
|
||||||
|
icon: Shield,
|
||||||
|
isActive: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "我的进度",
|
||||||
|
url: "/dashboard/student/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "开始做题",
|
||||||
|
url: "/problemset",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "个人设置",
|
||||||
|
url: "/dashboard/management",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// title: "已完成事项",
|
||||||
|
// url: "#",
|
||||||
|
// icon: BookOpen,
|
||||||
|
// items: [
|
||||||
|
// {
|
||||||
|
// title: "全部编程集",
|
||||||
|
// url: "#",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "错题集",
|
||||||
|
// url: "#",
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "收藏集",
|
||||||
|
// url: "#",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// title: "设置",
|
||||||
|
// url: "#",
|
||||||
|
// icon: Settings2,
|
||||||
|
// items: [
|
||||||
|
// {
|
||||||
|
// title: "语言",
|
||||||
|
// url: "#",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
navSecondary: [
|
||||||
|
{
|
||||||
|
title: "帮助",
|
||||||
|
url: `${siteConfig.url.repo.github}/issues`,
|
||||||
|
icon: LifeBuoy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "反馈",
|
||||||
|
url: `${siteConfig.url.repo.github}/pulls`,
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AppSidebarProps {
|
||||||
|
user: User;
|
||||||
|
wrongProblems: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ user, wrongProblems, ...props }: AppSidebarProps) {
|
||||||
|
const userInfo = {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
avatar: user.image ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<a href="#">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Command className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Judge4c</span>
|
||||||
|
<span className="truncate text-xs">Programming Learning</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={data.navMain} />
|
||||||
|
<NavProjects projects={wrongProblems} />
|
||||||
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser user={userInfo} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
99
src/components/sidebar/teacher-sidebar.tsx
Normal file
99
src/components/sidebar/teacher-sidebar.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Command, LifeBuoy, Send, Shield } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { User } from "next-auth";
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { NavMain } from "@/components/nav-main";
|
||||||
|
import { NavUser } from "@/components/nav-user";
|
||||||
|
import { NavSecondary } from "@/components/nav-secondary";
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: "管理面板",
|
||||||
|
url: "/dashboard",
|
||||||
|
icon: Shield,
|
||||||
|
isActive: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "用户管理",
|
||||||
|
url: "/dashboard/usermanagement/guest",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "题目管理",
|
||||||
|
url: "/dashboard/usermanagement/problem",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "完成情况",
|
||||||
|
url: "/dashboard/teacher/dashboard",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navSecondary: [
|
||||||
|
{
|
||||||
|
title: "帮助",
|
||||||
|
url: `${siteConfig.url.repo.github}/issues`,
|
||||||
|
icon: LifeBuoy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "反馈",
|
||||||
|
url: `${siteConfig.url.repo.github}/pulls`,
|
||||||
|
icon: Send,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TeacherSidebarProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherSidebar({
|
||||||
|
user,
|
||||||
|
...props
|
||||||
|
}: TeacherSidebarProps & React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const userInfo = {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
avatar: user.image ?? "/avatars/teacher.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar variant="inset" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton size="lg" asChild>
|
||||||
|
<a href="#">
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||||
|
<Command className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">Judge4c 教师端</span>
|
||||||
|
<span className="truncate text-xs">Teaching Platform</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
<NavMain items={data.navMain} />
|
||||||
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser user={userInfo} />
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
@ -13,6 +13,7 @@ import { auth, signIn, signOut } from "@/lib/auth";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { SettingsButton } from "@/components/settings-button";
|
import { SettingsButton } from "@/components/settings-button";
|
||||||
|
import { DashboardButton } from "@/components/dashboard-button";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
const handleLogIn = async () => {
|
const handleLogIn = async () => {
|
||||||
@ -88,6 +89,7 @@ const UserAvatar = async () => {
|
|||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DashboardButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
<DropdownMenuItem onClick={handleLogOut}>
|
<DropdownMenuItem onClick={handleLogOut}>
|
||||||
<LogOutIcon />
|
<LogOutIcon />
|
||||||
|
32
src/features/dashboard/layouts/protected-layout.tsx
Normal file
32
src/features/dashboard/layouts/protected-layout.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Role } from "@/generated/client";
|
||||||
|
import { auth, signIn } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
interface ProtectedLayoutProps {
|
||||||
|
roles: Role[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProtectedLayout = async ({
|
||||||
|
roles,
|
||||||
|
children,
|
||||||
|
}: ProtectedLayoutProps) => {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
await signIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !roles.includes(user.role)) {
|
||||||
|
redirect("/unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
24
src/features/user-management/components/generic-page.tsx
Normal file
24
src/features/user-management/components/generic-page.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { UserTable } from "./user-table";
|
||||||
|
import { Role } from "@/generated/client";
|
||||||
|
import { UserConfig } from "./user-table";
|
||||||
|
import type { User, Problem } from "@/generated/client";
|
||||||
|
|
||||||
|
interface GenericPageProps {
|
||||||
|
userType: "admin" | "teacher" | "guest" | "problem";
|
||||||
|
config: UserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GenericPage({
|
||||||
|
userType,
|
||||||
|
config,
|
||||||
|
}: GenericPageProps) {
|
||||||
|
if (userType === "problem") {
|
||||||
|
const data: Problem[] = await prisma.problem.findMany({});
|
||||||
|
return <UserTable config={config} data={data} />;
|
||||||
|
} else {
|
||||||
|
const role = userType.toUpperCase() as Role;
|
||||||
|
const data: User[] = await prisma.user.findMany({ where: { role } });
|
||||||
|
return <UserTable config={config} data={data} />;
|
||||||
|
}
|
||||||
|
}
|
1134
src/features/user-management/components/user-table.tsx
Normal file
1134
src/features/user-management/components/user-table.tsx
Normal file
File diff suppressed because it is too large
Load Diff
28
src/features/user-management/config/admin.ts
Normal file
28
src/features/user-management/config/admin.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
createUserConfig,
|
||||||
|
baseUserSchema,
|
||||||
|
baseAddUserSchema,
|
||||||
|
baseEditUserSchema,
|
||||||
|
} from "./base-config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 管理员数据校验 schema
|
||||||
|
export const adminSchema = baseUserSchema;
|
||||||
|
export type Admin = z.infer<typeof adminSchema>;
|
||||||
|
|
||||||
|
// 添加管理员表单校验 schema
|
||||||
|
export const addAdminSchema = baseAddUserSchema;
|
||||||
|
export type AddAdminFormData = z.infer<typeof addAdminSchema>;
|
||||||
|
|
||||||
|
// 编辑管理员表单校验 schema
|
||||||
|
export const editAdminSchema = baseEditUserSchema;
|
||||||
|
export type EditAdminFormData = z.infer<typeof editAdminSchema>;
|
||||||
|
|
||||||
|
// 管理员配置
|
||||||
|
export const adminConfig = createUserConfig(
|
||||||
|
"admin",
|
||||||
|
"管理员列表",
|
||||||
|
"添加管理员",
|
||||||
|
"请输入管理员姓名",
|
||||||
|
"请输入管理员邮箱"
|
||||||
|
);
|
124
src/features/user-management/config/base-config.ts
Normal file
124
src/features/user-management/config/base-config.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 基础用户 schema
|
||||||
|
export const baseUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基础添加用户 schema
|
||||||
|
export const baseAddUserSchema = z.object({
|
||||||
|
name: z.string().min(1, "姓名为必填项"),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基础编辑用户 schema
|
||||||
|
export const baseEditUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1, "姓名为必填项"),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 基础表格列配置
|
||||||
|
export const baseColumns = [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "姓名",
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
placeholder: "搜索姓名",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
label: "邮箱",
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
placeholder: "搜索邮箱",
|
||||||
|
},
|
||||||
|
{ key: "createdAt", label: "创建时间", sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 基础表单字段配置
|
||||||
|
export const baseFormFields = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "姓名",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "请输入姓名",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
label: "邮箱",
|
||||||
|
type: "email",
|
||||||
|
placeholder: "请输入邮箱",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "password",
|
||||||
|
label: "密码",
|
||||||
|
type: "password",
|
||||||
|
placeholder: "请输入8-32位密码",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
label: "创建时间",
|
||||||
|
type: "datetime-local",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 基础操作配置
|
||||||
|
export const baseActions = {
|
||||||
|
add: { label: "添加", icon: "PlusIcon" },
|
||||||
|
edit: { label: "编辑", icon: "PencilIcon" },
|
||||||
|
delete: { label: "删除", icon: "TrashIcon" },
|
||||||
|
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// 基础分页配置
|
||||||
|
export const basePagination = {
|
||||||
|
pageSizes: [10, 50, 100, 500],
|
||||||
|
defaultPageSize: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建用户配置的工厂函数
|
||||||
|
export function createUserConfig(
|
||||||
|
userType: string,
|
||||||
|
title: string,
|
||||||
|
addLabel: string,
|
||||||
|
namePlaceholder: string,
|
||||||
|
emailPlaceholder: string
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
userType,
|
||||||
|
title,
|
||||||
|
apiPath: "/api/user",
|
||||||
|
columns: baseColumns,
|
||||||
|
formFields: baseFormFields.map((field) => ({
|
||||||
|
...field,
|
||||||
|
placeholder:
|
||||||
|
field.key === "name"
|
||||||
|
? namePlaceholder
|
||||||
|
: field.key === "email"
|
||||||
|
? emailPlaceholder
|
||||||
|
: field.placeholder,
|
||||||
|
})),
|
||||||
|
actions: {
|
||||||
|
...baseActions,
|
||||||
|
add: { ...baseActions.add, label: addLabel },
|
||||||
|
},
|
||||||
|
pagination: basePagination,
|
||||||
|
};
|
||||||
|
}
|
24
src/features/user-management/config/guest.ts
Normal file
24
src/features/user-management/config/guest.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
createUserConfig,
|
||||||
|
baseUserSchema,
|
||||||
|
baseAddUserSchema,
|
||||||
|
baseEditUserSchema,
|
||||||
|
} from "./base-config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const guestSchema = baseUserSchema;
|
||||||
|
export type Guest = z.infer<typeof guestSchema>;
|
||||||
|
|
||||||
|
export const addGuestSchema = baseAddUserSchema;
|
||||||
|
export type AddGuestFormData = z.infer<typeof addGuestSchema>;
|
||||||
|
|
||||||
|
export const editGuestSchema = baseEditUserSchema;
|
||||||
|
export type EditGuestFormData = z.infer<typeof editGuestSchema>;
|
||||||
|
|
||||||
|
export const guestConfig = createUserConfig(
|
||||||
|
"guest",
|
||||||
|
"客户列表",
|
||||||
|
"添加客户",
|
||||||
|
"请输入客户姓名",
|
||||||
|
"请输入客户邮箱"
|
||||||
|
);
|
53
src/features/user-management/config/problem.ts
Normal file
53
src/features/user-management/config/problem.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const problemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addProblemSchema = z.object({
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const editProblemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const problemConfig = {
|
||||||
|
userType: "problem",
|
||||||
|
title: "题目列表",
|
||||||
|
apiPath: "/api/problem",
|
||||||
|
columns: [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{
|
||||||
|
key: "displayId",
|
||||||
|
label: "题目编号",
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
placeholder: "搜索编号",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "difficulty",
|
||||||
|
label: "难度",
|
||||||
|
sortable: true,
|
||||||
|
searchable: true,
|
||||||
|
placeholder: "搜索难度",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ key: "displayId", label: "题目编号", type: "number", required: true },
|
||||||
|
{ key: "difficulty", label: "难度", type: "text", required: true },
|
||||||
|
],
|
||||||
|
actions: {
|
||||||
|
add: { label: "添加题目", icon: "PlusIcon" },
|
||||||
|
edit: { label: "编辑", icon: "PencilIcon" },
|
||||||
|
delete: { label: "删除", icon: "TrashIcon" },
|
||||||
|
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||||
|
},
|
||||||
|
pagination: { pageSizes: [10, 50, 100, 500], defaultPageSize: 10 },
|
||||||
|
};
|
24
src/features/user-management/config/teacher.ts
Normal file
24
src/features/user-management/config/teacher.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
createUserConfig,
|
||||||
|
baseUserSchema,
|
||||||
|
baseAddUserSchema,
|
||||||
|
baseEditUserSchema,
|
||||||
|
} from "./base-config";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const teacherSchema = baseUserSchema;
|
||||||
|
export type Teacher = z.infer<typeof teacherSchema>;
|
||||||
|
|
||||||
|
export const addTeacherSchema = baseAddUserSchema;
|
||||||
|
export type AddTeacherFormData = z.infer<typeof addTeacherSchema>;
|
||||||
|
|
||||||
|
export const editTeacherSchema = baseEditUserSchema;
|
||||||
|
export type EditTeacherFormData = z.infer<typeof editTeacherSchema>;
|
||||||
|
|
||||||
|
export const teacherConfig = createUserConfig(
|
||||||
|
"teacher",
|
||||||
|
"教师列表",
|
||||||
|
"添加教师",
|
||||||
|
"请输入教师姓名",
|
||||||
|
"请输入教师邮箱"
|
||||||
|
);
|
Loading…
Reference in New Issue
Block a user