mirror of
https://github.com/cfngc4594/monaco-editor-lsp-next.git
synced 2025-07-04 09:20:53 +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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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 {
|
||||
ADMIN
|
||||
GUEST
|
||||
TEACHER
|
||||
}
|
||||
|
||||
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 { ProblemEditLayout } from "@/features/admin/ui/layouts/problem-edit-layout";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@ -13,7 +12,7 @@ const Layout = async ({ children, params }: LayoutProps) => {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return <ProblemEditLayout>{children}</ProblemEditLayout>;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
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 {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout = ({ children }: LayoutProps) => {
|
||||
return <AdminProtectedLayout>{children}</AdminProtectedLayout>;
|
||||
return (
|
||||
<ProtectedLayout roles={["ADMIN", "TEACHER", "GUEST"]}>
|
||||
{children}
|
||||
</ProtectedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
@ -17,66 +18,59 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
export interface NavMainProps {
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function NavMain({ items }: NavMainProps) {
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) =>
|
||||
!item.items ? (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
{items.map((item) => (
|
||||
<Collapsible key={item.title} asChild defaultOpen={item.isActive}>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
{item.items?.length ? (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuAction className="data-[state=open]:rotate-90">
|
||||
<ChevronRight />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</SidebarMenuAction>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : null}
|
||||
</SidebarMenuItem>
|
||||
) : (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={item.isActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={`${item.url}${subItem.url}`}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
)}
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
@ -1,20 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BookX,
|
||||
Folder,
|
||||
Forward,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
Share,
|
||||
Check,
|
||||
X,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
@ -23,67 +18,141 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
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({
|
||||
projects,
|
||||
}: {
|
||||
projects: {
|
||||
name: string
|
||||
url: string
|
||||
icon: LucideIcon
|
||||
}[]
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
url?: string;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const { isMobile } = useSidebar();
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [shareLink, setShareLink] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Projects</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>View Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward className="text-muted-foreground" />
|
||||
<span>Share Project</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>Delete Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>待完成项目</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{projects.slice(0, 1).map((item) => (
|
||||
<SidebarMenuItem key={item.id}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<BookX />
|
||||
<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>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (item.url) {
|
||||
router.push(item.url);
|
||||
} else {
|
||||
router.push(`/problems/${item.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Folder className="text-muted-foreground" />
|
||||
<span>查看</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShareLink(
|
||||
`${window.location.origin}/problems/${item.id}`
|
||||
);
|
||||
setShareOpen(true);
|
||||
}}
|
||||
>
|
||||
<Share className="text-muted-foreground mr-2" />
|
||||
<span>复制链接</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<WrongbookDialog problems={projects}>
|
||||
<SidebarMenuButton>
|
||||
<MoreHorizontal />
|
||||
<span>更多</span>
|
||||
</SidebarMenuButton>
|
||||
</WrongbookDialog>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<MoreHorizontal className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
</SidebarMenu>
|
||||
</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";
|
||||
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
@ -23,20 +15,37 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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";
|
||||
|
||||
export interface NavUserProps {
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: NavUserProps) {
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
await signOut({
|
||||
callbackUrl: "/sign-in",
|
||||
redirect: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccount() {
|
||||
if (user && user.email) {
|
||||
router.replace("/dashboard/management");
|
||||
} else {
|
||||
router.replace("/sign-in");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@ -78,28 +87,17 @@ export function NavUser({
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleAccount}>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
<DropdownMenuItem onClick={() => router.push("/sign-in")}>
|
||||
<UserPen />
|
||||
Switch User
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsButton } from "@/components/settings-button";
|
||||
import { DashboardButton } from "@/components/dashboard-button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
||||
const handleLogIn = async () => {
|
||||
@ -88,6 +89,7 @@ const UserAvatar = async () => {
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DashboardButton />
|
||||
<SettingsButton />
|
||||
<DropdownMenuItem onClick={handleLogOut}>
|
||||
<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