diff --git a/bun.lock b/bun.lock index 8f61547..e25f1e3 100644 --- a/bun.lock +++ b/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=="], diff --git a/prisma/migrations/20250621055555_lg/migration.sql b/prisma/migrations/20250621055555_lg/migration.sql new file mode 100644 index 0000000..e3f5f45 --- /dev/null +++ b/prisma/migrations/20250621055555_lg/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Role" ADD VALUE 'TEACHER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fa0abd..7005118 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,7 @@ generator client { enum Role { ADMIN GUEST + TEACHER } enum Difficulty { diff --git a/src/app/(protected)/dashboard/actions/student-dashboard.ts b/src/app/(protected)/dashboard/actions/student-dashboard.ts new file mode 100644 index 0000000..8880d77 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/student-dashboard.ts @@ -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(); + const attemptedProblems = new Set(); + const wrongSubmissions = new Map(); // 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(); + + // 统计在已完成的题目中,哪些题目曾经有过错误提交 + 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 : "未知错误"}` + ); + } +} diff --git a/src/app/(protected)/dashboard/actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts new file mode 100644 index 0000000..874eba3 --- /dev/null +++ b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts @@ -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; + totalUsers: Set; + 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; + } + >(); + + 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, + }; +} diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx similarity index 68% rename from src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx rename to src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx index fb46c63..de77dfd 100644 --- a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx +++ b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx @@ -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 {children}; + return <>{children}; }; export default Layout; diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx similarity index 100% rename from src/app/(protected)/admin/problems/[problemId]/edit/page.tsx rename to src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx diff --git a/src/app/(protected)/dashboard/layout.tsx b/src/app/(protected)/dashboard/layout.tsx new file mode 100644 index 0000000..e403e75 --- /dev/null +++ b/src/app/(protected)/dashboard/layout.tsx @@ -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 ; + case "TEACHER": + return ; + case "GUEST": + default: + // 学生(GUEST)需要查询错题数据 + return ; + } + }; + + // 只有学生才需要查询错题数据 + 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 ( + + {fullUser.role === "GUEST" ? ( + + ) : ( + renderSidebar() + )} + +
+
+ + + +
+
+
{children}
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/management/actions/changePassword.ts b/src/app/(protected)/dashboard/management/actions/changePassword.ts new file mode 100644 index 0000000..6e8ba92 --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/changePassword.ts @@ -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("修改密码失败"); + } +} diff --git a/src/app/(protected)/dashboard/management/actions/getUserInfo.ts b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts new file mode 100644 index 0000000..1916740 --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts @@ -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("获取用户信息失败"); + } +} diff --git a/src/app/(protected)/dashboard/management/actions/index.ts b/src/app/(protected)/dashboard/management/actions/index.ts new file mode 100644 index 0000000..baf0cc7 --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/index.ts @@ -0,0 +1,3 @@ +export { getUserInfo } from "./getUserInfo"; +export { updateUserInfo } from "./updateUserInfo"; +export { changePassword } from "./changePassword"; diff --git a/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts new file mode 100644 index 0000000..e8d539b --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts @@ -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("更新用户信息失败"); + } +} diff --git a/src/app/(protected)/dashboard/management/change-password/page.tsx b/src/app/(protected)/dashboard/management/change-password/page.tsx new file mode 100644 index 0000000..e39300a --- /dev/null +++ b/src/app/(protected)/dashboard/management/change-password/page.tsx @@ -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 ( +
+
+

修改密码

+
+
+ + 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 + /> +
+ +
+ + 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 && ( +

+ 密码强度: + +   + {strengthLabel} +

+ )} +
+ +
+ + 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 && ( +

密码不一致

+ )} +
+ +
+ +
+
+
+ + {showSuccess && ( +
+ ✅ 密码修改成功! +
+ )} +
+ ); +} diff --git a/src/app/(protected)/dashboard/management/page.tsx b/src/app/(protected)/dashboard/management/page.tsx new file mode 100644 index 0000000..5545c71 --- /dev/null +++ b/src/app/(protected)/dashboard/management/page.tsx @@ -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 ; + case "change-password": + return ; + default: + return ; + } + }; + + return ( +
+ {/* 顶部导航栏 */} +
+ {/* 页面切换按钮 */} +
+ + +
+
+ + {/* 主体内容 */} +
{renderContent()}
+
+ ); +} diff --git a/src/app/(protected)/dashboard/management/profile/page.tsx b/src/app/(protected)/dashboard/management/profile/page.tsx new file mode 100644 index 0000000..9d6e319 --- /dev/null +++ b/src/app/(protected)/dashboard/management/profile/page.tsx @@ -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(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

加载中...

; + + return ( +
+
+

用户信息

+ +
+
+
+ 👤 +
+
+
+ {isEditing ? ( + + ) : ( +

+ {user?.name || "未提供"} +

+ )} +

角色:{user?.role}

+

+ 邮箱验证时间: + {user.emailVerified + ? new Date(user.emailVerified).toLocaleString() + : "未验证"} +

+
+
+ +
+ +
+
+ +

{user.id}

+
+ +
+ + {isEditing ? ( + + ) : ( +

{user.email}

+ )} +
+ +
+ +

+ {new Date(user.createdAt).toLocaleString()} +

+
+ +
+ +

+ {new Date(user.updatedAt).toLocaleString()} +

+
+
+ +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..b685250 --- /dev/null +++ b/src/app/(protected)/dashboard/page.tsx @@ -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 ( +
+ {/* 欢迎区域 */} +
+

{config.title}

+

{config.description}

+
+ {fullUser.role} + + 欢迎回来,{fullUser.name || fullUser.email} + +
+
+ + {/* 统计卡片 */} +
+ {config.stats.map((stat, index) => ( + + + + {stat.label} + + + + +
{stat.value}
+
+
+ ))} +
+ + {/* 学生进度条 */} + {fullUser.role === "GUEST" && ( + + + + + 学习进度 + + + 已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "} + 道题目 + + + + +

+ 完成率: {completionRate.toFixed(1)}% +

+
+
+ )} + + {/* 快速操作 */} + + + 快速操作 + 常用功能快速访问 + + +
+ {config.actions.map((action, index) => ( + + + + ))} +
+
+
+ + {/* 最近活动 */} + + + 最近活动 + 查看最新的系统活动 + + +
+ {recentActivity.length > 0 ? ( + recentActivity.map((activity, index) => ( +
+
+ {activity.status === "AC" ? ( + + ) : activity.status ? ( + + ) : ( + + )} +
+
+

{activity.title}

+

+ {activity.description} +

+
+
+ {new Date(activity.time).toLocaleDateString()} +
+
+ )) + ) : ( +

暂无活动

+ )} +
+
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/student/dashboard/page.tsx b/src/app/(protected)/dashboard/student/dashboard/page.tsx new file mode 100644 index 0000000..2db7818 --- /dev/null +++ b/src/app/(protected)/dashboard/student/dashboard/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
加载中...
+
+ ); + } + + if (error) { + return ( +
+
错误: {error}
+
+ ); + } + + if (!data) { + return ( +
+
暂无数据
+
+ ); + } + + const { + completionData, + errorData, + difficultProblems, + pieChartData, + errorPieChartData, + } = data; + const COLORS = ["#4CAF50", "#FFC107"]; + + return ( +
+

学生仪表板

+ +
+ {/* 题目完成比例模块 */} + + + 题目完成比例 + + +
+
+ + 已完成题目:{completionData.completed}/{completionData.total} + + + {completionData.percentage}% + +
+ +
+ + + + {pieChartData.map( + ( + entry: { name: string; value: number }, + index: number + ) => ( + + ) + )} + + + +
+
+
+
+ + {/* 错题比例模块 */} + + + 错题比例 + + +
+
+ + 错题数量:{errorData.wrong}/{errorData.total} + + {errorData.percentage}% +
+ +
+ + + + {errorPieChartData.map( + ( + entry: { name: string; value: number }, + index: number + ) => ( + + ) + )} + + + +
+
+
+
+
+ + {/* 易错题练习模块 */} + + + 易错题练习 + + +
+
+ 易错题数量:{difficultProblems.length} +
+ {difficultProblems.length > 0 ? ( + + + + 题目ID + 题目名称 + 难度 + 错误次数 + + + + {difficultProblems.map( + (problem: { + id: string | number; + title: string; + difficulty: string; + errorCount: number; + }) => ( + + {problem.id} + {problem.title} + {problem.difficulty} + {problem.errorCount} + + ) + )} + +
+ ) : ( +
+ 暂无易错题数据 +
+ )} +
+
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/teacher/dashboard/page.tsx b/src/app/(protected)/dashboard/teacher/dashboard/page.tsx new file mode 100644 index 0000000..407fa2c --- /dev/null +++ b/src/app/(protected)/dashboard/teacher/dashboard/page.tsx @@ -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([]); + const [difficultProblems, setDifficultProblems] = useState< + DifficultProblemData[] + >([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

教师仪表板

+
+
加载中...
+
+
+ ); + } + + if (error) { + return ( +
+

教师仪表板

+
+
错误: {error}
+
+
+ ); + } + + return ( +
+

教师仪表板

+ +
+ {/* 题目完成情况模块 */} + + + 题目完成情况 + 各题目完成及未完成人数图表 + + + {chartData.length === 0 ? ( +
+
暂无数据
+
+ ) : ( + <> + + + + `${value}%`} + /> + + } + /> + + `${value}人`} + /> + + + `${value}人`} + /> + + + + {/* 分页控制 */} + {totalPages > 1 && ( +
+ + + 第 {currentPage} 页,共 {totalPages} 页 + + +
+ )} + + )} +
+ +
+ 完成度趋势 +
+
+ 显示各题目完成情况(已完成/未完成) +
+
+
+ + {/* 学生易错题模块 */} + + + 学生易错题 + 各班级易错题数量及列表 + + +
+
+ 出错率较高题目数量:{difficultProblems.length} +
+ {difficultProblems.length === 0 ? ( +
+
+ 暂无易错题数据 +
+
+ ) : ( + + + + 题目编号 + 题目名称 + 错误次数 + + + + {difficultProblems.map((problem) => ( + + + {problem.problemDisplayId || + problem.id.substring(0, 8)} + + {problem.problemTitle} + {problem.problemCount} + + ))} + +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx b/src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx new file mode 100644 index 0000000..bee5f21 --- /dev/null +++ b/src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
加载中...
; + } + + if (error) { + return
错误: {error}
; + } + + return ( +
+

数据测试页面

+ +
+
+

题目完成数据

+
+            {JSON.stringify(data?.problemData, null, 2)}
+          
+
+ +
+

易错题数据

+
+            {JSON.stringify(data?.difficultProblems, null, 2)}
+          
+
+ +
+

统计信息

+
+            {JSON.stringify(
+              {
+                totalProblems: data?.totalProblems,
+                totalDifficultProblems: data?.totalDifficultProblems,
+              },
+              null,
+              2
+            )}
+          
+
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/usermanagement/actions/problemActions.ts b/src/app/(protected)/dashboard/usermanagement/actions/problemActions.ts new file mode 100644 index 0000000..f86502a --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/actions/problemActions.ts @@ -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 +) { + await prisma.problem.create({ data }); + revalidatePath("/usermanagement/problem"); +} + +export async function deleteProblem(id: string) { + await prisma.problem.delete({ where: { id } }); + revalidatePath("/usermanagement/problem"); +} diff --git a/src/app/(protected)/dashboard/usermanagement/actions/userActions.ts b/src/app/(protected)/dashboard/usermanagement/actions/userActions.ts new file mode 100644 index 0000000..16aa684 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/actions/userActions.ts @@ -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 & { 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> +) { + 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}`); +} diff --git a/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx new file mode 100644 index 0000000..711abf1 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx @@ -0,0 +1,9 @@ +import GenericLayout from "../components/GenericLayout"; + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(protected)/dashboard/usermanagement/admin/page.tsx b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx new file mode 100644 index 0000000..96065e8 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/dashboard/usermanagement/components/GenericLayout.tsx b/src/app/(protected)/dashboard/usermanagement/components/GenericLayout.tsx new file mode 100644 index 0000000..27a6163 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/components/GenericLayout.tsx @@ -0,0 +1,15 @@ +import ProtectedLayout from "./ProtectedLayout"; + +interface GenericLayoutProps { + children: React.ReactNode; + allowedRoles: string[]; +} + +export default function GenericLayout({ + children, + allowedRoles, +}: GenericLayoutProps) { + return ( + {children} + ); +} diff --git a/src/app/(protected)/dashboard/usermanagement/components/ProtectedLayout.tsx b/src/app/(protected)/dashboard/usermanagement/components/ProtectedLayout.tsx new file mode 100644 index 0000000..b3843f1 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/components/ProtectedLayout.tsx @@ -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
{children}
; +} diff --git a/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx new file mode 100644 index 0000000..1c4b421 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx @@ -0,0 +1,13 @@ +import GenericLayout from "../components/GenericLayout"; + +export default function GuestLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/(protected)/dashboard/usermanagement/guest/page.tsx b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx new file mode 100644 index 0000000..3aa6a30 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx new file mode 100644 index 0000000..5bd5c5a --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx @@ -0,0 +1,13 @@ +import GenericLayout from "../components/GenericLayout"; + +export default function ProblemLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/(protected)/dashboard/usermanagement/problem/page.tsx b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx new file mode 100644 index 0000000..d61ac67 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx new file mode 100644 index 0000000..53d57cb --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx @@ -0,0 +1,9 @@ +import GenericLayout from "../components/GenericLayout"; + +export default function TeacherLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx new file mode 100644 index 0000000..26393b9 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx @@ -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 ; +} diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/layout.tsx index 8a017f9..078b0c1 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/layout.tsx @@ -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 {children}; + return ( + + {children} + + ); }; export default Layout; diff --git a/src/components/UncompletedProject/sharedialog.tsx b/src/components/UncompletedProject/sharedialog.tsx new file mode 100644 index 0000000..c146ca5 --- /dev/null +++ b/src/components/UncompletedProject/sharedialog.tsx @@ -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 ( + + + Share link + + Anyone who has this link will be able to view this. + + +
+
+ + +
+
+ + + + + +
+ ); +} diff --git a/src/components/UncompletedProject/wrongbook-dialog.tsx b/src/components/UncompletedProject/wrongbook-dialog.tsx new file mode 100644 index 0000000..da6b281 --- /dev/null +++ b/src/components/UncompletedProject/wrongbook-dialog.tsx @@ -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(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 ( + + + {children ? ( + children + ) : ( + + )} + + + + 全部错题集 + +
+
+ + + + + + + + + + {problems.map((item) => ( + + + + + + ))} + +
操作 + 题目名称 + 状态
+ + + + {item.name} + + + {(() => { + if (item.status === "AC") { + return ( + + + {item.status} + + ); + } else if (item.status === "WA") { + return ( + + + {item.status} + + ); + } else if ( + ["RE", "CE", "MLE", "TLE"].includes(item.status) + ) { + return ( + + + {item.status} + + ); + } else { + return ( + + + {item.status} + + ); + } + })()} +
+
+
+
+
+ ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx deleted file mode 100644 index 526e620..0000000 --- a/src/components/app-sidebar.tsx +++ /dev/null @@ -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 { - user: NavUserProps["user"]; -} - -export function AppSidebar({ - user, - ...props -}: AppSidebarProps) { - return ( - - - - - - - - - - - - - - ); -} diff --git a/src/components/dashboard-button.tsx b/src/components/dashboard-button.tsx new file mode 100644 index 0000000..93b5bc8 --- /dev/null +++ b/src/components/dashboard-button.tsx @@ -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 ( + router.push("/dashboard")}> + + Dashboard + + ); +}; diff --git a/src/components/dynamic-breadcrumb.tsx b/src/components/dynamic-breadcrumb.tsx new file mode 100644 index 0000000..4368a2f --- /dev/null +++ b/src/components/dynamic-breadcrumb.tsx @@ -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 = { + 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 ( + + + {breadcrumbs.map((item, index) => ( +
+ + {item.href ? ( + {item.label} + ) : ( + {item.label} + )} + + {index < breadcrumbs.length - 1 && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 7706335..8b2d99c 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -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 ( Platform - {items.map((item) => - !item.items ? ( - + {items.map((item) => ( + + - {item.icon && } + {item.title} + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ) : null} - ) : ( - - - - - {item.icon && } - {item.title} - - - - - - {item.items.map((subItem) => ( - - - - {subItem.title} - - - - ))} - - - - - ) - )} + + ))} ); diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index f50b20d..3803013 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -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 ( - - Projects - - {projects.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - View Project - - - - Share Project - - - - - Delete Project - - - + <> + + 待完成项目 + + {projects.slice(0, 1).map((item) => ( + + + + + + + {item.name} + + {(() => { + if (item.status === "AC") { + return ( + + + {item.status} + + ); + } else if (item.status === "WA") { + return ( + + + {item.status} + + ); + } else if ( + ["RE", "CE", "MLE", "TLE"].includes(item.status) + ) { + return ( + + + {item.status} + + ); + } else { + return ( + + + {item.status} + + ); + } + })()} + + + + + + + + More + + + + { + e.stopPropagation(); + if (item.url) { + router.push(item.url); + } else { + router.push(`/problems/${item.id}`); + } + }} + > + + 查看 + + { + e.stopPropagation(); + setShareLink( + `${window.location.origin}/problems/${item.id}` + ); + setShareOpen(true); + }} + > + + 复制链接 + + + + + ))} + + + + + 更多 + + - ))} - - - - More - - - - - ) + + + + + + + ); } diff --git a/src/components/nav-secondary.tsx b/src/components/nav-secondary.tsx new file mode 100644 index 0000000..1d9a13c --- /dev/null +++ b/src/components/nav-secondary.tsx @@ -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) { + return ( + + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + ); +} diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 66190a5..ad9e4aa 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -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 ( @@ -78,28 +87,17 @@ export function NavUser({ - - - Upgrade to Pro - - - - - + Account - - - Billing - - - - Notifications + router.push("/sign-in")}> + + Switch User - + Log out diff --git a/src/components/sidebar/admin-sidebar.tsx b/src/components/sidebar/admin-sidebar.tsx new file mode 100644 index 0000000..f6c739c --- /dev/null +++ b/src/components/sidebar/admin-sidebar.tsx @@ -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) { + const userInfo = { + name: user.name ?? "管理员", + email: user.email ?? "", + avatar: user.image ?? "/avatars/default.jpg", + }; + + return ( + + + + + + +
+ +
+
+ Admin + 管理后台 +
+
+
+
+
+
+ + + + + + + +
+ ); +} diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx new file mode 100644 index 0000000..b14026f --- /dev/null +++ b/src/components/sidebar/app-sidebar.tsx @@ -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 ( + + + + + + +
+ +
+
+ Judge4c + Programming Learning +
+
+
+
+
+
+ + + + + + + + +
+ ); +} diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx new file mode 100644 index 0000000..04508bc --- /dev/null +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -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) { + const userInfo = { + name: user.name ?? "", + email: user.email ?? "", + avatar: user.image ?? "/avatars/teacher.jpg", + }; + + return ( + + + + + + +
+ +
+
+ Judge4c 教师端 + Teaching Platform +
+
+
+
+
+
+ + + + + + + +
+ ); +} diff --git a/src/components/user-avatar.tsx b/src/components/user-avatar.tsx index e6c97dd..2cada3a 100644 --- a/src/components/user-avatar.tsx +++ b/src/components/user-avatar.tsx @@ -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 () => { + diff --git a/src/features/dashboard/layouts/protected-layout.tsx b/src/features/dashboard/layouts/protected-layout.tsx new file mode 100644 index 0000000..949f0db --- /dev/null +++ b/src/features/dashboard/layouts/protected-layout.tsx @@ -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}; +}; diff --git a/src/features/user-management/components/generic-page.tsx b/src/features/user-management/components/generic-page.tsx new file mode 100644 index 0000000..090c63e --- /dev/null +++ b/src/features/user-management/components/generic-page.tsx @@ -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 ; + } else { + const role = userType.toUpperCase() as Role; + const data: User[] = await prisma.user.findMany({ where: { role } }); + return ; + } +} diff --git a/src/features/user-management/components/user-table.tsx b/src/features/user-management/components/user-table.tsx new file mode 100644 index 0000000..8fb8cba --- /dev/null +++ b/src/features/user-management/components/user-table.tsx @@ -0,0 +1,1134 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + PlusIcon, + PencilIcon, + TrashIcon, + ListFilter, +} from "lucide-react"; +import { z } from "zod"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import * as React from "react"; +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { toast } from "sonner"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useForm } from "react-hook-form"; +import { Tabs } from "@/components/ui/tabs"; +import { useRouter } from "next/navigation"; +import { useState, useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Difficulty, Role } from "@/generated/client"; +import type { User, Problem } from "@/generated/client"; +import { + createUser, + updateUser, + deleteUser, +} from "@/app/(protected)/dashboard/usermanagement/actions/userActions"; +import { + createProblem, + deleteProblem, +} from "@/app/(protected)/dashboard/usermanagement/actions/problemActions"; + +export interface UserConfig { + userType: string; + title: string; + apiPath: string; + columns: Array<{ + key: string; + label: string; + sortable?: boolean; + searchable?: boolean; + placeholder?: string; + }>; + formFields: Array<{ + key: string; + label: string; + type: string; + placeholder?: string; + required?: boolean; + options?: Array<{ value: string; label: string }>; + }>; + actions: { + add: { label: string; icon: string }; + edit: { label: string; icon: string }; + delete: { label: string; icon: string }; + batchDelete: { label: string; icon: string }; + }; + pagination: { + pageSizes: number[]; + defaultPageSize: number; + }; +} + +type UserTableProps = + | { config: UserConfig; data: User[] } + | { config: UserConfig; data: Problem[] }; + +type UserForm = { + id?: string; + name: string; + email: string; + password: string; + createdAt: string; + role: Role; + image: string | null; + emailVerified: Date | null; +}; + +// 新增用户表单类型 +type AddUserForm = Omit; + +const addUserSchema = z.object({ + name: z.string(), + email: z.string().email(), + password: z.string().min(1, "密码不能为空").min(8, "密码长度至少8位"), + createdAt: z.string(), + image: z.string().nullable(), + emailVerified: z.date().nullable(), + role: z.nativeEnum(Role), +}); + +const editUserSchema = z.object({ + id: z.string().default(""), + name: z.string(), + email: z.string().email(), + password: z.string(), + createdAt: z.string(), + image: z.string().nullable(), + emailVerified: z.date().nullable(), + role: z.nativeEnum(Role), +}); + +// 题目表单 schema 兼容 null/undefined +const addProblemSchema = z.object({ + displayId: z.number().optional().default(0), + difficulty: z.nativeEnum(Difficulty).default(Difficulty.EASY), +}); + +export function UserTable(props: UserTableProps) { + const isProblem = props.config.userType === "problem"; + const router = useRouter(); + const problemData = isProblem ? (props.data as Problem[]) : undefined; + + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleteBatch, setDeleteBatch] = useState(false); + const [rowSelection, setRowSelection] = useState({}); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnFilters, setColumnFilters] = useState([]); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: props.config.pagination.defaultPageSize, + }); + const [pageInput, setPageInput] = useState(pagination.pageIndex + 1); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [pendingDeleteItem, setPendingDeleteItem] = useState< + User | Problem | null + >(null); + useEffect(() => { + setPageInput(pagination.pageIndex + 1); + }, [pagination.pageIndex]); + + // 表格列 + const tableColumns = React.useMemo[]>(() => { + const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + + table.toggleAllPageRowsSelected(!!value) + } + aria-label="选择所有" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="选择行" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ]; + props.config.columns.forEach((col) => { + const column: ColumnDef = { + accessorKey: col.key, + header: col.label, + cell: ({ row }) => { + // 类型安全分流 + if (col.key === "displayId" && isProblem) { + return (row.original as Problem).displayId; + } + if (col.key === "createdAt" || col.key === "updatedAt") { + const value = row.getValue(col.key); + if (value instanceof Date) { + return value.toLocaleString(); + } + if (typeof value === "string" && !isNaN(Date.parse(value))) { + return new Date(value).toLocaleString(); + } + } + return row.getValue(col.key); + }, + enableSorting: col.sortable !== false, + filterFn: col.searchable + ? (row, columnId, value) => { + const searchValue = String(value).toLowerCase(); + const cellValue = String(row.getValue(columnId)).toLowerCase(); + return cellValue.includes(searchValue); + } + : undefined, + }; + columns.push(column); + }); + columns.push({ + id: "actions", + header: () =>
操作
, + cell: ({ row }) => { + const item = row.original; + return ( +
+ + +
+ ); + }, + }); + return columns; + }, [props.config, router, isProblem]); + + const table = useReactTable({ + data: props.data, + columns: tableColumns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + pagination, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }); + + // 添加用户对话框组件(仅用户) + function AddUserDialogUser({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) { + const [isLoading, setIsLoading] = useState(false); + const form = useForm({ + resolver: zodResolver(addUserSchema), + defaultValues: { + name: "", + email: "", + password: "", + createdAt: "", + image: null, + emailVerified: null, + role: Role.GUEST, + }, + }); + React.useEffect(() => { + if (open) { + form.reset({ + name: "", + email: "", + password: "", + createdAt: "", + image: null, + emailVerified: null, + role: Role.GUEST, + }); + } + }, [open, form]); + async function onSubmit(data: AddUserForm) { + try { + setIsLoading(true); + + // 验证必填字段 + if (!data.password || data.password.trim() === "") { + toast.error("密码不能为空", { duration: 1500 }); + return; + } + + const submitData = { + ...data, + image: data.image ?? null, + emailVerified: data.emailVerified ?? null, + role: data.role ?? Role.GUEST, + }; + if (!submitData.name) submitData.name = ""; + if (!submitData.createdAt) + submitData.createdAt = new Date().toISOString(); + else + submitData.createdAt = new Date(submitData.createdAt).toISOString(); + if (props.config.userType === "admin") + await createUser("admin", submitData); + else if (props.config.userType === "teacher") + await createUser("teacher", submitData); + else if (props.config.userType === "guest") + await createUser("guest", submitData); + onOpenChange(false); + toast.success("添加成功", { duration: 1500 }); + router.refresh(); + } catch (error) { + console.error("添加失败:", error); + toast.error("添加失败", { duration: 1500 }); + } finally { + setIsLoading(false); + } + } + return ( + + + + {props.config.actions.add.label} + 请填写信息,ID自动生成。 + +
+
+ {props.config.formFields + .filter((field) => field.key !== "id") + .map((field) => ( +
+ + {field.type === "select" && field.options ? ( + + ) : ( + + )} + {form.formState.errors[ + field.key as keyof typeof form.formState.errors + ]?.message && ( +

+ { + form.formState.errors[ + field.key as keyof typeof form.formState.errors + ]?.message as string + } +

+ )} +
+ ))} +
+ + + +
+
+
+ ); + } + + // 添加题目对话框组件(仅题目) + function AddUserDialogProblem({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) { + const [isLoading, setIsLoading] = useState(false); + const form = useForm>({ + resolver: zodResolver(addProblemSchema), + defaultValues: { displayId: 0, difficulty: Difficulty.EASY }, + }); + React.useEffect(() => { + if (open) { + form.reset({ displayId: 0, difficulty: Difficulty.EASY }); + } + }, [open, form]); + async function onSubmit(formData: Partial) { + try { + setIsLoading(true); + const submitData: Partial = { + ...formData, + displayId: Number(formData.displayId), + }; + await createProblem({ + displayId: Number(submitData.displayId), + difficulty: submitData.difficulty ?? Difficulty.EASY, + isPublished: false, + isTrim: false, + timeLimit: 1000, + memoryLimit: 134217728, + userId: null, + }); + onOpenChange(false); + toast.success("添加成功", { duration: 1500 }); + router.refresh(); + } catch (error) { + console.error("添加失败:", error); + toast.error("添加失败", { duration: 1500 }); + } finally { + setIsLoading(false); + } + } + return ( + + + + {props.config.actions.add.label} + 请填写信息,ID自动生成。 + +
+
+ {props.config.formFields.map((field) => ( +
+ + {field.key === "difficulty" ? ( + + ) : ( + + )} + {form.formState.errors[ + field.key as keyof typeof form.formState.errors + ]?.message && ( +

+ { + form.formState.errors[ + field.key as keyof typeof form.formState.errors + ]?.message as string + } +

+ )} +
+ ))} +
+ + + +
+
+
+ ); + } + + // 编辑用户对话框组件(仅用户) + function EditUserDialogUser({ + open, + onOpenChange, + user, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + user: User; + }) { + const [isLoading, setIsLoading] = useState(false); + const editForm = useForm({ + resolver: zodResolver(editUserSchema), + defaultValues: { + id: typeof user.id === "string" ? user.id : "", + name: user.name ?? "", + email: user.email ?? "", + password: "", + role: user.role ?? Role.GUEST, + createdAt: user.createdAt + ? new Date(user.createdAt).toISOString().slice(0, 16) + : "", + image: user.image ?? null, + emailVerified: user.emailVerified ?? null, + }, + }); + React.useEffect(() => { + if (open) { + editForm.reset({ + id: typeof user.id === "string" ? user.id : "", + name: user.name ?? "", + email: user.email ?? "", + password: "", + role: user.role ?? Role.GUEST, + createdAt: user.createdAt + ? new Date(user.createdAt).toISOString().slice(0, 16) + : "", + image: user.image ?? null, + emailVerified: user.emailVerified ?? null, + }); + } + }, [open, user, editForm]); + async function onSubmit(data: UserForm) { + try { + setIsLoading(true); + const submitData = { + ...data, + createdAt: data.createdAt + ? new Date(data.createdAt).toISOString() + : new Date().toISOString(), + image: data.image ?? null, + emailVerified: data.emailVerified ?? null, + role: data.role ?? Role.GUEST, + }; + const id = typeof submitData.id === "string" ? submitData.id : ""; + if (props.config.userType === "admin") + await updateUser("admin", id, submitData); + else if (props.config.userType === "teacher") + await updateUser("teacher", id, submitData); + else if (props.config.userType === "guest") + await updateUser("guest", id, submitData); + onOpenChange(false); + toast.success("修改成功", { duration: 1500 }); + } catch { + toast.error("修改失败", { duration: 1500 }); + } finally { + setIsLoading(false); + } + } + return ( + + + + {props.config.actions.edit.label} + 修改信息 + +
+
+ {props.config.formFields.map((field) => ( +
+ + + {editForm.formState.errors[ + field.key as keyof typeof editForm.formState.errors + ]?.message && ( +

+ { + editForm.formState.errors[ + field.key as keyof typeof editForm.formState.errors + ]?.message as string + } +

+ )} +
+ ))} + {/* 编辑时显示角色选择 */} + {props.config.userType !== "problem" && ( +
+ + + {editForm.formState.errors.role?.message && ( +

+ {editForm.formState.errors.role?.message as string} +

+ )} +
+ )} +
+ + + +
+
+
+ ); + } + + // 用ref保证获取最新data + const dataRef = React.useRef(props.data); + React.useEffect(() => { + dataRef.current = props.data; + }, [props.data]); + + return ( + +
+
+ {props.config.title} +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnNameMap: Record = { + select: "选择", + id: "ID", + name: "姓名", + email: "邮箱", + password: "密码", + createdAt: "创建时间", + actions: "操作", + displayId: "题目编号", + difficulty: "难度", + }; + return ( + + column.toggleVisibility(!!value) + } + > + {columnNameMap[column.id] || column.id} + + ); + })} + + + {isProblem && props.config.actions.add && ( + + )} + {!isProblem && props.config.actions.add && ( + + )} + +
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + 暂无数据 + + + )} + +
+
+
+
+
+ 共 {table.getFilteredRowModel().rows.length} 条记录 +
+
+
+

每页显示

+ +
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共{" "} + {table.getPageCount()} 页 +
+
+ + +
+ 跳转到 + setPageInput(Number(e.target.value))} + onKeyDown={(e) => { + if (e.key === "Enter") { + const page = pageInput - 1; + if (page >= 0 && page < table.getPageCount()) { + table.setPageIndex(page); + } + } + }} + className="w-16 h-8 text-sm" + /> + +
+ + +
+
+
+ {/* 添加用户对话框 */} + {isProblem && props.config.actions.add ? ( + + ) : !isProblem && props.config.actions.add ? ( + + ) : null} + {/* 编辑用户对话框 */} + {!isProblem && editingUser ? ( + + ) : null} + {/* 删除确认对话框 */} + + + + 确认删除 + + {deleteBatch + ? `确定要删除选中的 ${ + table.getFilteredSelectedRowModel().rows.length + } 条记录吗?此操作不可撤销。` + : "确定要删除这条记录吗?此操作不可撤销。"} + + + + + + + + + + + + 确认删除 + +
确定要删除该条数据吗?此操作不可撤销。
+ + + + +
+
+
+ ); +} diff --git a/src/features/user-management/config/admin.ts b/src/features/user-management/config/admin.ts new file mode 100644 index 0000000..6d07679 --- /dev/null +++ b/src/features/user-management/config/admin.ts @@ -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; + +// 添加管理员表单校验 schema +export const addAdminSchema = baseAddUserSchema; +export type AddAdminFormData = z.infer; + +// 编辑管理员表单校验 schema +export const editAdminSchema = baseEditUserSchema; +export type EditAdminFormData = z.infer; + +// 管理员配置 +export const adminConfig = createUserConfig( + "admin", + "管理员列表", + "添加管理员", + "请输入管理员姓名", + "请输入管理员邮箱" +); diff --git a/src/features/user-management/config/base-config.ts b/src/features/user-management/config/base-config.ts new file mode 100644 index 0000000..e3b85cd --- /dev/null +++ b/src/features/user-management/config/base-config.ts @@ -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, + }; +} diff --git a/src/features/user-management/config/guest.ts b/src/features/user-management/config/guest.ts new file mode 100644 index 0000000..10574fa --- /dev/null +++ b/src/features/user-management/config/guest.ts @@ -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; + +export const addGuestSchema = baseAddUserSchema; +export type AddGuestFormData = z.infer; + +export const editGuestSchema = baseEditUserSchema; +export type EditGuestFormData = z.infer; + +export const guestConfig = createUserConfig( + "guest", + "客户列表", + "添加客户", + "请输入客户姓名", + "请输入客户邮箱" +); diff --git a/src/features/user-management/config/problem.ts b/src/features/user-management/config/problem.ts new file mode 100644 index 0000000..43df2fe --- /dev/null +++ b/src/features/user-management/config/problem.ts @@ -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 }, +}; diff --git a/src/features/user-management/config/teacher.ts b/src/features/user-management/config/teacher.ts new file mode 100644 index 0000000..3da731a --- /dev/null +++ b/src/features/user-management/config/teacher.ts @@ -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; + +export const addTeacherSchema = baseAddUserSchema; +export type AddTeacherFormData = z.infer; + +export const editTeacherSchema = baseEditUserSchema; +export type EditTeacherFormData = z.infer; + +export const teacherConfig = createUserConfig( + "teacher", + "教师列表", + "添加教师", + "请输入教师姓名", + "请输入教师邮箱" +);