From 69dfadd81a77512a6d18d345654bb6b8d60deaf3 Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Tue, 17 Jun 2025 17:05:15 +0800 Subject: [PATCH 1/9] =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/.gitignore | 8 + .idea/inspectionProfiles/Project_Default.xml | 6 + .idea/judge4c.iml | 9 ++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + bun.lock | 66 ++++++++ src/app/(app)/problemset/layout.tsx | 2 +- src/app/dashboard/page.tsx | 52 ++++++ src/app/globals.css | 41 +++-- src/components/admin/sidebar.tsx | 122 ++++++++++++++ src/components/app-sidebar.tsx | 139 +++++++++------- src/components/nav-main.tsx | 109 ++++++------- src/components/nav-projects.tsx | 12 +- src/components/nav-secondary.tsx | 40 +++++ src/components/nav-user.tsx | 61 +++---- src/components/ui/donut-chart.tsx | 58 +++++++ tailwind.config.ts | 162 +++++++++---------- 18 files changed, 663 insertions(+), 244 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/judge4c.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/components/admin/sidebar.tsx create mode 100644 src/components/nav-secondary.tsx create mode 100644 src/components/ui/donut-chart.tsx diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/judge4c.iml b/.idea/judge4c.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/judge4c.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6f29fee --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ce31b97 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file 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/src/app/(app)/problemset/layout.tsx b/src/app/(app)/problemset/layout.tsx index 0a1981f..6866a54 100644 --- a/src/app/(app)/problemset/layout.tsx +++ b/src/app/(app)/problemset/layout.tsx @@ -11,4 +11,4 @@ export default function ProblemsetLayout({ children }: ProblemsetLayoutProps) { {children} ); -} +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..caa8889 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,52 @@ +import { AppSidebar } from "@/components/app-sidebar" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import { Separator } from "@/components/ui/separator" +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" + +export default function Page() { + return ( + + + +
+
+ + + + + + + Building Your Application + + + + + Data Fetching + + + +
+
+
+
+
+
+
+
+
+
+ + + ) +} diff --git a/src/app/globals.css b/src/app/globals.css index 26923bb..a257927 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -33,13 +33,13 @@ --chart-5: 213 16% 16%; --radius: 0.5rem; --sidebar-background: 0 0% 98%; - --sidebar-foreground: 213 13% 6%; - --sidebar-primary: 213 13% 16%; - --sidebar-primary-foreground: 213 13% 76%; - --sidebar-accent: 0 0% 85%; - --sidebar-accent-foreground: 0 0% 25%; - --sidebar-border: 0 0% 95%; - --sidebar-ring: 213 13% 16%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { @@ -67,14 +67,14 @@ --chart-3: 216 28% 22%; --chart-4: 210 7% 28%; --chart-5: 210 20% 82%; - --sidebar-background: 216 28% 5%; - --sidebar-foreground: 210 17% 92%; - --sidebar-primary: 210 17% 82%; - --sidebar-primary-foreground: 210 17% 22%; - --sidebar-accent: 216 28% 22%; - --sidebar-accent-foreground: 216 28% 82%; - --sidebar-border: 216 18% 12%; - --sidebar-ring: 210 17% 82%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } } @@ -119,3 +119,14 @@ code[data-theme*=" "] span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); } + + + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/components/admin/sidebar.tsx b/src/components/admin/sidebar.tsx new file mode 100644 index 0000000..7b62f44 --- /dev/null +++ b/src/components/admin/sidebar.tsx @@ -0,0 +1,122 @@ +"use client" +import { useSession } from "next-auth/react"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarRail, +} from "@/components/ui/sidebar"; +import { NavMain } from "@/components/nav-main"; +import { NavProjects } from "@/components/nav-projects"; +import { NavUser } from "@/components/nav-user"; +import { + Command, + House, + PieChart, + Settings2, +} from "lucide-react"; + +import { useEffect, useState } from "react"; +import { PrismaClient } from "@prisma/client"; + +// 如果 adminData.teams 没有在别处定义,请取消注释下面的代码并提供实际值 +/* +const teams = [ + // 在这里放置你的团队数据 +]; +*/ + + +const adminData = { + // teams: [ + // { + // name: "Admin Team", + // logo: GalleryVerticalEnd, + // plan: "Enterprise", + // }, + // ], + navMain: [ + { + title: "OverView", + url: "/", + icon: House, + }, + { + title: "Dashboard", + url: "/admin", + icon: Settings2, + items: [ + { + title: "User", + url: "/admin/users", + }, + { + title: "Teacher", + url: "/admin/problems", + }, + ], + }, + ], + projects: [ + { + name: "System Monitoring", + url: "/admin/monitoring", + icon: PieChart, + }, + { + name: "Admin Tools", + url: "/admin/tools", + icon: Command, + }, + ] +}; + +export const AdminSidebar = ({ ...props }: React.ComponentProps) => { + const { data: session } = useSession(); + const [userAvatar, setUserAvatar] = useState(""); + + useEffect(() => { + const fetchUserAvatar = async () => { + if (session?.user?.email) { + const prisma = new PrismaClient(); + try { + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { image: true } + }); + setUserAvatar(user?.image || ""); + } catch (error) { + console.error("Failed to fetch user avatar:", error); + } finally { + await prisma.$disconnect(); + } + } + }; + + fetchUserAvatar(); + }, [session?.user?.email]); + + + + const user = { + name: session?.user?.name || "Admin", + email: session?.user?.email || "admin@example.com", + avatar: userAvatar + }; + + return ( + + {/**/} + {/* */} + {/**/} + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 526e620..c32996a 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -1,89 +1,107 @@ -"use client"; - +"use client" +import { siteConfig } from "@/config/site" +import * as React from "react" import { - AudioWaveform, - Bot, + BookOpen, Command, Frame, - GalleryVerticalEnd, + LifeBuoy, Map, PieChart, + Send, Settings2, SquareTerminal, -} from "lucide-react"; -import * as React from "react"; +} from "lucide-react" + +import { NavMain } from "@/components/nav-main" +import { NavProjects } from "@/components/nav-projects" +import { NavSecondary } from "@/components/nav-secondary" +import { NavUser } from "@/components/nav-user" 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"; + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" const data = { - teams: [ - { - name: "Acme Inc", - logo: GalleryVerticalEnd, - plan: "Enterprise", - }, - { - name: "Acme Corp.", - logo: AudioWaveform, - plan: "Startup", - }, - { - name: "Evil Corp.", - logo: Command, - plan: "Free", - }, - ], + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, navMain: [ { - title: "Problemset", - url: "/dashboard/problemset", + title: "Dashboard", + url: "#", icon: SquareTerminal, isActive: true, - }, - { - title: "Models", - url: "#", - icon: Bot, items: [ { - title: "Genesis", + title: "Home", + url: "/", + }, + { + title: "Personal interface", url: "#", }, { - title: "Explorer", + title: "Problems", + url: "/problemset", + }, + ], + }, + + { + title: "Done Topics", + url: "#", + icon: BookOpen, + items: [ + { + title: "All Coding", url: "#", }, { - title: "Quantum", + title: "Correct Codingset", + url: "#", + }, + { + title: "Wrong Codingset", url: "#", }, ], }, { title: "Settings", - url: "/dashboard/settings", + url: "#", icon: Settings2, items: [ { title: "General", - url: "/general", + url: "#", }, { - title: "Language Server", - url: "/language-server", + title: "Language", + url: "#", }, ], }, ], + navSecondary: [ + { + title: "Support", + url: "/", + icon: LifeBuoy, + }, + { + title: "Feedback", + url: siteConfig.url.repo.github, + icon: Send, + }, + ], projects: [ { name: "Design Engineering", @@ -101,29 +119,36 @@ const data = { icon: Map, }, ], -}; - -interface AppSidebarProps extends React.ComponentProps { - user: NavUserProps["user"]; } -export function AppSidebar({ - user, - ...props -}: AppSidebarProps) { +export function AppSidebar({ ...props }: React.ComponentProps) { return ( - + - + + + + +
+ +
+
+ Judge4c + Programming Learning +
+
+
+
+
+ - + -
- ); + ) } diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 7706335..3481f96 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -1,83 +1,78 @@ -"use client"; +"use client" +import { ChevronRight, type LucideIcon } from "lucide-react" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" import { SidebarGroup, SidebarGroupLabel, SidebarMenu, + SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, -} from "@/components/ui/sidebar"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { ChevronRight, type LucideIcon } from "lucide-react"; +} from "@/components/ui/sidebar" -export interface NavMainProps { +export function NavMain({ + items, +}: { items: { - title: string; - url: string; - icon?: LucideIcon; - isActive?: boolean; + title: string + url: string + icon: LucideIcon + isActive?: boolean items?: { - title: string; - url: string; - }[]; - }[]; -} - -export function NavMain({ items }: NavMainProps) { + title: string + url: string + }[] + }[] +}) { 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..ae5867b 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -2,8 +2,8 @@ import { Folder, - Forward, MoreHorizontal, + Share, Trash2, type LucideIcon, } from "lucide-react" @@ -38,7 +38,7 @@ export function NavProjects({ return ( - Projects + Recent programming topics {projects.map((item) => ( @@ -56,7 +56,7 @@ export function NavProjects({ @@ -65,7 +65,7 @@ export function NavProjects({ View Project - + Share Project @@ -78,8 +78,8 @@ export function NavProjects({ ))} - - + + More diff --git a/src/components/nav-secondary.tsx b/src/components/nav-secondary.tsx new file mode 100644 index 0000000..a931a7e --- /dev/null +++ b/src/components/nav-secondary.tsx @@ -0,0 +1,40 @@ +import * as React from "react" +import { type LucideIcon } from "lucide-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +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..ec6f1f6 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,19 +1,20 @@ -"use client"; +"use client" import { BadgeCheck, Bell, ChevronsUpDown, - CreditCard, + UserPen, LogOut, Sparkles, -} from "lucide-react"; +} from "lucide-react" +import { useRouter } from "next/navigation" + import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar" import { DropdownMenu, DropdownMenuContent, @@ -22,21 +23,25 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; - -export interface NavUserProps { - user: { - name: string; - email: string; - avatar: string; - }; -} +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" export function NavUser({ user, -}: NavUserProps) { - const { isMobile } = useSidebar(); +}: { + user: { + name: string + email: string + avatar: string + } +}) { + const { isMobile } = useSidebar() + const router = useRouter() return ( @@ -80,7 +85,7 @@ export function NavUser({ - Upgrade to Pro + Update @@ -89,17 +94,19 @@ export function NavUser({ Account - - - Billing + router.push("/sign-in")}> + + Switch User - + Notifications - + { + router.replace("/"); + }}> Log out @@ -107,5 +114,5 @@ export function NavUser({ - ); + ) } diff --git a/src/components/ui/donut-chart.tsx b/src/components/ui/donut-chart.tsx new file mode 100644 index 0000000..c6f3296 --- /dev/null +++ b/src/components/ui/donut-chart.tsx @@ -0,0 +1,58 @@ +// 简单的环形图组件,使用 SVG 实现 +import React from "react"; + +interface DonutChartProps { + percent: number; // 完成比例 0-100 + size?: number; // 图表直径 + strokeWidth?: number; // 圆环宽度 + color?: string; // 完成部分颜色 + bgColor?: string; // 未完成部分颜色 +} + +export function DonutChart({ + percent, + size = 120, + strokeWidth = 16, + color = "#3b82f6", + bgColor = "#e5e7eb", +}: DonutChartProps) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - percent / 100); + + return ( + + + + + {percent}% + + + ); +} diff --git a/tailwind.config.ts b/tailwind.config.ts index 6bb95d9..23aa253 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -12,87 +12,87 @@ export default { "./src/lib/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { - extend: { - colors: { - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - chart: { - "1": "hsl(var(--chart-1))", - "2": "hsl(var(--chart-2))", - "3": "hsl(var(--chart-3))", - "4": "hsl(var(--chart-4))", - "5": "hsl(var(--chart-5))", - }, - sidebar: { - DEFAULT: "hsl(var(--sidebar-background))", - foreground: "hsl(var(--sidebar-foreground))", - primary: "hsl(var(--sidebar-primary))", - "primary-foreground": "hsl(var(--sidebar-primary-foreground))", - accent: "hsl(var(--sidebar-accent))", - "accent-foreground": "hsl(var(--sidebar-accent-foreground))", - border: "hsl(var(--sidebar-border))", - ring: "hsl(var(--sidebar-ring))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { - height: "0", - }, - to: { - height: "var(--radix-accordion-content-height)", - }, - }, - "accordion-up": { - from: { - height: "var(--radix-accordion-content-height)", - }, - to: { - height: "0", - }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + }, + sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: '0' + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: '0' + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } }, plugins: [animate], } satisfies Config; From 24c58b83297b44bf3d0c4195ec7451d53e89ae5d Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Wed, 18 Jun 2025 17:49:07 +0800 Subject: [PATCH 2/9] 6.18 --- src/app/dashboard/page.tsx | 2 +- .../UncompletedProject/sharedialog.tsx | 39 ++++ .../UncompletedProject/wrongbook-dialog.tsx | 84 +++++++++ src/components/admin/sidebar.tsx | 122 ------------- src/components/nav-projects.tsx | 167 +++++++++++++----- src/components/{ => sidebar}/app-sidebar.tsx | 48 ++--- 6 files changed, 266 insertions(+), 196 deletions(-) create mode 100644 src/components/UncompletedProject/sharedialog.tsx create mode 100644 src/components/UncompletedProject/wrongbook-dialog.tsx delete mode 100644 src/components/admin/sidebar.tsx rename src/components/{ => sidebar}/app-sidebar.tsx (80%) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index caa8889..a6876ff 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import { AppSidebar } from "@/components/app-sidebar" +import { AppSidebar } from "@/components/sidebar/app-sidebar" import { Breadcrumb, BreadcrumbItem, diff --git a/src/components/UncompletedProject/sharedialog.tsx b/src/components/UncompletedProject/sharedialog.tsx new file mode 100644 index 0000000..f3fc891 --- /dev/null +++ b/src/components/UncompletedProject/sharedialog.tsx @@ -0,0 +1,39 @@ +import { Button } from "@/components/ui/button" +import { + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogClose, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +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..c7edcc7 --- /dev/null +++ b/src/components/UncompletedProject/wrongbook-dialog.tsx @@ -0,0 +1,84 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Check, X, Info, AlertTriangle } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import Link from "next/link" + +export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string }[]; children?: React.ReactNode }) { + return ( + + + {children ? children : ( + + )} + + + + 全部错题集 + +
+
+ + + + + + + + + + {problems.map((item) => ( + + + + + + ))} + +
ID题目名称状态
{item.id} + + {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/admin/sidebar.tsx b/src/components/admin/sidebar.tsx deleted file mode 100644 index 7b62f44..0000000 --- a/src/components/admin/sidebar.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client" -import { useSession } from "next-auth/react"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarRail, -} from "@/components/ui/sidebar"; -import { NavMain } from "@/components/nav-main"; -import { NavProjects } from "@/components/nav-projects"; -import { NavUser } from "@/components/nav-user"; -import { - Command, - House, - PieChart, - Settings2, -} from "lucide-react"; - -import { useEffect, useState } from "react"; -import { PrismaClient } from "@prisma/client"; - -// 如果 adminData.teams 没有在别处定义,请取消注释下面的代码并提供实际值 -/* -const teams = [ - // 在这里放置你的团队数据 -]; -*/ - - -const adminData = { - // teams: [ - // { - // name: "Admin Team", - // logo: GalleryVerticalEnd, - // plan: "Enterprise", - // }, - // ], - navMain: [ - { - title: "OverView", - url: "/", - icon: House, - }, - { - title: "Dashboard", - url: "/admin", - icon: Settings2, - items: [ - { - title: "User", - url: "/admin/users", - }, - { - title: "Teacher", - url: "/admin/problems", - }, - ], - }, - ], - projects: [ - { - name: "System Monitoring", - url: "/admin/monitoring", - icon: PieChart, - }, - { - name: "Admin Tools", - url: "/admin/tools", - icon: Command, - }, - ] -}; - -export const AdminSidebar = ({ ...props }: React.ComponentProps) => { - const { data: session } = useSession(); - const [userAvatar, setUserAvatar] = useState(""); - - useEffect(() => { - const fetchUserAvatar = async () => { - if (session?.user?.email) { - const prisma = new PrismaClient(); - try { - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { image: true } - }); - setUserAvatar(user?.image || ""); - } catch (error) { - console.error("Failed to fetch user avatar:", error); - } finally { - await prisma.$disconnect(); - } - } - }; - - fetchUserAvatar(); - }, [session?.user?.email]); - - - - const user = { - name: session?.user?.name || "Admin", - email: session?.user?.email || "admin@example.com", - avatar: userAvatar - }; - - return ( - - {/**/} - {/* */} - {/**/} - - - - - - - - - - ); -}; \ No newline at end of file diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index ae5867b..b5df78e 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -1,12 +1,25 @@ "use client" import { + BookX, Folder, MoreHorizontal, Share, Trash2, + Check, + X, + Info, + AlertTriangle, type LucideIcon, } from "lucide-react" +import React, { useState } from "react" +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { DropdownMenu, @@ -24,66 +37,122 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" +import { Badge } from "@/components/ui/badge" +import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog" +import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog" export function NavProjects({ projects, }: { projects: { + id: string name: string - url: string - icon: LucideIcon + status: string }[] }) { const { isMobile } = useSidebar() + const [shareOpen, setShareOpen] = useState(false) + const [shareLink, setShareLink] = useState("") return ( - - Recent programming topics - - {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() + setShareLink(`${window.location.origin}/problem/${item.id}`) + setShareOpen(true) + }} + > + + 复制链接 + + + + + 移除 + + + + + ))} + + + + + 更多 + + - ))} - - - - More - - - - + + + + + + ) } diff --git a/src/components/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx similarity index 80% rename from src/components/app-sidebar.tsx rename to src/components/sidebar/app-sidebar.tsx index c32996a..24b2d7c 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -35,56 +35,56 @@ const data = { }, navMain: [ { - title: "Dashboard", + title: "页面", url: "#", icon: SquareTerminal, isActive: true, items: [ { - title: "Home", - url: "/", + title: "主页", + url: "/dashboard", }, { - title: "Personal interface", + title: "历史记录", url: "#", }, { - title: "Problems", + title: "题目集", url: "/problemset", }, ], }, { - title: "Done Topics", + title: "已完成事项", url: "#", icon: BookOpen, items: [ { - title: "All Coding", + title: "全部编程集", url: "#", }, { - title: "Correct Codingset", + title: "错题集", url: "#", }, - { - title: "Wrong Codingset", + { + title: "收藏集", url: "#", }, ], }, { - title: "Settings", + title: "设置", url: "#", icon: Settings2, items: [ { - title: "General", + title: "一般设置", url: "#", }, { - title: "Language", + title: "语言", url: "#", }, ], @@ -102,21 +102,21 @@ const data = { icon: Send, }, ], - projects: [ + wrongProblems: [ { - name: "Design Engineering", - url: "#", - icon: Frame, + id: "abc123", + name: "Two Sum", + status: "WA", }, { - name: "Sales & Marketing", - url: "#", - icon: PieChart, + id: "def456", + name: "Reverse Linked List", + status: "RE", }, { - name: "Travel", - url: "#", - icon: Map, + id: "ghi789", + name: "Binary Tree Paths", + status: "TLE", }, ], } @@ -143,7 +143,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + From 932bdbfd8756b1527ba391571d0f9edaf909b86a Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Thu, 19 Jun 2025 16:07:40 +0800 Subject: [PATCH 3/9] 6.19 --- src/app/dashboard/page.tsx | 4 +- src/app/layout.tsx | 3 +- src/components/nav-projects.tsx | 2 +- src/components/sidebar/app-sidebar.tsx | 9 +- src/components/sidebar/teacher-sidebar.tsx | 132 +++++++++++++++++++++ 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 src/components/sidebar/teacher-sidebar.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a6876ff..37f7e40 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import { AppSidebar } from "@/components/sidebar/app-sidebar" +import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar" import { Breadcrumb, BreadcrumbItem, @@ -17,7 +17,7 @@ import { export default function Page() { return ( - +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d50795c..7726dce 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { NextIntlClientProvider } from "next-intl"; import { ThemeProvider } from "@/components/theme-provider"; import { SettingsDialog } from "@/components/settings-dialog"; + export const metadata: Metadata = { title: "Judge4c", description: @@ -37,4 +38,4 @@ export default async function RootLayout({ children }: RootLayoutProps) { ); -} +} \ No newline at end of file diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index b5df78e..b8584c3 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -155,4 +155,4 @@ export function NavProjects({ ) -} +} \ No newline at end of file diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 24b2d7c..f0ef9ee 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -27,6 +27,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar" + const data = { user: { name: "shadcn", @@ -42,7 +43,7 @@ const data = { items: [ { title: "主页", - url: "/dashboard", + url: "/student/dashboard", }, { title: "历史记录", @@ -92,12 +93,12 @@ const data = { ], navSecondary: [ { - title: "Support", + title: "帮助", url: "/", icon: LifeBuoy, }, { - title: "Feedback", + title: "反馈", url: siteConfig.url.repo.github, icon: Send, }, @@ -151,4 +152,4 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ) -} +} \ No newline at end of file diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx new file mode 100644 index 0000000..908dd1e --- /dev/null +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -0,0 +1,132 @@ +"use client" +import { siteConfig } from "@/config/site" +import * as React from "react" +import { + BookOpen, + Command, + Frame, + LifeBuoy, + Map, + PieChart, + Send, + Settings2, + SquareTerminal, +} from "lucide-react" + +import { NavMain } from "@/components/nav-main" +import { NavProjects } from "@/components/nav-projects" +import { NavSecondary } from "@/components/nav-secondary" +import { NavUser } from "@/components/nav-user" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +const data = { + user: { + name: "teacher", + email: "teacher@example.com", + avatar: "/avatars/teacher.jpg", + }, + navMain: [ + { + title: "教师首页", + url: "/teacher/dashboard", + icon: SquareTerminal, + isActive: true, + items: [ + { + title: "课程管理", + url: "/teacher/courses", + }, + { + title: "学生管理", + url: "/teacher/students", + }, + { + title: "题库管理", + url: "/teacher/problems", + }, + ], + }, + { + title: "统计分析", + url: "/teacher/statistics", + icon: PieChart, + items: [ + { + title: "成绩统计", + url: "/teacher/statistics/grades", + }, + { + title: "错题分析", + url: "/teacher/statistics/activity", + }, + ], + }, + { + title: "设置", + url: "#", + icon: Settings2, + items: [ + { + title: "一般设置", + url: "/teacher/profile", + }, + { + title: "语言", + url: "/teacher/settings", + }, + ], + }, + ], + navSecondary: [ + { + title: "帮助", + url: "/", + icon: LifeBuoy, + }, + { + title: "反馈", + url: siteConfig.url.repo.github, + icon: Send, + }, + ], +} + +export function TeacherSidebar({ ...props }: React.ComponentProps) { + return ( + + + + + + +
+ +
+
+ Judge4c 教师端 + Teaching Platform +
+
+
+
+
+
+ + + {/* 教师端可自定义更多内容 */} + + + + + +
+ ) +} From 5ee0c86fcc80827458dd53a724615d81f17d7b7f Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Thu, 19 Jun 2025 16:33:43 +0800 Subject: [PATCH 4/9] 6.19 --- src/app/dashboard/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 37f7e40..a6876ff 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,4 +1,4 @@ -import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar" +import { AppSidebar } from "@/components/sidebar/app-sidebar" import { Breadcrumb, BreadcrumbItem, @@ -17,7 +17,7 @@ import { export default function Page() { return ( - +
From bcbe2868d52397d269328570a474f05ca1da0024 Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Thu, 19 Jun 2025 16:42:37 +0800 Subject: [PATCH 5/9] 6.19 --- src/components/nav-projects.tsx | 6 ------ src/components/sidebar/app-sidebar.tsx | 3 --- src/components/sidebar/teacher-sidebar.tsx | 4 ---- 3 files changed, 13 deletions(-) diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index b8584c3..1d075ad 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -10,15 +10,10 @@ import { X, Info, AlertTriangle, - type LucideIcon, } from "lucide-react" import React, { useState } from "react" import { Dialog, - DialogTrigger, - DialogContent, - DialogHeader, - DialogTitle, } from "@/components/ui/dialog" import { @@ -37,7 +32,6 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar" -import { Badge } from "@/components/ui/badge" import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog" import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog" diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index f0ef9ee..ff41c19 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -4,10 +4,7 @@ import * as React from "react" import { BookOpen, Command, - Frame, LifeBuoy, - Map, - PieChart, Send, Settings2, SquareTerminal, diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx index 908dd1e..49babac 100644 --- a/src/components/sidebar/teacher-sidebar.tsx +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -2,11 +2,8 @@ import { siteConfig } from "@/config/site" import * as React from "react" import { - BookOpen, Command, - Frame, LifeBuoy, - Map, PieChart, Send, Settings2, @@ -14,7 +11,6 @@ import { } from "lucide-react" import { NavMain } from "@/components/nav-main" -import { NavProjects } from "@/components/nav-projects" import { NavSecondary } from "@/components/nav-secondary" import { NavUser } from "@/components/nav-user" import { From dff0515dbbc9fc8743a685f5c6fd99a51f56eb7c Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Fri, 20 Jun 2025 20:18:13 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=9A=82=E6=97=B6=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../management/actions/changePassword.ts | 42 +++++ .../(app)/management/actions/getUserInfo.ts | 19 +++ src/app/(app)/management/actions/index.ts | 4 + .../management/actions/updateUserInfo.ts | 25 +++ .../(app)/management/change-password/page.tsx | 125 +++++++++++++++ src/app/(app)/management/page.tsx | 90 +++++++++++ src/app/(app)/management/profile/page.tsx | 150 ++++++++++++++++++ .../dashboard/layout.tsx} | 34 ++-- src/app/(protected)/dashboard/page.tsx | 3 + .../management-sidebar/manage-form.tsx | 28 ++++ .../management-sidebar/manage-sidebar.tsx | 97 +++++++++++ .../management-sidebar/manage-switcher.tsx | 64 ++++++++ src/components/nav-user.tsx | 21 ++- src/components/sidebar/admin-sidebar.tsx | 108 +++++++++++++ src/components/sidebar/app-sidebar.tsx | 84 +++++++--- src/components/sidebar/teacher-sidebar.tsx | 8 +- src/components/ui/donut-chart.tsx | 58 ------- src/lib/auth.ts | 5 + 18 files changed, 856 insertions(+), 109 deletions(-) create mode 100644 src/app/(app)/management/actions/changePassword.ts create mode 100644 src/app/(app)/management/actions/getUserInfo.ts create mode 100644 src/app/(app)/management/actions/index.ts create mode 100644 src/app/(app)/management/actions/updateUserInfo.ts create mode 100644 src/app/(app)/management/change-password/page.tsx create mode 100644 src/app/(app)/management/page.tsx create mode 100644 src/app/(app)/management/profile/page.tsx rename src/app/{dashboard/page.tsx => (protected)/dashboard/layout.tsx} (61%) create mode 100644 src/app/(protected)/dashboard/page.tsx create mode 100644 src/components/management-sidebar/manage-form.tsx create mode 100644 src/components/management-sidebar/manage-sidebar.tsx create mode 100644 src/components/management-sidebar/manage-switcher.tsx create mode 100644 src/components/sidebar/admin-sidebar.tsx delete mode 100644 src/components/ui/donut-chart.tsx diff --git a/src/app/(app)/management/actions/changePassword.ts b/src/app/(app)/management/actions/changePassword.ts new file mode 100644 index 0000000..018db7a --- /dev/null +++ b/src/app/(app)/management/actions/changePassword.ts @@ -0,0 +1,42 @@ +// changePassword.ts +"use server"; + +import prisma from "@/lib/prisma"; +import bcrypt from "bcryptjs"; + +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 user = await prisma.user.findUnique({ + where: { id: '1' }, + }); + + 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: '1' }, + data: { password: hashedPassword }, + }); + + return { success: true }; + } catch (error) { + console.error("修改密码失败:", error); + throw new Error("修改密码失败"); + } +} \ No newline at end of file diff --git a/src/app/(app)/management/actions/getUserInfo.ts b/src/app/(app)/management/actions/getUserInfo.ts new file mode 100644 index 0000000..dd83390 --- /dev/null +++ b/src/app/(app)/management/actions/getUserInfo.ts @@ -0,0 +1,19 @@ +// getUserInfo.ts +"use server"; + +import prisma from "@/lib/prisma"; + +export async function getUserInfo() { + try { + const user = await prisma.user.findUnique({ + where: { id: 'user_001' }, + }); + + if (!user) throw new Error("用户不存在"); + + return user; + } catch (error) { + console.error("获取用户信息失败:", error); + throw new Error("获取用户信息失败"); + } +} \ No newline at end of file diff --git a/src/app/(app)/management/actions/index.ts b/src/app/(app)/management/actions/index.ts new file mode 100644 index 0000000..5c599e5 --- /dev/null +++ b/src/app/(app)/management/actions/index.ts @@ -0,0 +1,4 @@ +// index.ts +export { getUserInfo } from "./getUserInfo"; +export { updateUserInfo } from "./updateUserInfo"; +export { changePassword } from "./changePassword"; \ No newline at end of file diff --git a/src/app/(app)/management/actions/updateUserInfo.ts b/src/app/(app)/management/actions/updateUserInfo.ts new file mode 100644 index 0000000..201284e --- /dev/null +++ b/src/app/(app)/management/actions/updateUserInfo.ts @@ -0,0 +1,25 @@ +// updateUserInfo.ts +"use server"; + +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 updatedUser = await prisma.user.update({ + where: { id: 'user_001' }, + data: { name, email }, + }); + + return updatedUser; + } catch (error) { + console.error("更新用户信息失败:", error); + throw new Error("更新用户信息失败"); + } +} \ No newline at end of file diff --git a/src/app/(app)/management/change-password/page.tsx b/src/app/(app)/management/change-password/page.tsx new file mode 100644 index 0000000..3b9b770 --- /dev/null +++ b/src/app/(app)/management/change-password/page.tsx @@ -0,0 +1,125 @@ +// src/app/(app)/management/change-password/page.tsx +"use client"; + +import { useState } from "react"; +import { changePassword } from "@/app/(app)/management/actions"; + +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: any) { + alert(error.message); + } + }; + + return ( +
+
+

修改密码

+
+
+ + setOldPassword(e.target.value)} + className="w-full border border-gray-300 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 border-gray-300 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 border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> + {newPassword && confirmPassword && newPassword !== confirmPassword && ( +

密码不一致

+ )} +
+ +
+ +
+
+
+ + {showSuccess && ( +
+ ✅ 密码修改成功! +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/app/(app)/management/page.tsx b/src/app/(app)/management/page.tsx new file mode 100644 index 0000000..03a8f8c --- /dev/null +++ b/src/app/(app)/management/page.tsx @@ -0,0 +1,90 @@ +"use client" +import React, { useState } from "react" +import { AppSidebar } from "@/components/management-sidebar/manage-sidebar" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import { Separator } from "@/components/ui/separator" +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" +import ProfilePage from "./profile/page" +import ChangePasswordPage from "./change-password/page" + +// 模拟菜单数据 +const menuItems = [ + { title: "登录信息", key: "profile" }, + { title: "修改密码", key: "change-password" }, +] + +export default function ManagementDefaultPage() { + const [activePage, setActivePage] = useState("profile") + const [isCollapsed, setIsCollapsed] = useState(false) + + const renderContent = () => { + switch (activePage) { + case "profile": + return + case "change-password": + return + default: + return + } + } + + const toggleSidebar = () => { + setIsCollapsed((prev) => !prev) + } + + return ( + +
+ {/* 左侧侧边栏 */} + {!isCollapsed && ( +
+ +
+ )} + + {/* 右侧主内容区域 */} + +
+ {/* 折叠按钮 */} + + + + {/* 面包屑导航 */} + + + + 管理面板 + + + + + {menuItems.find((item) => item.key === activePage)?.title} + + + + +
+ {/* 主体内容:根据 isCollapsed 切换样式 */} +
+ {renderContent()} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/(app)/management/profile/page.tsx b/src/app/(app)/management/profile/page.tsx new file mode 100644 index 0000000..4659aba --- /dev/null +++ b/src/app/(app)/management/profile/page.tsx @@ -0,0 +1,150 @@ +// src/app/(app)/management/profile/page.tsx +"use client"; + +import { useEffect, useState } from "react"; +import { getUserInfo, updateUserInfo } from "@/app/(app)/management/actions"; + +interface User { + id: string; // TEXT 类型 + name: string | null; // 可能为空 + email: string; // NOT NULL + emailVerified: Date | null; // TIMESTAMP 转换为字符串 + image: string | null; + role: "GUEST" | "USER" | "ADMIN"; // 枚举类型 + createdAt: Date; // TIMESTAMP 转换为字符串 + updatedAt: Date; // TIMESTAMP 转换为字符串 +} + +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: any) { + alert(error.message); + } +}; + + 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 ? ( + <> + + + + ) : ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/(protected)/dashboard/layout.tsx similarity index 61% rename from src/app/dashboard/page.tsx rename to src/app/(protected)/dashboard/layout.tsx index a6876ff..565b2a7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/layout.tsx @@ -1,4 +1,4 @@ -import { AppSidebar } from "@/components/sidebar/app-sidebar" +import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { Breadcrumb, BreadcrumbItem, @@ -6,18 +6,29 @@ import { BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" -import { Separator } from "@/components/ui/separator" +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { auth } from "@/lib/auth"; +import { notFound } from "next/navigation"; -export default function Page() { +interface LayoutProps { + children: React.ReactNode; +} + +export default async function Layout({ children }: LayoutProps) { + const session = await auth(); + const user = session?.user; + if (!user) { + notFound(); + } return ( - +
@@ -38,15 +49,8 @@ export default function Page() {
-
-
-
-
-
-
-
-
+
{children}
- ) + ); } diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..80efbfb --- /dev/null +++ b/src/app/(protected)/dashboard/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Dashboard
+} diff --git a/src/components/management-sidebar/manage-form.tsx b/src/components/management-sidebar/manage-form.tsx new file mode 100644 index 0000000..a120602 --- /dev/null +++ b/src/components/management-sidebar/manage-form.tsx @@ -0,0 +1,28 @@ +import { Search } from "lucide-react" + +import { Label } from "@/components/ui/label" +import { + SidebarGroup, + SidebarGroupContent, + SidebarInput, +} from "@/components/ui/sidebar" + +export function SearchForm({ ...props }: React.ComponentProps<"form">) { + return ( +
+ + + + + + + +
+ ) +} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-sidebar.tsx b/src/components/management-sidebar/manage-sidebar.tsx new file mode 100644 index 0000000..fb026d3 --- /dev/null +++ b/src/components/management-sidebar/manage-sidebar.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; +import { ChevronRight } from "lucide-react"; + +import { VersionSwitcher } from "@/components//management-sidebar/manage-switcher"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar"; + +// 自定义数据:包含用户相关菜单项 +const data = { + versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"], + navUser: [ + { + title: "个人中心", + url: "#", + items: [ + { title: "登录信息", url: "#", key: "profile" }, + { title: "修改密码", url: "#", key: "change-password" }, + ], + }, + ], +}; + +// 显式定义 props 类型 +interface AppSidebarProps { + onItemClick?: (key: string) => void; +} + +export function AppSidebar({ onItemClick = (key: string) => {}, ...props }: AppSidebarProps) { + return ( + + + + + + {/* 渲染用户相关的侧边栏菜单 */} + {data.navUser.map((item) => ( + + + + + {item.title} + + + + + + + {item.items.map((subItem) => ( + + { + e.preventDefault(); + onItemClick(subItem.key); + }} + > + {subItem.title} + + + ))} + + + + + + ))} + + + + ); +} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-switcher.tsx b/src/components/management-sidebar/manage-switcher.tsx new file mode 100644 index 0000000..054995b --- /dev/null +++ b/src/components/management-sidebar/manage-switcher.tsx @@ -0,0 +1,64 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react" + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +export function VersionSwitcher({ + versions, + defaultVersion, +}: { + versions: string[] + defaultVersion: string +}) { + const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion) + + return ( + + + + + +
+ +
+
+ Documentation + v{selectedVersion} +
+ +
+
+ + {versions.map((version) => ( + setSelectedVersion(version)} + > + v{version}{" "} + {version === selectedVersion && } + + ))} + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index ec6f1f6..1fa7a82 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -43,6 +43,19 @@ export function NavUser({ const { isMobile } = useSidebar() const router = useRouter() + async function handleLogout() { + await fetch("/api/auth/signout", { method: "POST" }); + router.replace("/sign-in"); + } + + function handleAccount() { + if (user && user.email) { + router.replace("/user/profile"); + } else { + router.replace("/sign-in"); + } + } + return ( @@ -90,11 +103,11 @@ export function NavUser({ - + Account - router.push("/sign-in")}> + router.push("/sign-in")}> Switch User @@ -104,9 +117,7 @@ export function NavUser({ - { - router.replace("/"); - }}> + Log out diff --git a/src/components/sidebar/admin-sidebar.tsx b/src/components/sidebar/admin-sidebar.tsx new file mode 100644 index 0000000..b5ead92 --- /dev/null +++ b/src/components/sidebar/admin-sidebar.tsx @@ -0,0 +1,108 @@ +"use client" +import { siteConfig } from "@/config/site" +import * as React from "react" +import { + LifeBuoy, + Send, + Shield, +} from "lucide-react" + +import { NavMain } from "@/components/nav-main" +import { NavProjects } from "@/components/nav-projects" +import { NavSecondary } from "@/components/nav-secondary" +import { NavUser } from "@/components/nav-user" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" + +import { useEffect, useState } from "react" + +const adminData = { + navMain: [ + { + title: "管理面板", + url: "#", + icon: Shield, + isActive: true, + items: [ + { title: "管理员管理", url: "/usermanagement/admin" }, + { title: "用户管理", url: "/usermanagement/guest" }, + { title: "教师管理", url: "/usermanagement/teacher" }, + { title: "题目管理", url: "/usermanagement/problem" }, + ], + }, + + ], + navSecondary: [ + { title: "帮助", url: "/", icon: LifeBuoy }, + { title: "反馈", url: siteConfig.url.repo.github, icon: Send }, + ], + wrongProblems: [], +} + +async function fetchCurrentUser() { + try { + const res = await fetch("/api/auth/session"); + if (!res.ok) return null; + const session = await res.json(); + return { + name: session?.user?.name ?? "未登录管理员", + email: session?.user?.email ?? "", + avatar: session?.user?.image ?? "/avatars/default.jpg", + }; + } catch { + return { + name: "未登录管理员", + email: "", + avatar: "/avatars/default.jpg", + }; + } +} + +export function AdminSidebar(props: React.ComponentProps) { + const [user, setUser] = useState({ + name: "未登录管理员", + email: "", + avatar: "/avatars/default.jpg", + }); + + useEffect(() => { + fetchCurrentUser().then(u => u && setUser(u)); + }, []); + + return ( + + + + + + +
+ +
+
+ Admin + 管理后台 +
+
+
+
+
+
+ + + + + + + + +
+ ) +} diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index ff41c19..9038c9e 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -1,6 +1,7 @@ -"use client" -import { siteConfig } from "@/config/site" -import * as React from "react" +"use client"; + +import { siteConfig } from "@/config/site"; +import * as React from "react"; import { BookOpen, Command, @@ -8,12 +9,12 @@ import { Send, Settings2, SquareTerminal, -} from "lucide-react" +} from "lucide-react"; -import { NavMain } from "@/components/nav-main" -import { NavProjects } from "@/components/nav-projects" -import { NavSecondary } from "@/components/nav-secondary" -import { NavUser } from "@/components/nav-user" +import { NavMain } from "@/components/nav-main"; +import { NavProjects } from "@/components/nav-projects"; +import { NavSecondary } from "@/components/nav-secondary"; +import { NavUser } from "@/components/nav-user"; import { Sidebar, SidebarContent, @@ -22,15 +23,13 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { User } from "next-auth"; +// import { useEffect, useState } from "react" +// import { auth, signIn } from "@/lib/auth" const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, navMain: [ { title: "页面", @@ -52,7 +51,7 @@ const data = { }, ], }, - + { title: "已完成事项", url: "#", @@ -66,7 +65,7 @@ const data = { title: "错题集", url: "#", }, - { + { title: "收藏集", url: "#", }, @@ -77,10 +76,6 @@ const data = { url: "#", icon: Settings2, items: [ - { - title: "一般设置", - url: "#", - }, { title: "语言", url: "#", @@ -117,11 +112,50 @@ const data = { status: "TLE", }, ], +}; + +// // 获取当前登录用户信息的 API +// async function fetchCurrentUser() { +// try { +// const res = await fetch("/api/auth/session"); +// if (!res.ok) return null; +// const session = await res.json(); +// return { +// name: session?.user?.name ?? "未登录用户", +// email: session?.user?.email ?? "", +// avatar: session?.user?.image ?? "/avatars/default.jpg", +// }; +// } catch { +// return { +// name: "未登录用户", +// email: "", +// avatar: "/avatars/default.jpg", +// }; +// } +// } + +interface AppSidebarProps{ + user:User } -export function AppSidebar({ ...props }: React.ComponentProps) { +export function AppSidebar({ user, ...props }: AppSidebarProps) { + // const [user, setUser] = useState({ + // name: "未登录用户", + // email: "", + // avatar: "/avatars/default.jpg", + // }); + + // useEffect(() => { + // fetchCurrentUser().then(u => u && setUser(u)); + // }, []); + const userInfo = { + name: user.name ?? "", + email: user.email ?? "", + avatar: user.image ?? "", + }; + return ( - + @@ -145,8 +179,8 @@ export function AppSidebar({ ...props }: React.ComponentProps) { - + - ) -} \ No newline at end of file + ); +} diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx index 49babac..2eecd71 100644 --- a/src/components/sidebar/teacher-sidebar.tsx +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -36,10 +36,6 @@ const data = { icon: SquareTerminal, isActive: true, items: [ - { - title: "课程管理", - url: "/teacher/courses", - }, { title: "学生管理", url: "/teacher/students", @@ -56,11 +52,11 @@ const data = { icon: PieChart, items: [ { - title: "成绩统计", + title: "完成情况", url: "/teacher/statistics/grades", }, { - title: "错题分析", + title: "错题统计", url: "/teacher/statistics/activity", }, ], diff --git a/src/components/ui/donut-chart.tsx b/src/components/ui/donut-chart.tsx deleted file mode 100644 index c6f3296..0000000 --- a/src/components/ui/donut-chart.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// 简单的环形图组件,使用 SVG 实现 -import React from "react"; - -interface DonutChartProps { - percent: number; // 完成比例 0-100 - size?: number; // 图表直径 - strokeWidth?: number; // 圆环宽度 - color?: string; // 完成部分颜色 - bgColor?: string; // 未完成部分颜色 -} - -export function DonutChart({ - percent, - size = 120, - strokeWidth = 16, - color = "#3b82f6", - bgColor = "#e5e7eb", -}: DonutChartProps) { - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - const offset = circumference * (1 - percent / 100); - - return ( - - - - - {percent}% - - - ); -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0643271..96ba401 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -247,3 +247,8 @@ export const { auth, handlers, signIn, signOut } = NextAuth({ }, }, }); + +export const getCurrentUser=async ()=>{ + const session=await auth(); + return session?.user +} From 47feffd62ce6514a40dc6b685868e3a457eb47bf Mon Sep 17 00:00:00 2001 From: Asuka <15019597+asuka-civil@user.noreply.gitee.com> Date: Sat, 21 Jun 2025 17:44:14 +0800 Subject: [PATCH 7/9] feat: add dashboard --- .idea/.gitignore | 8 - .idea/inspectionProfiles/Project_Default.xml | 6 - .idea/judge4c.iml | 9 - .idea/misc.xml | 6 - .idea/modules.xml | 8 - .idea/vcs.xml | 6 - prisma/check-problem-submissions.ts | 67 ++ prisma/fill-testcase-results.ts | 37 + prisma/generate-user-data.ts | 118 +++ .../20250621055555_lg/migration.sql | 2 + prisma/schema.prisma | 1 + prisma/test-student-dashboard.ts | 144 +++ prisma/test-student-data-access.ts | 79 ++ .../(app)/management/actions/getUserInfo.ts | 19 - src/app/(app)/management/page.tsx | 90 -- .../problems/[problemId]/edit/layout.tsx | 2 +- .../admin/problems/[problemId]/edit/page.tsx | 17 + .../{ => (protect-admin)}/layout.tsx | 2 +- .../admin/problems/[problemId]/edit/page.tsx | 13 - .../_actions/student-dashboard.ts | 175 ++++ .../_actions/teacher-dashboard.ts | 206 ++++ .../student/dashboard/page.tsx | 211 ++++ .../teacher/dashboard/page.tsx | 253 +++++ .../teacher/dashboard/test-data.tsx | 89 ++ src/app/(protected)/dashboard/layout.tsx | 110 +- .../management/actions/changePassword.ts | 27 +- .../management/actions/getUserInfo.ts | 40 + .../dashboard}/management/actions/index.ts | 0 .../management/actions/updateUserInfo.ts | 13 +- .../management/change-password/page.tsx | 7 +- .../(protected)/dashboard/management/page.tsx | 58 ++ .../dashboard}/management/profile/page.tsx | 22 +- src/app/(protected)/dashboard/page.tsx | 306 +++++- .../usermanagement/_actions/problemActions.ts | 14 + .../usermanagement/_actions/userActions.ts | 46 + .../_components/GenericLayout.tsx | 10 + .../_components/ProtectedLayout.tsx | 28 + .../dashboard/usermanagement/admin/layout.tsx | 5 + .../dashboard/usermanagement/admin/page.tsx | 6 + .../dashboard/usermanagement/guest/layout.tsx | 5 + .../dashboard/usermanagement/guest/page.tsx | 6 + .../usermanagement/problem/layout.tsx | 5 + .../dashboard/usermanagement/problem/page.tsx | 6 + .../usermanagement/teacher/layout.tsx | 5 + .../dashboard/usermanagement/teacher/page.tsx | 6 + .../UncompletedProject/wrongbook-dialog.tsx | 37 +- src/components/dynamic-breadcrumb.tsx | 106 ++ .../management-sidebar/manage-form.tsx | 28 - .../management-sidebar/manage-sidebar.tsx | 97 -- .../management-sidebar/manage-switcher.tsx | 64 -- src/components/nav-projects.tsx | 27 +- src/components/nav-user.tsx | 23 +- src/components/sidebar/admin-sidebar.tsx | 51 +- src/components/sidebar/app-sidebar.tsx | 132 +-- src/components/sidebar/teacher-sidebar.tsx | 71 +- .../admin/ui/views/problem-edit-view.tsx | 38 +- .../components/generic-page.tsx | 21 + .../user-management/components/user-table.tsx | 954 ++++++++++++++++++ src/features/user-management/config/admin.ts | 23 + .../user-management/config/base-config.ts | 86 ++ src/features/user-management/config/guest.ts | 19 + .../user-management/config/problem.ts | 41 + .../user-management/config/teacher.ts | 19 + 63 files changed, 3522 insertions(+), 608 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/judge4c.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml create mode 100644 prisma/check-problem-submissions.ts create mode 100644 prisma/fill-testcase-results.ts create mode 100644 prisma/generate-user-data.ts create mode 100644 prisma/migrations/20250621055555_lg/migration.sql create mode 100644 prisma/test-student-dashboard.ts create mode 100644 prisma/test-student-data-access.ts delete mode 100644 src/app/(app)/management/actions/getUserInfo.ts delete mode 100644 src/app/(app)/management/page.tsx rename src/app/(protected)/{ => (protect-admin)}/admin/problems/[problemId]/edit/layout.tsx (95%) create mode 100644 src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx rename src/app/(protected)/{ => (protect-admin)}/layout.tsx (92%) delete mode 100644 src/app/(protected)/admin/problems/[problemId]/edit/page.tsx create mode 100644 src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts create mode 100644 src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts create mode 100644 src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx create mode 100644 src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx create mode 100644 src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx rename src/app/{(app) => (protected)/dashboard}/management/actions/changePassword.ts (63%) create mode 100644 src/app/(protected)/dashboard/management/actions/getUserInfo.ts rename src/app/{(app) => (protected)/dashboard}/management/actions/index.ts (100%) rename src/app/{(app) => (protected)/dashboard}/management/actions/updateUserInfo.ts (66%) rename src/app/{(app) => (protected)/dashboard}/management/change-password/page.tsx (94%) create mode 100644 src/app/(protected)/dashboard/management/page.tsx rename src/app/{(app) => (protected)/dashboard}/management/profile/page.tsx (89%) create mode 100644 src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts create mode 100644 src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts create mode 100644 src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/admin/layout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/admin/page.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/guest/layout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/guest/page.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/problem/layout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/problem/page.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/teacher/page.tsx create mode 100644 src/components/dynamic-breadcrumb.tsx delete mode 100644 src/components/management-sidebar/manage-form.tsx delete mode 100644 src/components/management-sidebar/manage-sidebar.tsx delete mode 100644 src/components/management-sidebar/manage-switcher.tsx create mode 100644 src/features/user-management/components/generic-page.tsx create mode 100644 src/features/user-management/components/user-table.tsx create mode 100644 src/features/user-management/config/admin.ts create mode 100644 src/features/user-management/config/base-config.ts create mode 100644 src/features/user-management/config/guest.ts create mode 100644 src/features/user-management/config/problem.ts create mode 100644 src/features/user-management/config/teacher.ts diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/judge4c.iml b/.idea/judge4c.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/judge4c.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6f29fee..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ce31b97..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/prisma/check-problem-submissions.ts b/prisma/check-problem-submissions.ts new file mode 100644 index 0000000..b3aca04 --- /dev/null +++ b/prisma/check-problem-submissions.ts @@ -0,0 +1,67 @@ +import { PrismaClient } from "@/generated/client"; + +const prisma = new PrismaClient(); + +async function checkProblemSubmissions() { + console.log("检查所有题目的提交记录情况..."); + + // 获取所有题目 + const problems = await prisma.problem.findMany({ + orderBy: { displayId: 'asc' } + }); + + console.log(`总题目数: ${problems.length}`); + + for (const problem of problems) { + // 统计该题目的提交记录 + const submissionCount = await prisma.submission.count({ + where: { problemId: problem.id } + }); + + // 统计该题目的完成情况 + const completedCount = await prisma.submission.count({ + where: { + problemId: problem.id, + status: "AC" + } + }); + + // 查询时包含 localizations + const problems = await prisma.problem.findMany({ + include: { + localizations: { + where: { type: "TITLE" }, + select: { content: true } + } + } + }); + + problems.forEach(problem => { + const title = problem.localizations[0]?.content || "无标题"; + console.log(`题目${problem.displayId} (${title}): ...`); + }); + + // 统计有提交记录的题目数量 + const problemsWithSubmissions = await prisma.problem.findMany({ + where: { + submissions: { + some: {} + } + } + }); + + console.log(`\n有提交记录的题目数量: ${problemsWithSubmissions.length}`); + console.log("有提交记录的题目编号:"); + problemsWithSubmissions.forEach(p => { + console.log(` ${p.displayId}`); + }); +} + +checkProblemSubmissions() + .catch((e) => { + console.error("检查数据时出错:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + })}; \ No newline at end of file diff --git a/prisma/fill-testcase-results.ts b/prisma/fill-testcase-results.ts new file mode 100644 index 0000000..56a1fff --- /dev/null +++ b/prisma/fill-testcase-results.ts @@ -0,0 +1,37 @@ +import { PrismaClient, Status } from "@/generated/client"; +const prisma = new PrismaClient(); + +async function fillTestcaseResults() { + const submissions = await prisma.submission.findMany(); + let count = 0; + for (const submission of submissions) { + const testcases = await prisma.testcase.findMany({ + where: { problemId: submission.problemId }, + }); + for (const testcase of testcases) { + // 检查是否已存在,避免重复 + const exists = await prisma.testcaseResult.findFirst({ + where: { + submissionId: submission.id, + testcaseId: testcase.id, + }, + }); + if (!exists) { + await prisma.testcaseResult.create({ + data: { + isCorrect: submission.status === Status.AC, + output: submission.status === Status.AC ? "正确答案" : "错误答案", + timeUsage: Math.floor(Math.random() * 1000) + 1, + memoryUsage: Math.floor(Math.random() * 128) + 1, + submissionId: submission.id, + testcaseId: testcase.id, + }, + }); + count++; + } + } + } + console.log(`已为 ${count} 个提交生成测试用例结果`); +} + +fillTestcaseResults().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/prisma/generate-user-data.ts b/prisma/generate-user-data.ts new file mode 100644 index 0000000..176c219 --- /dev/null +++ b/prisma/generate-user-data.ts @@ -0,0 +1,118 @@ +import { PrismaClient, Status, Language } from "@/generated/client"; + +const prisma = new PrismaClient(); + +async function generateUserData() { + console.log("为 student@example.com 生成测试数据..."); + + try { + // 查找用户 + const user = await prisma.user.findUnique({ + where: { email: "student@example.com" } + }); + + if (!user) { + console.log("用户不存在,创建用户..."); + const newUser = await prisma.user.create({ + data: { + name: "测试学生", + email: "student@example.com", + password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", + role: "GUEST", + }, + }); + console.log("创建用户成功:", newUser); + return; + } + + console.log("找到用户:", user.name || user.email); + + // 获取所有已发布的题目 + const problems = await prisma.problem.findMany({ + where: { isPublished: true }, + select: { id: true, displayId: true, localizations: { + where: { locale: "en", type: "TITLE" }, + select: { content: true } + } } + }); + + console.log(`找到 ${problems.length} 道已发布题目`); + + // 为这个用户生成提交记录 + const submissionCount = Math.min(problems.length, 8); // 最多8道题目 + const selectedProblems = problems.slice(0, submissionCount); + + for (const problem of selectedProblems) { + // 为每道题目生成1-3次提交 + const attempts = Math.floor(Math.random() * 3) + 1; + + for (let i = 0; i < attempts; i++) { + // 60%概率AC,40%概率WA + const isAC = Math.random() < 0.6 || i === attempts - 1; // 最后一次提交更可能是AC + + const submission = await prisma.submission.create({ + data: { + language: Math.random() > 0.5 ? Language.c : Language.cpp, + content: `// ${user.name || user.email} 针对题目${problem.displayId}的第${i + 1}次提交`, + status: isAC ? Status.AC : Status.WA, + message: isAC ? "Accepted" : "Wrong Answer", + timeUsage: Math.floor(Math.random() * 1000) + 1, + memoryUsage: Math.floor(Math.random() * 128) + 1, + userId: user.id, + problemId: problem.id, + }, + }); + + // 获取题目的测试用例 + const testcases = await prisma.testcase.findMany({ + where: { problemId: problem.id } + }); + + // 为每个提交生成测试用例结果 + for (const testcase of testcases) { + await prisma.testcaseResult.create({ + data: { + isCorrect: isAC, + output: isAC ? "正确答案" : "错误答案", + timeUsage: Math.floor(Math.random() * 1000) + 1, + memoryUsage: Math.floor(Math.random() * 128) + 1, + submissionId: submission.id, + testcaseId: testcase.id, + }, + }); + } + + console.log(`题目${problem.displayId}: 第${i + 1}次提交 - ${isAC ? 'AC' : 'WA'}`); + + // 如果AC了,就不再继续提交这道题 + if (isAC) break; + } + } + + console.log("数据生成完成!"); + + // 验证生成的数据 + const userSubmissions = await prisma.submission.findMany({ + where: { userId: user.id }, + include: { + problem: { select: { displayId: true, localizations: { + where: { locale: "en", type: "TITLE" }, + select: { content: true } + } } } + } + }); + + console.log(`\n用户 ${user.name || user.email} 现在有 ${userSubmissions.length} 条提交记录:`); + userSubmissions.forEach((s, index) => { + const title = s.problem.localizations.find(l => l.content === "TITLE")?.content || "无标题"; + console.log(`${index + 1}. 题目${s.problem.displayId} (${title}) - ${s.status}`); +}); + + } catch (error) { + console.error("生成数据时出错:", error); + } finally { + await prisma.$disconnect(); + } +} + +generateUserData(); \ No newline at end of file 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/prisma/test-student-dashboard.ts b/prisma/test-student-dashboard.ts new file mode 100644 index 0000000..eb49d54 --- /dev/null +++ b/prisma/test-student-dashboard.ts @@ -0,0 +1,144 @@ +import { PrismaClient } from "@/generated/client"; + +const prisma = new PrismaClient(); + +async function testStudentDashboard() { + console.log("测试学生仪表板数据获取..."); + + // 获取一个学生用户 + const student = await prisma.user.findFirst({ + where: { role: "GUEST" }, + select: { id: true, name: true, email: true } + }); + + if (!student) { + console.log("未找到学生用户,创建测试学生..."); + const newStudent = await prisma.user.create({ + data: { + name: "测试学生", + email: "test_student@example.com", + password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", + role: "GUEST", + }, + }); + console.log(`创建学生: ${newStudent.name} (${newStudent.email})`); + } + + // 获取所有已发布的题目 + const allProblems = await prisma.problem.findMany({ + where: { isPublished: true }, + select: { + id: true, + displayId: true, + difficulty: true, + localizations: { + where: { + type: "TITLE", + locale: "en" // 或者根据需求使用其他语言 + }, + select: { + content: true + } + } + } + }); + + console.log(`总题目数: ${allProblems.length}`); + + // 获取学生的所有提交记录 + const userSubmissions = await prisma.submission.findMany({ + where: { userId: student?.id }, + include: { + problem: { + select: { + id: true, + displayId: true, + difficulty: true, + localizations: { + where: { + type: "TITLE", + locale: "en" // 或者根据需求使用其他语言 + }, + select: { + content: true + } + } + } + } + } + }); + + console.log(`学生提交记录数: ${userSubmissions.length}`); + + // 计算题目完成情况 + 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); + } + }); + + // 题目完成比例数据 + const completionData = { + total: allProblems.length, + completed: completedProblems.size, + percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0, + }; + + // 错题比例数据 + const totalSubmissions = userSubmissions.length; + const wrongSubmissionsCount = userSubmissions.filter(s => s.status !== "AC").length; + const errorData = { + total: totalSubmissions, + wrong: wrongSubmissionsCount, + percentage: totalSubmissions > 0 ? Math.round((wrongSubmissionsCount / totalSubmissions) * 100) : 0, + }; + //易错题列表(按错误次数排序) + const difficultProblems = Array.from(wrongSubmissions.entries()) + .map(([problemId, errorCount]) => { + const problem = allProblems.find(p => p.id === problemId); + // 从 localizations 获取标题(英文优先) + const title = problem?.localizations?.find(l => l.content === "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); + + console.log("\n=== 学生仪表板数据 ==="); + console.log(`题目完成情况: ${completionData.completed}/${completionData.total} (${completionData.percentage}%)`); + console.log(`提交正确率: ${errorData.total - errorData.wrong}/${errorData.total} (${100 - errorData.percentage}%)`); + console.log(`易错题数量: ${difficultProblems.length}`); + + if (difficultProblems.length > 0) { + console.log("\n易错题列表:"); + difficultProblems.forEach((problem, index) => { + console.log(`${index + 1}. 题目${problem.id} (${problem.title}) - ${problem.difficulty} - 错误${problem.errorCount}次`); + }); + } + + console.log("\n测试完成!"); +} + +testStudentDashboard() + .catch((e) => { + console.error("测试学生仪表板时出错:", e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); \ No newline at end of file diff --git a/prisma/test-student-data-access.ts b/prisma/test-student-data-access.ts new file mode 100644 index 0000000..3a9643f --- /dev/null +++ b/prisma/test-student-data-access.ts @@ -0,0 +1,79 @@ +// import { PrismaClient } from "@/generated/client"; + +// const prisma = new PrismaClient(); + +// async function testStudentDataAccess() { +// console.log("测试学生数据访问..."); + +// try { +// // 1. 检查是否有学生用户 +// const students = await prisma.user.findMany({ +// where: { role: "GUEST" }, +// select: { id: true, name: true, email: true } +// }); +// console.log(`找到 ${students.length} 个学生用户:`); +// students.forEach(s => console.log(` - ${s.name} (${s.email})`)); + +// if (students.length === 0) { +// console.log("没有学生用户,创建测试学生..."); +// const testStudent = await prisma.user.create({ +// data: { +// name: "测试学生", +// email: "test_student@example.com", +// password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", +// role: "GUEST", +// }, +// }); +// console.log(`创建学生: ${testStudent.name}`); +// } + +// // 2. 检查已发布的题目 +// const publishedProblems = await prisma.problem.findMany({ +// where: { isPublished: true }, +// select: { id: true, +// displayId: true, +// localizations: { +// where: { locale: "en", type: "TITLE" }, +// select: { content: true } +// } +// } +// }); +// console.log(`\n已发布题目数量: ${publishedProblems.length}`); +// publishedProblems.slice(0, 5).forEach((p: any) => { +// const title = p.localizations?.find((l: any) => l.type === "TITLE")?.content || "无标题"; +// console.log(` - ${p.displayId}: ${title}`); +// }); + +// // 3. 检查提交记录 +// const allSubmissions = await prisma.submission.findMany({ +// select: { id: true, userId: true, problemId: true, status: true } +// }); +// console.log(`\n总提交记录数: ${allSubmissions.length}`); + +// // 4. 检查特定学生的提交记录 +// const firstStudent = students[0] || await prisma.user.findFirst({ where: { role: "GUEST" } }); +// if (firstStudent) { +// const studentSubmissions = await prisma.submission.findMany({ +// where: { userId: firstStudent.id }, +// include: { +// problem: { select: { displayId: true, localizations: { +// where: { locale: "en", type: "TITLE" }, +// select: { content: true } +// } } } +// } +// }); +// console.log(`\n学生 ${firstStudent.name} 的提交记录数: ${studentSubmissions.length}`); +// studentSubmissions.slice(0, 3).forEach(s => { +// console.log(` - 题目${s.problem.displayId}: ${s.status}`); +// }); +// } + +// console.log("\n数据访问测试完成!"); +// } catch (error) { +// console.error("测试过程中出错:", error); +// } finally { +// await prisma.$disconnect(); +// } +// } + +// testStudentDataAccess(); \ No newline at end of file diff --git a/src/app/(app)/management/actions/getUserInfo.ts b/src/app/(app)/management/actions/getUserInfo.ts deleted file mode 100644 index dd83390..0000000 --- a/src/app/(app)/management/actions/getUserInfo.ts +++ /dev/null @@ -1,19 +0,0 @@ -// getUserInfo.ts -"use server"; - -import prisma from "@/lib/prisma"; - -export async function getUserInfo() { - try { - const user = await prisma.user.findUnique({ - where: { id: 'user_001' }, - }); - - if (!user) throw new Error("用户不存在"); - - return user; - } catch (error) { - console.error("获取用户信息失败:", error); - throw new Error("获取用户信息失败"); - } -} \ No newline at end of file diff --git a/src/app/(app)/management/page.tsx b/src/app/(app)/management/page.tsx deleted file mode 100644 index 03a8f8c..0000000 --- a/src/app/(app)/management/page.tsx +++ /dev/null @@ -1,90 +0,0 @@ -"use client" -import React, { useState } from "react" -import { AppSidebar } from "@/components/management-sidebar/manage-sidebar" -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" -import { Separator } from "@/components/ui/separator" -import { - SidebarInset, - SidebarProvider, - SidebarTrigger, -} from "@/components/ui/sidebar" -import ProfilePage from "./profile/page" -import ChangePasswordPage from "./change-password/page" - -// 模拟菜单数据 -const menuItems = [ - { title: "登录信息", key: "profile" }, - { title: "修改密码", key: "change-password" }, -] - -export default function ManagementDefaultPage() { - const [activePage, setActivePage] = useState("profile") - const [isCollapsed, setIsCollapsed] = useState(false) - - const renderContent = () => { - switch (activePage) { - case "profile": - return - case "change-password": - return - default: - return - } - } - - const toggleSidebar = () => { - setIsCollapsed((prev) => !prev) - } - - return ( - -
- {/* 左侧侧边栏 */} - {!isCollapsed && ( -
- -
- )} - - {/* 右侧主内容区域 */} - -
- {/* 折叠按钮 */} - - - - {/* 面包屑导航 */} - - - - 管理面板 - - - - - {menuItems.find((item) => item.key === activePage)?.title} - - - - -
- {/* 主体内容:根据 isCollapsed 切换样式 */} -
- {renderContent()} -
-
-
-
- ) -} \ No newline at end of file diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx similarity index 95% rename from src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx rename to src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx index fb46c63..0667f52 100644 --- a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx +++ b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx @@ -16,4 +16,4 @@ const Layout = async ({ children, params }: LayoutProps) => { return {children}; }; -export default Layout; +export default Layout; \ No newline at end of file diff --git a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx new file mode 100644 index 0000000..87ae266 --- /dev/null +++ b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx @@ -0,0 +1,17 @@ +import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view"; + +// interface PageProps { +// params: Promise<{ problemId: string }>; +// } + +const Page = async ( + // { params }: PageProps +) => { + // const { problemId } = await params; + + return ; +}; + +export default Page; \ No newline at end of file diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/(protect-admin)/layout.tsx similarity index 92% rename from src/app/(protected)/layout.tsx rename to src/app/(protected)/(protect-admin)/layout.tsx index 8a017f9..105864e 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/(protect-admin)/layout.tsx @@ -8,4 +8,4 @@ const Layout = ({ children }: LayoutProps) => { return {children}; }; -export default Layout; +export default Layout; \ No newline at end of file diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx deleted file mode 100644 index 8f8302f..0000000 --- a/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view"; - -interface PageProps { - params: Promise<{ problemId: string }>; -} - -const Page = async ({ params }: PageProps) => { - const { problemId } = await params; - - return ; -}; - -export default Page; diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts b/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts new file mode 100644 index 0000000..d54485d --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts @@ -0,0 +1,175 @@ +"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 : '未知错误'}`); + } +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts new file mode 100644 index 0000000..aeb9718 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts @@ -0,0 +1,206 @@ +"use server"; + +import prisma from "@/lib/prisma"; +import { Locale, Status, ProblemLocalization } from "@/generated/client"; +import { getLocale } from "next-intl/server"; + +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 { + // 获取所有提交记录,按题目分组统计 + const submissions = await prisma.submission.findMany({ + include: { + user: true, + problem: { + include:{ + localizations:true + } + } + }, + }); + + const locale = await getLocale(); + + // 按题目分组统计完成情况(统计独立用户数) + const problemStats = new Map; + 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 { + // 获取所有测试用例结果 + const testcaseResults = await prisma.testcaseResult.findMany({ + include: { + testcase: { + include: { + problem: { + include: { + localizations: true + } + }, + }, + }, + submission: { + include: { + user: true, + }, + }, + }, + }); + + // 按问题分组统计错误率 + const problemStats = new Map; + }>(); + + 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, + }; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx b/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx new file mode 100644 index 0000000..d2d9c53 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; +import { useEffect, useState } from "react"; +import { getStudentDashboardData } from "@/app/(protected)/dashboard/(userdashboard)/_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} + + ))} + +
+ ) : ( +
+ 暂无易错题数据 +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx new file mode 100644 index 0000000..01f0ba1 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { Bar, BarChart, XAxis, YAxis, LabelList, CartesianGrid } from "recharts"; +import { Button } from "@/components/ui/button"; +import { useState, useEffect } from "react"; + +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 { getDashboardStats, ProblemCompletionData, DifficultProblemData } from "@/app/(protected)/dashboard/(userdashboard)/_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([]); + 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} + + ))} + +
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx new file mode 100644 index 0000000..bf518d5 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { getDashboardStats } from "@/app/(protected)/dashboard/(userdashboard)/_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)}
+          
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/layout.tsx b/src/app/(protected)/dashboard/layout.tsx index 565b2a7..119e21f 100644 --- a/src/app/(protected)/dashboard/layout.tsx +++ b/src/app/(protected)/dashboard/layout.tsx @@ -1,12 +1,7 @@ import { AppSidebar } from "@/components/sidebar/app-sidebar"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; +import { AdminSidebar } from "@/components/sidebar/admin-sidebar"; +import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar"; +import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; import { Separator } from "@/components/ui/separator"; import { SidebarInset, @@ -14,39 +9,106 @@ import { SidebarTrigger, } from "@/components/ui/sidebar"; import { auth } from "@/lib/auth"; -import { notFound } from "next/navigation"; +import prisma from "@/lib/prisma"; +import { redirect } from "next/navigation"; 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) { - notFound(); + 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() + )}
- - - - - Building Your Application - - - - - Data Fetching - - - +
{children}
diff --git a/src/app/(app)/management/actions/changePassword.ts b/src/app/(protected)/dashboard/management/actions/changePassword.ts similarity index 63% rename from src/app/(app)/management/actions/changePassword.ts rename to src/app/(protected)/dashboard/management/actions/changePassword.ts index 018db7a..554a2be 100644 --- a/src/app/(app)/management/actions/changePassword.ts +++ b/src/app/(protected)/dashboard/management/actions/changePassword.ts @@ -1,7 +1,8 @@ // changePassword.ts "use server"; -import prisma from "@/lib/prisma"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; import bcrypt from "bcryptjs"; export async function changePassword(formData: FormData) { @@ -13,24 +14,40 @@ export async function changePassword(formData: FormData) { } try { + // 获取当前登录用户 + const session = await auth(); + const userId = session?.user?.id; + + if (!userId) { + throw new Error("用户未登录"); + } + + // 查询当前用户信息 const user = await prisma.user.findUnique({ - where: { id: '1' }, + where: { id: userId }, }); - if (!user) throw new Error("用户不存在"); + 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("旧密码错误"); + if (!isMatch) { + throw new Error("旧密码错误"); + } + // 加密新密码 const hashedPassword = await bcrypt.hash(newPassword, 10); + // 更新密码 await prisma.user.update({ - where: { id: '1' }, + where: { id: userId }, data: { password: hashedPassword }, }); 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..53aacab --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts @@ -0,0 +1,40 @@ +// getUserInfo.ts +"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("获取用户信息失败"); + } +} \ No newline at end of file diff --git a/src/app/(app)/management/actions/index.ts b/src/app/(protected)/dashboard/management/actions/index.ts similarity index 100% rename from src/app/(app)/management/actions/index.ts rename to src/app/(protected)/dashboard/management/actions/index.ts diff --git a/src/app/(app)/management/actions/updateUserInfo.ts b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts similarity index 66% rename from src/app/(app)/management/actions/updateUserInfo.ts rename to src/app/(protected)/dashboard/management/actions/updateUserInfo.ts index 201284e..66edff6 100644 --- a/src/app/(app)/management/actions/updateUserInfo.ts +++ b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts @@ -1,7 +1,8 @@ // updateUserInfo.ts "use server"; -import prisma from "@/lib/prisma"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; export async function updateUserInfo(formData: FormData) { const name = formData.get("name") as string; @@ -12,8 +13,16 @@ export async function updateUserInfo(formData: FormData) { } try { + // 获取当前会话 + const session = await auth(); + const userId = session?.user?.id; + + if (!userId) { + throw new Error("用户未登录"); + } + const updatedUser = await prisma.user.update({ - where: { id: 'user_001' }, + where: { id: userId }, data: { name, email }, }); diff --git a/src/app/(app)/management/change-password/page.tsx b/src/app/(protected)/dashboard/management/change-password/page.tsx similarity index 94% rename from src/app/(app)/management/change-password/page.tsx rename to src/app/(protected)/dashboard/management/change-password/page.tsx index 3b9b770..b635a46 100644 --- a/src/app/(app)/management/change-password/page.tsx +++ b/src/app/(protected)/dashboard/management/change-password/page.tsx @@ -2,7 +2,7 @@ "use client"; import { useState } from "react"; -import { changePassword } from "@/app/(app)/management/actions"; +import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword"; export default function ChangePasswordPage() { const [oldPassword, setOldPassword] = useState(""); @@ -50,8 +50,9 @@ export default function ChangePasswordPage() { await changePassword(formData); setShowSuccess(true); setTimeout(() => setShowSuccess(false), 3000); - } catch (error: any) { - alert(error.message); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : '修改密码失败'; + alert(errorMessage); } }; diff --git a/src/app/(protected)/dashboard/management/page.tsx b/src/app/(protected)/dashboard/management/page.tsx new file mode 100644 index 0000000..7bdf039 --- /dev/null +++ b/src/app/(protected)/dashboard/management/page.tsx @@ -0,0 +1,58 @@ +"use client" +import React, { useState } from "react" +import { Separator } from "@/components/ui/separator" +import ProfilePage from "./profile/page" +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()} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/(app)/management/profile/page.tsx b/src/app/(protected)/dashboard/management/profile/page.tsx similarity index 89% rename from src/app/(app)/management/profile/page.tsx rename to src/app/(protected)/dashboard/management/profile/page.tsx index 4659aba..a789024 100644 --- a/src/app/(app)/management/profile/page.tsx +++ b/src/app/(protected)/dashboard/management/profile/page.tsx @@ -2,17 +2,18 @@ "use client"; import { useEffect, useState } from "react"; -import { getUserInfo, updateUserInfo } from "@/app/(app)/management/actions"; +import { getUserInfo } from "@/app/(protected)/dashboard/management/actions/getUserInfo"; +import { updateUserInfo } from "@/app/(protected)/dashboard/management/actions/updateUserInfo"; interface User { - id: string; // TEXT 类型 - name: string | null; // 可能为空 - email: string; // NOT NULL - emailVerified: Date | null; // TIMESTAMP 转换为字符串 + id: string; + name: string | null; + email: string; + emailVerified?: Date | null; image: string | null; - role: "GUEST" | "USER" | "ADMIN"; // 枚举类型 - createdAt: Date; // TIMESTAMP 转换为字符串 - updatedAt: Date; // TIMESTAMP 转换为字符串 + role: "GUEST" | "USER" | "ADMIN" | "TEACHER"; + createdAt: Date; + updatedAt: Date; } export default function ProfilePage() { @@ -49,8 +50,9 @@ export default function ProfilePage() { const updatedUser = await updateUserInfo(formData); setUser(updatedUser); setIsEditing(false); - } catch (error: any) { - alert(error.message); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : '更新用户信息失败'; + alert(errorMessage); } }; diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index 80efbfb..16d0921 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -1,3 +1,305 @@ -export default function Page() { - return
Dashboard
+import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Users, + BookOpen, + CheckCircle, + Clock, + TrendingUp, + AlertCircle, + BarChart3, + Target, + Activity +} from "lucide-react"; +import Link from "next/link"; + +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/usermanagement/guest", icon: Users }, + { label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen }, + { label: "管理员设置", href: "/dashboard/management", icon: Target } + ] + }; + 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: "/problemset", icon: BookOpen }, + { label: "我的进度", href: "/dashboard/student/dashboard", icon: TrendingUp }, + { 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/usermanagement/_actions/problemActions.ts b/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts new file mode 100644 index 0000000..ca7ca81 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts @@ -0,0 +1,14 @@ +'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') +} \ No newline at end of file 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..e49269f --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts @@ -0,0 +1,46 @@ +'use server' +import prisma from '@/lib/prisma' +import { revalidatePath } from 'next/cache' +import bcrypt from 'bcryptjs' +import type { User } from '@/generated/client' +import { Role } 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}`) +} \ No newline at end of file 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..a182e77 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx @@ -0,0 +1,10 @@ +import ProtectedLayout from "./ProtectedLayout"; + +interface GenericLayoutProps { + children: React.ReactNode; + allowedRoles: string[]; +} + +export default function GenericLayout({ children, allowedRoles }: GenericLayoutProps) { + return {children}; +} \ No newline at end of file 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..ed7830a --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx @@ -0,0 +1,28 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +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}
; +} \ No newline at end of file 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..db50dae --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file 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..d911127 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx @@ -0,0 +1,6 @@ +import GenericPage from '@/features/user-management/components/generic-page' +import { adminConfig } from '@/features/user-management/config/admin' + +export default function AdminPage() { + return +} \ No newline at end of file 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..6c084f4 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function GuestLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file 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..5d8d1bc --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx @@ -0,0 +1,6 @@ +import GenericPage from '@/features/user-management/components/generic-page' +import { guestConfig } from '@/features/user-management/config/guest' + +export default function GuestPage() { + return +} \ No newline at end of file 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..b4a002e --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function ProblemLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file 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..e67a3d0 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx @@ -0,0 +1,6 @@ +import GenericPage from '@/features/user-management/components/generic-page' +import { problemConfig } from '@/features/user-management/config/problem' + +export default function ProblemPage() { + return +} \ No newline at end of file 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..8051801 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function TeacherLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file 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..9bccf6f --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx @@ -0,0 +1,6 @@ +import GenericPage from '@/features/user-management/components/generic-page' +import { teacherConfig } from '@/features/user-management/config/teacher' + +export default function TeacherPage() { + return +} \ No newline at end of file diff --git a/src/components/UncompletedProject/wrongbook-dialog.tsx b/src/components/UncompletedProject/wrongbook-dialog.tsx index c7edcc7..03241b3 100644 --- a/src/components/UncompletedProject/wrongbook-dialog.tsx +++ b/src/components/UncompletedProject/wrongbook-dialog.tsx @@ -8,11 +8,25 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { Check, X, Info, AlertTriangle } from "lucide-react" +import { Check, X, Info, AlertTriangle, Copy, Check as CheckIcon } from "lucide-react" import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" import Link from "next/link" -export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string }[]; children?: React.ReactNode }) { +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 ( @@ -29,7 +43,7 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string - + @@ -37,9 +51,22 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string {problems.map((item) => ( - + diff --git a/src/components/dynamic-breadcrumb.tsx b/src/components/dynamic-breadcrumb.tsx new file mode 100644 index 0000000..a52799a --- /dev/null +++ b/src/components/dynamic-breadcrumb.tsx @@ -0,0 +1,106 @@ +"use client" + +import { usePathname } from "next/navigation" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" + +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 && ( + + )} +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-form.tsx b/src/components/management-sidebar/manage-form.tsx deleted file mode 100644 index a120602..0000000 --- a/src/components/management-sidebar/manage-form.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Search } from "lucide-react" - -import { Label } from "@/components/ui/label" -import { - SidebarGroup, - SidebarGroupContent, - SidebarInput, -} from "@/components/ui/sidebar" - -export function SearchForm({ ...props }: React.ComponentProps<"form">) { - return ( -
- - - - - - - - - ) -} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-sidebar.tsx b/src/components/management-sidebar/manage-sidebar.tsx deleted file mode 100644 index fb026d3..0000000 --- a/src/components/management-sidebar/manage-sidebar.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; -import { ChevronRight } from "lucide-react"; - -import { VersionSwitcher } from "@/components//management-sidebar/manage-switcher"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarRail, -} from "@/components/ui/sidebar"; - -// 自定义数据:包含用户相关菜单项 -const data = { - versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"], - navUser: [ - { - title: "个人中心", - url: "#", - items: [ - { title: "登录信息", url: "#", key: "profile" }, - { title: "修改密码", url: "#", key: "change-password" }, - ], - }, - ], -}; - -// 显式定义 props 类型 -interface AppSidebarProps { - onItemClick?: (key: string) => void; -} - -export function AppSidebar({ onItemClick = (key: string) => {}, ...props }: AppSidebarProps) { - return ( - - - - - - {/* 渲染用户相关的侧边栏菜单 */} - {data.navUser.map((item) => ( - - - - - {item.title} - - - - - - - {item.items.map((subItem) => ( - - { - e.preventDefault(); - onItemClick(subItem.key); - }} - > - {subItem.title} - - - ))} - - - - - - ))} - - - - ); -} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-switcher.tsx b/src/components/management-sidebar/manage-switcher.tsx deleted file mode 100644 index 054995b..0000000 --- a/src/components/management-sidebar/manage-switcher.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client" - -import * as React from "react" -import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react" - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" - -export function VersionSwitcher({ - versions, - defaultVersion, -}: { - versions: string[] - defaultVersion: string -}) { - const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion) - - return ( - - - - - -
- -
-
- Documentation - v{selectedVersion} -
- -
-
- - {versions.map((version) => ( - setSelectedVersion(version)} - > - v{version}{" "} - {version === selectedVersion && } - - ))} - -
-
-
- ) -} \ No newline at end of file diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index 1d075ad..acb0c8d 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -5,13 +5,13 @@ import { Folder, MoreHorizontal, Share, - Trash2, Check, X, Info, AlertTriangle, } from "lucide-react" import React, { useState } from "react" +import { useRouter } from "next/navigation" import { Dialog, } from "@/components/ui/dialog" @@ -20,7 +20,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { @@ -42,11 +41,13 @@ export function NavProjects({ id: string name: string status: string + url?: string }[] }) { const { isMobile } = useSidebar() const [shareOpen, setShareOpen] = useState(false) const [shareLink, setShareLink] = useState("") + const router = useRouter() return ( <> @@ -56,9 +57,9 @@ export function NavProjects({ {projects.slice(0, 1).map((item) => ( - + - + - + { + e.stopPropagation() + if (item.url) { + router.push(item.url) + } else { + router.push(`/problems/${item.id}`) + } + }} + > 查看 { e.stopPropagation() - setShareLink(`${window.location.origin}/problem/${item.id}`) + setShareLink(`${window.location.origin}/problems/${item.id}`) setShareOpen(true) }} > 复制链接 - - - - 移除 - diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 1fa7a82..d915947 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -2,13 +2,14 @@ import { BadgeCheck, - Bell, + // Bell, ChevronsUpDown, UserPen, LogOut, - Sparkles, + // Sparkles, } from "lucide-react" import { useRouter } from "next/navigation" +import { signOut } from "next-auth/react" import { Avatar, @@ -44,13 +45,15 @@ export function NavUser({ const router = useRouter() async function handleLogout() { - await fetch("/api/auth/signout", { method: "POST" }); - router.replace("/sign-in"); + await signOut({ + callbackUrl: "/sign-in", + redirect: true + }); } function handleAccount() { if (user && user.email) { - router.replace("/user/profile"); + router.replace("/dashboard/management"); } else { router.replace("/sign-in"); } @@ -95,13 +98,13 @@ export function NavUser({ - + {/* Update - - + */} + {/* */} @@ -111,10 +114,10 @@ export function NavUser({ Switch User - + {/* Notifications - + */} diff --git a/src/components/sidebar/admin-sidebar.tsx b/src/components/sidebar/admin-sidebar.tsx index b5ead92..4525c75 100644 --- a/src/components/sidebar/admin-sidebar.tsx +++ b/src/components/sidebar/admin-sidebar.tsx @@ -8,7 +8,6 @@ import { } from "lucide-react" import { NavMain } from "@/components/nav-main" -import { NavProjects } from "@/components/nav-projects" import { NavSecondary } from "@/components/nav-secondary" import { NavUser } from "@/components/nav-user" import { @@ -20,8 +19,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" - -import { useEffect, useState } from "react" +import { User } from "next-auth" const adminData = { navMain: [ @@ -31,10 +29,10 @@ const adminData = { icon: Shield, isActive: true, items: [ - { title: "管理员管理", url: "/usermanagement/admin" }, - { title: "用户管理", url: "/usermanagement/guest" }, - { title: "教师管理", url: "/usermanagement/teacher" }, - { title: "题目管理", url: "/usermanagement/problem" }, + { title: "管理员管理", url: "/dashboard/usermanagement/admin" }, + { title: "用户管理", url: "/dashboard/usermanagement/guest" }, + { title: "教师管理", url: "/dashboard/usermanagement/teacher" }, + { title: "题目管理", url: "/dashboard/usermanagement/problem" }, ], }, @@ -43,38 +41,18 @@ const adminData = { { title: "帮助", url: "/", icon: LifeBuoy }, { title: "反馈", url: siteConfig.url.repo.github, icon: Send }, ], - wrongProblems: [], } -async function fetchCurrentUser() { - try { - const res = await fetch("/api/auth/session"); - if (!res.ok) return null; - const session = await res.json(); - return { - name: session?.user?.name ?? "未登录管理员", - email: session?.user?.email ?? "", - avatar: session?.user?.image ?? "/avatars/default.jpg", - }; - } catch { - return { - name: "未登录管理员", - email: "", - avatar: "/avatars/default.jpg", - }; - } +interface AdminSidebarProps { + user: User; } -export function AdminSidebar(props: React.ComponentProps) { - const [user, setUser] = useState({ - name: "未登录管理员", - email: "", - avatar: "/avatars/default.jpg", - }); - - useEffect(() => { - fetchCurrentUser().then(u => u && setUser(u)); - }, []); +export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.ComponentProps) { + const userInfo = { + name: user.name ?? "管理员", + email: user.email ?? "", + avatar: user.image ?? "/avatars/default.jpg", + }; return ( @@ -97,11 +75,10 @@ export function AdminSidebar(props: React.ComponentProps) { - - + ) diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 9038c9e..84af52c 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -3,11 +3,11 @@ import { siteConfig } from "@/config/site"; import * as React from "react"; import { - BookOpen, + // BookOpen, Command, LifeBuoy, Send, - Settings2, + // Settings2, SquareTerminal, } from "lucide-react"; @@ -26,9 +26,6 @@ import { } from "@/components/ui/sidebar"; import { User } from "next-auth"; -// import { useEffect, useState } from "react" -// import { auth, signIn } from "@/lib/auth" - const data = { navMain: [ { @@ -39,11 +36,7 @@ const data = { items: [ { title: "主页", - url: "/student/dashboard", - }, - { - title: "历史记录", - url: "#", + url: "/dashboard/student/dashboard", }, { title: "题目集", @@ -52,36 +45,36 @@ const data = { ], }, - { - title: "已完成事项", - url: "#", - icon: BookOpen, - items: [ - { - title: "全部编程集", - url: "#", - }, - { - title: "错题集", - url: "#", - }, - { - title: "收藏集", - url: "#", - }, - ], - }, - { - title: "设置", - url: "#", - icon: Settings2, - items: [ - { - title: "语言", - url: "#", - }, - ], - }, + // { + // title: "已完成事项", + // url: "#", + // icon: BookOpen, + // items: [ + // { + // title: "全部编程集", + // url: "#", + // }, + // { + // title: "错题集", + // url: "#", + // }, + // { + // title: "收藏集", + // url: "#", + // }, + // ], + // }, + // { + // title: "设置", + // url: "#", + // icon: Settings2, + // items: [ + // { + // title: "语言", + // url: "#", + // }, + // ], + // }, ], navSecondary: [ { @@ -95,59 +88,18 @@ const data = { icon: Send, }, ], - wrongProblems: [ - { - id: "abc123", - name: "Two Sum", - status: "WA", - }, - { - id: "def456", - name: "Reverse Linked List", - status: "RE", - }, - { - id: "ghi789", - name: "Binary Tree Paths", - status: "TLE", - }, - ], }; -// // 获取当前登录用户信息的 API -// async function fetchCurrentUser() { -// try { -// const res = await fetch("/api/auth/session"); -// if (!res.ok) return null; -// const session = await res.json(); -// return { -// name: session?.user?.name ?? "未登录用户", -// email: session?.user?.email ?? "", -// avatar: session?.user?.image ?? "/avatars/default.jpg", -// }; -// } catch { -// return { -// name: "未登录用户", -// email: "", -// avatar: "/avatars/default.jpg", -// }; -// } -// } - -interface AppSidebarProps{ - user:User +interface AppSidebarProps { + user: User; + wrongProblems: { + id: string; + name: string; + status: string; + }[]; } -export function AppSidebar({ user, ...props }: AppSidebarProps) { - // const [user, setUser] = useState({ - // name: "未登录用户", - // email: "", - // avatar: "/avatars/default.jpg", - // }); - - // useEffect(() => { - // fetchCurrentUser().then(u => u && setUser(u)); - // }, []); +export function AppSidebar({ user, wrongProblems, ...props }: AppSidebarProps) { const userInfo = { name: user.name ?? "", email: user.email ?? "", @@ -175,7 +127,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) { - + diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx index 2eecd71..78abc3a 100644 --- a/src/components/sidebar/teacher-sidebar.tsx +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -6,7 +6,7 @@ import { LifeBuoy, PieChart, Send, - Settings2, + // Settings2, SquareTerminal, } from "lucide-react" @@ -22,60 +22,52 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar" +import { User } from "next-auth" const data = { - user: { - name: "teacher", - email: "teacher@example.com", - avatar: "/avatars/teacher.jpg", - }, navMain: [ { - title: "教师首页", - url: "/teacher/dashboard", + title: "教师管理", + url: "#", icon: SquareTerminal, isActive: true, items: [ { - title: "学生管理", - url: "/teacher/students", + title: "用户管理", + url: "/dashboard/usermanagement/guest", }, { title: "题库管理", - url: "/teacher/problems", + url: "/dashboard/usermanagement/problem", }, ], }, { title: "统计分析", - url: "/teacher/statistics", + url: "#", icon: PieChart, items: [ { title: "完成情况", - url: "/teacher/statistics/grades", - }, - { - title: "错题统计", - url: "/teacher/statistics/activity", - }, - ], - }, - { - title: "设置", - url: "#", - icon: Settings2, - items: [ - { - title: "一般设置", - url: "/teacher/profile", - }, - { - title: "语言", - url: "/teacher/settings", + url: "/dashboard/teacher/dashboard", }, + // { + // title: "错题统计", + // url: "/dashboard/teacher/dashboard", + // }, ], }, + // { + // title: "设置", + // url: "#", + // icon: Settings2, + // items: [ + // { + // title: "语言", + // url: "#", + // }, + // ], + // }, ], navSecondary: [ { @@ -91,7 +83,17 @@ const data = { ], } -export function TeacherSidebar({ ...props }: React.ComponentProps) { +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 ( @@ -113,11 +115,10 @@ export function TeacherSidebar({ ...props }: React.ComponentProps - {/* 教师端可自定义更多内容 */} - + ) diff --git a/src/features/admin/ui/views/problem-edit-view.tsx b/src/features/admin/ui/views/problem-edit-view.tsx index 57e17a3..a9f6f22 100644 --- a/src/features/admin/ui/views/problem-edit-view.tsx +++ b/src/features/admin/ui/views/problem-edit-view.tsx @@ -1,26 +1,28 @@ -import EditCodePanel from "@/components/creater/edit-code-panel"; -import EditDetailPanel from "@/components/creater/edit-detail-panel"; -import EditSolutionPanel from "@/components/creater/edit-solution-panel"; -import EditTestcasePanel from "@/components/creater/edit-testcase-panel"; -import EditDescriptionPanel from "@/components/creater/edit-description-panel"; -import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout"; +// import EditCodePanel from "@/components/creater/edit-code-panel"; +// import EditDetailPanel from "@/components/creater/edit-detail-panel"; +// import EditSolutionPanel from "@/components/creater/edit-solution-panel"; +// import EditTestcasePanel from "@/components/creater/edit-testcase-panel"; +// import EditDescriptionPanel from "@/components/creater/edit-description-panel"; +// import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout"; -interface ProblemEditViewProps { - problemId: string; -} +// interface ProblemEditViewProps { +// problemId: string; +// } -export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => { - const components: Record = { - detail: , - description: , - solution: , - code: , - testcase: , - }; +export const ProblemEditView = ( + // { problemId }: ProblemEditViewProps +) => { + // const components: Record = { + // description: , + // solution: , + // detail: , + // code: , + // testcase: , + // }; return (
- + {/* */}
); }; 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..7063fea --- /dev/null +++ b/src/features/user-management/components/generic-page.tsx @@ -0,0 +1,21 @@ +import { UserTable } from './user-table' +import { UserConfig } from './user-table' +import prisma from '@/lib/prisma' +import type { User, Problem } from '@/generated/client' +import { Role } 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 + } +} \ No newline at end of file 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..ad5710e --- /dev/null +++ b/src/features/user-management/components/user-table.tsx @@ -0,0 +1,954 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + PlusIcon, + PencilIcon, + TrashIcon, + ListFilter, +} from "lucide-react" +import { toast } from "sonner" +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Tabs, +} from "@/components/ui/tabs" + +import { createUser, updateUser, deleteUser } from '@/app/(protected)/dashboard/usermanagement/_actions/userActions' +import { createProblem, deleteProblem } from '@/app/(protected)/dashboard/usermanagement/_actions/problemActions' +import type { User, Problem } from '@/generated/client' +import { Difficulty, Role } from '@/generated/client' + +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(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 && ( + + )} + +
+
+
+
+
ID操作 题目名称 状态
{item.id} - + + + {item.name}
+ + {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} 条记录吗?此操作不可撤销。` + : "确定要删除这条记录吗?此操作不可撤销。" + } + + + + + + + + + + + + 确认删除 + +
确定要删除该条数据吗?此操作不可撤销。
+ + + + +
+
+ + ) +} \ No newline at end of file diff --git a/src/features/user-management/config/admin.ts b/src/features/user-management/config/admin.ts new file mode 100644 index 0000000..8be5d50 --- /dev/null +++ b/src/features/user-management/config/admin.ts @@ -0,0 +1,23 @@ +import { z } from "zod" +import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config' + +// 管理员数据校验 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", + "管理员列表", + "添加管理员", + "请输入管理员姓名", + "请输入管理员邮箱" +) \ No newline at end of file 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..154ac94 --- /dev/null +++ b/src/features/user-management/config/base-config.ts @@ -0,0 +1,86 @@ +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, + } +} \ No newline at end of file diff --git a/src/features/user-management/config/guest.ts b/src/features/user-management/config/guest.ts new file mode 100644 index 0000000..f41c910 --- /dev/null +++ b/src/features/user-management/config/guest.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config' + +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", + "客户列表", + "添加客户", + "请输入客户姓名", + "请输入客户邮箱" +); \ No newline at end of file diff --git a/src/features/user-management/config/problem.ts b/src/features/user-management/config/problem.ts new file mode 100644 index 0000000..08fcec9 --- /dev/null +++ b/src/features/user-management/config/problem.ts @@ -0,0 +1,41 @@ +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 }, +}; \ No newline at end of file diff --git a/src/features/user-management/config/teacher.ts b/src/features/user-management/config/teacher.ts new file mode 100644 index 0000000..ace2564 --- /dev/null +++ b/src/features/user-management/config/teacher.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config' + +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", + "教师列表", + "添加教师", + "请输入教师姓名", + "请输入教师邮箱" +); \ No newline at end of file From 0695dd2f61cfedd9c69366f848abd04dda857077 Mon Sep 17 00:00:00 2001 From: cfngc4594 Date: Sat, 21 Jun 2025 23:19:49 +0800 Subject: [PATCH 8/9] refactor: format and relocate code --- prisma/check-problem-submissions.ts | 67 -- prisma/fill-testcase-results.ts | 37 - prisma/generate-user-data.ts | 118 --- prisma/test-student-dashboard.ts | 144 ---- prisma/test-student-data-access.ts | 79 -- src/app/(app)/problemset/layout.tsx | 2 +- .../admin/problems/[problemId]/edit/page.tsx | 17 - .../(protected)/(protect-admin)/layout.tsx | 11 - .../_actions => actions}/student-dashboard.ts | 174 ++-- .../_actions => actions}/teacher-dashboard.ts | 115 +-- .../problems/[problemId]/edit/layout.tsx | 2 +- .../admin/problems/[problemId]/edit/page.tsx | 13 + src/app/(protected)/dashboard/layout.tsx | 14 +- .../management/actions/changePassword.ts | 5 +- .../management/actions/getUserInfo.ts | 3 +- .../dashboard/management/actions/index.ts | 3 +- .../management/actions/updateUserInfo.ts | 3 +- .../management/change-password/page.tsx | 47 +- .../(protected)/dashboard/management/page.tsx | 54 +- .../dashboard/management/profile/page.tsx | 110 ++- src/app/(protected)/dashboard/page.tsx | 293 +++++-- .../student/dashboard/page.tsx | 83 +- .../teacher/dashboard/page.tsx | 93 ++- .../teacher/dashboard/test-data.tsx | 22 +- .../usermanagement/_actions/problemActions.ts | 14 - .../usermanagement/_actions/userActions.ts | 46 - .../_components/GenericLayout.tsx | 10 - .../usermanagement/actions/problemActions.ts | 17 + .../usermanagement/actions/userActions.ts | 47 ++ .../dashboard/usermanagement/admin/layout.tsx | 10 +- .../dashboard/usermanagement/admin/page.tsx | 8 +- .../components/GenericLayout.tsx | 15 + .../ProtectedLayout.tsx | 11 +- .../dashboard/usermanagement/guest/layout.tsx | 16 +- .../dashboard/usermanagement/guest/page.tsx | 8 +- .../usermanagement/problem/layout.tsx | 16 +- .../dashboard/usermanagement/problem/page.tsx | 8 +- .../usermanagement/teacher/layout.tsx | 10 +- .../dashboard/usermanagement/teacher/page.tsx | 8 +- src/app/globals.css | 41 +- src/app/layout.tsx | 3 +- .../UncompletedProject/sharedialog.tsx | 10 +- .../UncompletedProject/wrongbook-dialog.tsx | 111 ++- src/components/dashboard-button.tsx | 16 + src/components/dynamic-breadcrumb.tsx | 98 ++- src/components/nav-main.tsx | 35 +- src/components/nav-projects.tsx | 81 +- src/components/nav-secondary.tsx | 17 +- src/components/nav-user.tsx | 65 +- src/components/sidebar/admin-sidebar.tsx | 34 +- src/components/sidebar/app-sidebar.tsx | 20 +- src/components/sidebar/teacher-sidebar.tsx | 30 +- src/components/user-avatar.tsx | 2 + .../admin/ui/layouts/protected-layout.tsx | 32 + .../admin/ui/views/problem-edit-view.tsx | 38 +- .../components/generic-page.tsx | 33 +- .../user-management/components/user-table.tsx | 788 +++++++++++------- src/features/user-management/config/admin.ts | 23 +- .../user-management/config/base-config.ts | 80 +- src/features/user-management/config/guest.ts | 9 +- .../user-management/config/problem.ts | 18 +- .../user-management/config/teacher.ts | 9 +- src/lib/auth.ts | 5 - tailwind.config.ts | 162 ++-- 64 files changed, 1799 insertions(+), 1714 deletions(-) delete mode 100644 prisma/check-problem-submissions.ts delete mode 100644 prisma/fill-testcase-results.ts delete mode 100644 prisma/generate-user-data.ts delete mode 100644 prisma/test-student-dashboard.ts delete mode 100644 prisma/test-student-data-access.ts delete mode 100644 src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx delete mode 100644 src/app/(protected)/(protect-admin)/layout.tsx rename src/app/(protected)/dashboard/{(userdashboard)/_actions => actions}/student-dashboard.ts (51%) rename src/app/(protected)/dashboard/{(userdashboard)/_actions => actions}/teacher-dashboard.ts (69%) rename src/app/(protected)/{(protect-admin) => dashboard}/admin/problems/[problemId]/edit/layout.tsx (95%) create mode 100644 src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx rename src/app/(protected)/dashboard/{(userdashboard) => }/student/dashboard/page.tsx (73%) rename src/app/(protected)/dashboard/{(userdashboard) => }/teacher/dashboard/page.tsx (80%) rename src/app/(protected)/dashboard/{(userdashboard) => }/teacher/dashboard/test-data.tsx (81%) delete mode 100644 src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts delete mode 100644 src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts delete mode 100644 src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx create mode 100644 src/app/(protected)/dashboard/usermanagement/actions/problemActions.ts create mode 100644 src/app/(protected)/dashboard/usermanagement/actions/userActions.ts create mode 100644 src/app/(protected)/dashboard/usermanagement/components/GenericLayout.tsx rename src/app/(protected)/dashboard/usermanagement/{_components => components}/ProtectedLayout.tsx (80%) create mode 100644 src/components/dashboard-button.tsx create mode 100644 src/features/admin/ui/layouts/protected-layout.tsx diff --git a/prisma/check-problem-submissions.ts b/prisma/check-problem-submissions.ts deleted file mode 100644 index b3aca04..0000000 --- a/prisma/check-problem-submissions.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PrismaClient } from "@/generated/client"; - -const prisma = new PrismaClient(); - -async function checkProblemSubmissions() { - console.log("检查所有题目的提交记录情况..."); - - // 获取所有题目 - const problems = await prisma.problem.findMany({ - orderBy: { displayId: 'asc' } - }); - - console.log(`总题目数: ${problems.length}`); - - for (const problem of problems) { - // 统计该题目的提交记录 - const submissionCount = await prisma.submission.count({ - where: { problemId: problem.id } - }); - - // 统计该题目的完成情况 - const completedCount = await prisma.submission.count({ - where: { - problemId: problem.id, - status: "AC" - } - }); - - // 查询时包含 localizations - const problems = await prisma.problem.findMany({ - include: { - localizations: { - where: { type: "TITLE" }, - select: { content: true } - } - } - }); - - problems.forEach(problem => { - const title = problem.localizations[0]?.content || "无标题"; - console.log(`题目${problem.displayId} (${title}): ...`); - }); - - // 统计有提交记录的题目数量 - const problemsWithSubmissions = await prisma.problem.findMany({ - where: { - submissions: { - some: {} - } - } - }); - - console.log(`\n有提交记录的题目数量: ${problemsWithSubmissions.length}`); - console.log("有提交记录的题目编号:"); - problemsWithSubmissions.forEach(p => { - console.log(` ${p.displayId}`); - }); -} - -checkProblemSubmissions() - .catch((e) => { - console.error("检查数据时出错:", e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - })}; \ No newline at end of file diff --git a/prisma/fill-testcase-results.ts b/prisma/fill-testcase-results.ts deleted file mode 100644 index 56a1fff..0000000 --- a/prisma/fill-testcase-results.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { PrismaClient, Status } from "@/generated/client"; -const prisma = new PrismaClient(); - -async function fillTestcaseResults() { - const submissions = await prisma.submission.findMany(); - let count = 0; - for (const submission of submissions) { - const testcases = await prisma.testcase.findMany({ - where: { problemId: submission.problemId }, - }); - for (const testcase of testcases) { - // 检查是否已存在,避免重复 - const exists = await prisma.testcaseResult.findFirst({ - where: { - submissionId: submission.id, - testcaseId: testcase.id, - }, - }); - if (!exists) { - await prisma.testcaseResult.create({ - data: { - isCorrect: submission.status === Status.AC, - output: submission.status === Status.AC ? "正确答案" : "错误答案", - timeUsage: Math.floor(Math.random() * 1000) + 1, - memoryUsage: Math.floor(Math.random() * 128) + 1, - submissionId: submission.id, - testcaseId: testcase.id, - }, - }); - count++; - } - } - } - console.log(`已为 ${count} 个提交生成测试用例结果`); -} - -fillTestcaseResults().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/prisma/generate-user-data.ts b/prisma/generate-user-data.ts deleted file mode 100644 index 176c219..0000000 --- a/prisma/generate-user-data.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { PrismaClient, Status, Language } from "@/generated/client"; - -const prisma = new PrismaClient(); - -async function generateUserData() { - console.log("为 student@example.com 生成测试数据..."); - - try { - // 查找用户 - const user = await prisma.user.findUnique({ - where: { email: "student@example.com" } - }); - - if (!user) { - console.log("用户不存在,创建用户..."); - const newUser = await prisma.user.create({ - data: { - name: "测试学生", - email: "student@example.com", - password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", - role: "GUEST", - }, - }); - console.log("创建用户成功:", newUser); - return; - } - - console.log("找到用户:", user.name || user.email); - - // 获取所有已发布的题目 - const problems = await prisma.problem.findMany({ - where: { isPublished: true }, - select: { id: true, displayId: true, localizations: { - where: { locale: "en", type: "TITLE" }, - select: { content: true } - } } - }); - - console.log(`找到 ${problems.length} 道已发布题目`); - - // 为这个用户生成提交记录 - const submissionCount = Math.min(problems.length, 8); // 最多8道题目 - const selectedProblems = problems.slice(0, submissionCount); - - for (const problem of selectedProblems) { - // 为每道题目生成1-3次提交 - const attempts = Math.floor(Math.random() * 3) + 1; - - for (let i = 0; i < attempts; i++) { - // 60%概率AC,40%概率WA - const isAC = Math.random() < 0.6 || i === attempts - 1; // 最后一次提交更可能是AC - - const submission = await prisma.submission.create({ - data: { - language: Math.random() > 0.5 ? Language.c : Language.cpp, - content: `// ${user.name || user.email} 针对题目${problem.displayId}的第${i + 1}次提交`, - status: isAC ? Status.AC : Status.WA, - message: isAC ? "Accepted" : "Wrong Answer", - timeUsage: Math.floor(Math.random() * 1000) + 1, - memoryUsage: Math.floor(Math.random() * 128) + 1, - userId: user.id, - problemId: problem.id, - }, - }); - - // 获取题目的测试用例 - const testcases = await prisma.testcase.findMany({ - where: { problemId: problem.id } - }); - - // 为每个提交生成测试用例结果 - for (const testcase of testcases) { - await prisma.testcaseResult.create({ - data: { - isCorrect: isAC, - output: isAC ? "正确答案" : "错误答案", - timeUsage: Math.floor(Math.random() * 1000) + 1, - memoryUsage: Math.floor(Math.random() * 128) + 1, - submissionId: submission.id, - testcaseId: testcase.id, - }, - }); - } - - console.log(`题目${problem.displayId}: 第${i + 1}次提交 - ${isAC ? 'AC' : 'WA'}`); - - // 如果AC了,就不再继续提交这道题 - if (isAC) break; - } - } - - console.log("数据生成完成!"); - - // 验证生成的数据 - const userSubmissions = await prisma.submission.findMany({ - where: { userId: user.id }, - include: { - problem: { select: { displayId: true, localizations: { - where: { locale: "en", type: "TITLE" }, - select: { content: true } - } } } - } - }); - - console.log(`\n用户 ${user.name || user.email} 现在有 ${userSubmissions.length} 条提交记录:`); - userSubmissions.forEach((s, index) => { - const title = s.problem.localizations.find(l => l.content === "TITLE")?.content || "无标题"; - console.log(`${index + 1}. 题目${s.problem.displayId} (${title}) - ${s.status}`); -}); - - } catch (error) { - console.error("生成数据时出错:", error); - } finally { - await prisma.$disconnect(); - } -} - -generateUserData(); \ No newline at end of file diff --git a/prisma/test-student-dashboard.ts b/prisma/test-student-dashboard.ts deleted file mode 100644 index eb49d54..0000000 --- a/prisma/test-student-dashboard.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { PrismaClient } from "@/generated/client"; - -const prisma = new PrismaClient(); - -async function testStudentDashboard() { - console.log("测试学生仪表板数据获取..."); - - // 获取一个学生用户 - const student = await prisma.user.findFirst({ - where: { role: "GUEST" }, - select: { id: true, name: true, email: true } - }); - - if (!student) { - console.log("未找到学生用户,创建测试学生..."); - const newStudent = await prisma.user.create({ - data: { - name: "测试学生", - email: "test_student@example.com", - password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", - role: "GUEST", - }, - }); - console.log(`创建学生: ${newStudent.name} (${newStudent.email})`); - } - - // 获取所有已发布的题目 - const allProblems = await prisma.problem.findMany({ - where: { isPublished: true }, - select: { - id: true, - displayId: true, - difficulty: true, - localizations: { - where: { - type: "TITLE", - locale: "en" // 或者根据需求使用其他语言 - }, - select: { - content: true - } - } - } - }); - - console.log(`总题目数: ${allProblems.length}`); - - // 获取学生的所有提交记录 - const userSubmissions = await prisma.submission.findMany({ - where: { userId: student?.id }, - include: { - problem: { - select: { - id: true, - displayId: true, - difficulty: true, - localizations: { - where: { - type: "TITLE", - locale: "en" // 或者根据需求使用其他语言 - }, - select: { - content: true - } - } - } - } - } - }); - - console.log(`学生提交记录数: ${userSubmissions.length}`); - - // 计算题目完成情况 - 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); - } - }); - - // 题目完成比例数据 - const completionData = { - total: allProblems.length, - completed: completedProblems.size, - percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0, - }; - - // 错题比例数据 - const totalSubmissions = userSubmissions.length; - const wrongSubmissionsCount = userSubmissions.filter(s => s.status !== "AC").length; - const errorData = { - total: totalSubmissions, - wrong: wrongSubmissionsCount, - percentage: totalSubmissions > 0 ? Math.round((wrongSubmissionsCount / totalSubmissions) * 100) : 0, - }; - //易错题列表(按错误次数排序) - const difficultProblems = Array.from(wrongSubmissions.entries()) - .map(([problemId, errorCount]) => { - const problem = allProblems.find(p => p.id === problemId); - // 从 localizations 获取标题(英文优先) - const title = problem?.localizations?.find(l => l.content === "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); - - console.log("\n=== 学生仪表板数据 ==="); - console.log(`题目完成情况: ${completionData.completed}/${completionData.total} (${completionData.percentage}%)`); - console.log(`提交正确率: ${errorData.total - errorData.wrong}/${errorData.total} (${100 - errorData.percentage}%)`); - console.log(`易错题数量: ${difficultProblems.length}`); - - if (difficultProblems.length > 0) { - console.log("\n易错题列表:"); - difficultProblems.forEach((problem, index) => { - console.log(`${index + 1}. 题目${problem.id} (${problem.title}) - ${problem.difficulty} - 错误${problem.errorCount}次`); - }); - } - - console.log("\n测试完成!"); -} - -testStudentDashboard() - .catch((e) => { - console.error("测试学生仪表板时出错:", e); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); \ No newline at end of file diff --git a/prisma/test-student-data-access.ts b/prisma/test-student-data-access.ts deleted file mode 100644 index 3a9643f..0000000 --- a/prisma/test-student-data-access.ts +++ /dev/null @@ -1,79 +0,0 @@ -// import { PrismaClient } from "@/generated/client"; - -// const prisma = new PrismaClient(); - -// async function testStudentDataAccess() { -// console.log("测试学生数据访问..."); - -// try { -// // 1. 检查是否有学生用户 -// const students = await prisma.user.findMany({ -// where: { role: "GUEST" }, -// select: { id: true, name: true, email: true } -// }); -// console.log(`找到 ${students.length} 个学生用户:`); -// students.forEach(s => console.log(` - ${s.name} (${s.email})`)); - -// if (students.length === 0) { -// console.log("没有学生用户,创建测试学生..."); -// const testStudent = await prisma.user.create({ -// data: { -// name: "测试学生", -// email: "test_student@example.com", -// password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u", -// role: "GUEST", -// }, -// }); -// console.log(`创建学生: ${testStudent.name}`); -// } - -// // 2. 检查已发布的题目 -// const publishedProblems = await prisma.problem.findMany({ -// where: { isPublished: true }, -// select: { id: true, -// displayId: true, -// localizations: { -// where: { locale: "en", type: "TITLE" }, -// select: { content: true } -// } -// } -// }); -// console.log(`\n已发布题目数量: ${publishedProblems.length}`); -// publishedProblems.slice(0, 5).forEach((p: any) => { -// const title = p.localizations?.find((l: any) => l.type === "TITLE")?.content || "无标题"; -// console.log(` - ${p.displayId}: ${title}`); -// }); - -// // 3. 检查提交记录 -// const allSubmissions = await prisma.submission.findMany({ -// select: { id: true, userId: true, problemId: true, status: true } -// }); -// console.log(`\n总提交记录数: ${allSubmissions.length}`); - -// // 4. 检查特定学生的提交记录 -// const firstStudent = students[0] || await prisma.user.findFirst({ where: { role: "GUEST" } }); -// if (firstStudent) { -// const studentSubmissions = await prisma.submission.findMany({ -// where: { userId: firstStudent.id }, -// include: { -// problem: { select: { displayId: true, localizations: { -// where: { locale: "en", type: "TITLE" }, -// select: { content: true } -// } } } -// } -// }); -// console.log(`\n学生 ${firstStudent.name} 的提交记录数: ${studentSubmissions.length}`); -// studentSubmissions.slice(0, 3).forEach(s => { -// console.log(` - 题目${s.problem.displayId}: ${s.status}`); -// }); -// } - -// console.log("\n数据访问测试完成!"); -// } catch (error) { -// console.error("测试过程中出错:", error); -// } finally { -// await prisma.$disconnect(); -// } -// } - -// testStudentDataAccess(); \ No newline at end of file diff --git a/src/app/(app)/problemset/layout.tsx b/src/app/(app)/problemset/layout.tsx index 6866a54..0a1981f 100644 --- a/src/app/(app)/problemset/layout.tsx +++ b/src/app/(app)/problemset/layout.tsx @@ -11,4 +11,4 @@ export default function ProblemsetLayout({ children }: ProblemsetLayoutProps) { {children}
); -} \ No newline at end of file +} diff --git a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx deleted file mode 100644 index 87ae266..0000000 --- a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view"; - -// interface PageProps { -// params: Promise<{ problemId: string }>; -// } - -const Page = async ( - // { params }: PageProps -) => { - // const { problemId } = await params; - - return ; -}; - -export default Page; \ No newline at end of file diff --git a/src/app/(protected)/(protect-admin)/layout.tsx b/src/app/(protected)/(protect-admin)/layout.tsx deleted file mode 100644 index 105864e..0000000 --- a/src/app/(protected)/(protect-admin)/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { AdminProtectedLayout } from "@/features/admin/ui/layouts/admin-protected-layout"; - -interface LayoutProps { - children: React.ReactNode; -} - -const Layout = ({ children }: LayoutProps) => { - return {children}; -}; - -export default Layout; \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts b/src/app/(protected)/dashboard/actions/student-dashboard.ts similarity index 51% rename from src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts rename to src/app/(protected)/dashboard/actions/student-dashboard.ts index d54485d..8880d77 100644 --- a/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts +++ b/src/app/(protected)/dashboard/actions/student-dashboard.ts @@ -6,11 +6,11 @@ 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("未登录"); @@ -22,7 +22,7 @@ export async function getStudentDashboardData() { // 检查用户是否存在 const currentUser = await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, name: true, email: true, role: true } + select: { id: true, name: true, email: true, role: true }, }); console.log("当前用户信息:", currentUser); @@ -31,59 +31,65 @@ export async function getStudentDashboardData() { } // 获取所有已发布的题目(包含英文标题) -const allProblems = await prisma.problem.findMany({ - where: { isPublished: true }, - select: { - id: true, - displayId: true, - difficulty: true, - localizations: { - where: { - type: "TITLE", + 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 -}))); + 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 - } - } - } - } - } -}); + // 获取当前学生的所有提交记录(包含题目英文标题) + 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 -}))); + 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(); @@ -92,7 +98,7 @@ console.log("提交记录详情:", userSubmissions.map(s => ({ userSubmissions.forEach((submission) => { attemptedProblems.add(submission.problemId); - + if (submission.status === "AC") { completedProblems.add(submission.problemId); } else { @@ -110,42 +116,53 @@ console.log("提交记录详情:", userSubmissions.map(s => ({ const completionData = { total: allProblems.length, completed: completedProblems.size, - percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0, + 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)) { + 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, + 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 || "未知题目"; + const difficultProblems = Array.from(wrongSubmissions.entries()) + .map(([problemId, errorCount]) => { + const problem = allProblems.find((p) => p.id === problemId); - 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个 + // 从 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, @@ -153,7 +170,10 @@ console.log("提交记录详情:", userSubmissions.map(s => ({ difficultProblems, pieChartData: [ { name: "已完成", value: completionData.completed }, - { name: "未完成", value: completionData.total - completionData.completed }, + { + name: "未完成", + value: completionData.total - completionData.completed, + }, ], errorPieChartData: [ { name: "正确", value: errorData.total - errorData.wrong }, @@ -170,6 +190,8 @@ console.log("提交记录详情:", userSubmissions.map(s => ({ return result; } catch (error) { console.error("获取学生仪表板数据失败:", error); - throw new Error(`获取数据失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw new Error( + `获取数据失败: ${error instanceof Error ? error.message : "未知错误"}` + ); } -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts similarity index 69% rename from src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts rename to src/app/(protected)/dashboard/actions/teacher-dashboard.ts index aeb9718..874eba3 100644 --- a/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts +++ b/src/app/(protected)/dashboard/actions/teacher-dashboard.ts @@ -1,8 +1,8 @@ "use server"; import prisma from "@/lib/prisma"; -import { Locale, Status, ProblemLocalization } from "@/generated/client"; import { getLocale } from "next-intl/server"; +import { Locale, Status, ProblemLocalization } from "@/generated/client"; const getLocalizedTitle = ( localizations: ProblemLocalization[], @@ -38,32 +38,37 @@ export interface DifficultProblemData { problemDisplayId: number; } -export async function getProblemCompletionData(): Promise { +export async function getProblemCompletionData(): Promise< + ProblemCompletionData[] +> { // 获取所有提交记录,按题目分组统计 const submissions = await prisma.submission.findMany({ include: { user: true, problem: { - include:{ - localizations:true - } - } + include: { + localizations: true, + }, + }, }, }); const locale = await getLocale(); // 按题目分组统计完成情况(统计独立用户数) - const problemStats = new Map; - totalUsers: Set; - title: string; - displayId: number; - }>(); + 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 localizations = submission.problem.localizations; + const title = getLocalizedTitle(localizations, locale as Locale); const problemId = submission.problemId; const problemTitle = title; const problemDisplayId = submission.problem.displayId; @@ -71,9 +76,9 @@ export async function getProblemCompletionData(): Promise { - 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, - }; - }); + 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); + return problemDataArray.sort( + (a, b) => a.problemDisplayId - b.problemDisplayId + ); } -export async function getDifficultProblemsData(): Promise { +export async function getDifficultProblemsData(): Promise< + DifficultProblemData[] +> { // 获取所有测试用例结果 const testcaseResults = await prisma.testcaseResult.findMany({ include: { @@ -120,8 +131,8 @@ export async function getDifficultProblemsData(): Promise; - }>(); + 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 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; @@ -181,9 +195,10 @@ export async function getDifficultProblemsData(): Promise - problem.errorRate > 30 && // 错误率超过30% - problem.totalAttempts >= 3 // 至少有3次尝试 + .filter( + (problem) => + problem.errorRate > 30 && // 错误率超过30% + problem.totalAttempts >= 3 // 至少有3次尝试 ) .sort((a, b) => b.errorRate - a.errorRate) // 按错误率降序排列 .slice(0, 10); // 取前10个最难的题目 @@ -203,4 +218,4 @@ export async function getDashboardStats() { totalProblems: problemData.length, totalDifficultProblems: difficultProblems.length, }; -} \ No newline at end of file +} diff --git a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx similarity index 95% rename from src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx rename to src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx index 0667f52..fb46c63 100644 --- a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx +++ b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/layout.tsx @@ -16,4 +16,4 @@ const Layout = async ({ children, params }: LayoutProps) => { return {children}; }; -export default Layout; \ No newline at end of file +export default Layout; diff --git a/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx new file mode 100644 index 0000000..8f8302f --- /dev/null +++ b/src/app/(protected)/dashboard/admin/problems/[problemId]/edit/page.tsx @@ -0,0 +1,13 @@ +import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view"; + +interface PageProps { + params: Promise<{ problemId: string }>; +} + +const Page = async ({ params }: PageProps) => { + const { problemId } = await params; + + return ; +}; + +export default Page; diff --git a/src/app/(protected)/dashboard/layout.tsx b/src/app/(protected)/dashboard/layout.tsx index 119e21f..e403e75 100644 --- a/src/app/(protected)/dashboard/layout.tsx +++ b/src/app/(protected)/dashboard/layout.tsx @@ -1,16 +1,16 @@ -import { AppSidebar } from "@/components/sidebar/app-sidebar"; -import { AdminSidebar } from "@/components/sidebar/admin-sidebar"; -import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar"; -import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb"; -import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/components/ui/sidebar"; -import { auth } from "@/lib/auth"; 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; @@ -33,7 +33,7 @@ export default async function Layout({ children }: LayoutProps) { // 获取用户的完整信息(包括角色) const fullUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { id: true, name: true, email: true, image: true, role: true } + select: { id: true, name: true, email: true, image: true, role: true }, }); if (!fullUser) { diff --git a/src/app/(protected)/dashboard/management/actions/changePassword.ts b/src/app/(protected)/dashboard/management/actions/changePassword.ts index 554a2be..6e8ba92 100644 --- a/src/app/(protected)/dashboard/management/actions/changePassword.ts +++ b/src/app/(protected)/dashboard/management/actions/changePassword.ts @@ -1,9 +1,8 @@ -// changePassword.ts "use server"; +import bcrypt from "bcryptjs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import bcrypt from "bcryptjs"; export async function changePassword(formData: FormData) { const oldPassword = formData.get("oldPassword") as string; @@ -56,4 +55,4 @@ export async function changePassword(formData: FormData) { console.error("修改密码失败:", error); throw new Error("修改密码失败"); } -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/management/actions/getUserInfo.ts b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts index 53aacab..1916740 100644 --- a/src/app/(protected)/dashboard/management/actions/getUserInfo.ts +++ b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts @@ -1,4 +1,3 @@ -// getUserInfo.ts "use server"; import { auth } from "@/lib/auth"; @@ -37,4 +36,4 @@ export async function getUserInfo() { console.error("获取用户信息失败:", error); throw new Error("获取用户信息失败"); } -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/management/actions/index.ts b/src/app/(protected)/dashboard/management/actions/index.ts index 5c599e5..baf0cc7 100644 --- a/src/app/(protected)/dashboard/management/actions/index.ts +++ b/src/app/(protected)/dashboard/management/actions/index.ts @@ -1,4 +1,3 @@ -// index.ts export { getUserInfo } from "./getUserInfo"; export { updateUserInfo } from "./updateUserInfo"; -export { changePassword } from "./changePassword"; \ No newline at end of file +export { changePassword } from "./changePassword"; diff --git a/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts index 66edff6..e8d539b 100644 --- a/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts +++ b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts @@ -1,4 +1,3 @@ -// updateUserInfo.ts "use server"; import { auth } from "@/lib/auth"; @@ -31,4 +30,4 @@ export async function updateUserInfo(formData: FormData) { console.error("更新用户信息失败:", error); throw new Error("更新用户信息失败"); } -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/management/change-password/page.tsx b/src/app/(protected)/dashboard/management/change-password/page.tsx index b635a46..e39300a 100644 --- a/src/app/(protected)/dashboard/management/change-password/page.tsx +++ b/src/app/(protected)/dashboard/management/change-password/page.tsx @@ -1,7 +1,8 @@ -// src/app/(app)/management/change-password/page.tsx "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() { @@ -51,40 +52,46 @@ export default function ChangePasswordPage() { setShowSuccess(true); setTimeout(() => setShowSuccess(false), 3000); } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : '修改密码失败'; + const errorMessage = + error instanceof Error ? error.message : "修改密码失败"; alert(errorMessage); } }; return (
-
+

修改密码

-
+
- setOldPassword(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + 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 border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required /> {newPassword && ( -

+

密码强度: - +   {strengthLabel}

@@ -93,25 +100,27 @@ export default function ChangePasswordPage() {
- setConfirmPassword(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" + 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 && ( -

密码不一致

- )} + {newPassword && + confirmPassword && + newPassword !== confirmPassword && ( +

密码不一致

+ )}
- +
@@ -123,4 +132,4 @@ export default function ChangePasswordPage() { )}
); -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/management/page.tsx b/src/app/(protected)/dashboard/management/page.tsx index 7bdf039..5545c71 100644 --- a/src/app/(protected)/dashboard/management/page.tsx +++ b/src/app/(protected)/dashboard/management/page.tsx @@ -1,58 +1,50 @@ -"use client" -import React, { useState } from "react" -import { Separator } from "@/components/ui/separator" -import ProfilePage from "./profile/page" -import ChangePasswordPage from "./change-password/page" +"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 [activePage, setActivePage] = useState("profile"); const renderContent = () => { switch (activePage) { case "profile": - return + return ; case "change-password": - return + return ; default: - return + return ; } - } + }; return (
{/* 顶部导航栏 */}
- - {/* 页面切换按钮 */}
- - +
{/* 主体内容 */} -
- {renderContent()} -
+
{renderContent()}
- ) -} \ No newline at end of file + ); +} diff --git a/src/app/(protected)/dashboard/management/profile/page.tsx b/src/app/(protected)/dashboard/management/profile/page.tsx index a789024..9d6e319 100644 --- a/src/app/(protected)/dashboard/management/profile/page.tsx +++ b/src/app/(protected)/dashboard/management/profile/page.tsx @@ -1,7 +1,8 @@ -// src/app/(app)/management/profile/page.tsx "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"; @@ -34,33 +35,38 @@ export default function ProfilePage() { }, []); const handleSave = async () => { - const nameInput = document.getElementById("name") as HTMLInputElement | null; - const emailInput = document.getElementById("email") as HTMLInputElement | null; + const nameInput = document.getElementById( + "name" + ) as HTMLInputElement | null; + const emailInput = document.getElementById( + "email" + ) as HTMLInputElement | null; - if (!nameInput || !emailInput) { - alert("表单元素缺失"); - return; - } + if (!nameInput || !emailInput) { + alert("表单元素缺失"); + return; + } - const formData = new FormData(); - formData.append("name", nameInput.value); - formData.append("email", emailInput.value); + 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); - } -}; + 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 (
-
+

用户信息

@@ -71,82 +77,94 @@ export default function ProfilePage() {
{isEditing ? ( - ) : ( -

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

+

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

)} -

角色:{user?.role}

-

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

+

角色:{user?.role}

+

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

-
+
- -

{user.id}

+ +

{user.id}

- + {isEditing ? ( - ) : ( -

{user.email}

+

{user.email}

)}
- -

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

+ +

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

- -

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

+ +

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

{isEditing ? ( <> - - + ) : ( - + )}
); -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index 16d0921..397287a 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -1,22 +1,28 @@ -import { auth } from "@/lib/auth"; -import prisma from "@/lib/prisma"; -import { redirect } from "next/navigation"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import { - Users, - BookOpen, - CheckCircle, - Clock, - TrendingUp, +import { + Users, + BookOpen, + CheckCircle, + Clock, + TrendingUp, AlertCircle, BarChart3, Target, - Activity + Activity, } 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; @@ -37,7 +43,7 @@ interface Activity { export default async function DashboardPage() { const session = await auth(); const user = session?.user; - + if (!user) { redirect("/sign-in"); } @@ -45,7 +51,7 @@ export default async function DashboardPage() { // 获取用户的完整信息 const fullUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { id: true, name: true, email: true, image: true, role: true } + select: { id: true, name: true, email: true, image: true, role: true }, }); if (!fullUser) { @@ -58,62 +64,81 @@ export default async function DashboardPage() { 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 } - }) - ]); + 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 => ({ + recentActivity = recentUsers.map((user) => ({ type: "新用户注册", title: user.name || user.email, description: `角色: ${user.role}`, - time: user.createdAt + 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 } } - } - } - } - }) - ]); + 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 => ({ + recentActivity = recentSubmissions.map((sub) => ({ type: "学生提交", - title: `${sub.user.name || sub.user.email} 提交了题目 ${sub.problem.displayId}`, - description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`, + 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 + status: sub.status, })); } else { // 学生统计 - const [totalProblems, completedProblems, totalSubmissions, recentSubmissions] = await Promise.all([ + 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, + status: "AC", + }, }), prisma.submission.count({ where: { userId: user.id } }), prisma.submission.findMany({ @@ -121,23 +146,27 @@ export default async function DashboardPage() { take: 5, orderBy: { createdAt: "desc" }, include: { - problem: { - select: { + problem: { + select: { displayId: true, - localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } } - } - } - } - }) + localizations: { + where: { type: "TITLE", locale: "zh" }, + select: { content: true }, + }, + }, + }, + }, + }), ]); stats = { totalProblems, completedProblems, totalSubmissions }; - recentActivity = recentSubmissions.map(sub => ({ + recentActivity = recentSubmissions.map((sub) => ({ type: "我的提交", title: `题目 ${sub.problem.displayId}`, - description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`, + description: + sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`, time: sub.createdAt, - status: sub.status + status: sub.status, })); } @@ -148,52 +177,129 @@ export default async function DashboardPage() { 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" } + { + 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/usermanagement/guest", icon: Users }, - { label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen }, - { label: "管理员设置", href: "/dashboard/management", icon: Target } - ] + { + label: "用户管理", + href: "/dashboard/usermanagement/guest", + icon: Users, + }, + { + label: "题目管理", + href: "/dashboard/usermanagement/problem", + icon: BookOpen, + }, + { + label: "管理员设置", + href: "/dashboard/management", + icon: Target, + }, + ], }; 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" } + { + 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 } - ] + { + 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" } + { + 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: "/problemset", icon: BookOpen }, - { label: "我的进度", href: "/dashboard/student/dashboard", icon: TrendingUp }, - { label: "个人设置", href: "/dashboard/management", icon: Target } - ] + { + label: "我的进度", + href: "/dashboard/student/dashboard", + icon: TrendingUp, + }, + { 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; + const completionRate = + fullUser.role === "GUEST" + ? (stats.totalProblems || 0) > 0 + ? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100 + : 0 + : 0; return (
@@ -214,7 +320,9 @@ export default async function DashboardPage() { {config.stats.map((stat, index) => ( - {stat.label} + + {stat.label} + @@ -233,7 +341,8 @@ export default async function DashboardPage() { 学习进度 - 已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0} 道题目 + 已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "} + 道题目 @@ -287,7 +396,9 @@ export default async function DashboardPage() {

{activity.title}

-

{activity.description}

+

+ {activity.description} +

{new Date(activity.time).toLocaleDateString()} diff --git a/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx b/src/app/(protected)/dashboard/student/dashboard/page.tsx similarity index 73% rename from src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx rename to src/app/(protected)/dashboard/student/dashboard/page.tsx index d2d9c53..2db7818 100644 --- a/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/student/dashboard/page.tsx @@ -1,7 +1,5 @@ "use client"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; import { Table, TableBody, @@ -10,9 +8,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; import { useEffect, useState } from "react"; -import { getStudentDashboardData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard"; +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: { @@ -86,13 +86,19 @@ export default function StudentDashboard() { ); } - const { completionData, errorData, difficultProblems, pieChartData, errorPieChartData } = data; + const { + completionData, + errorData, + difficultProblems, + pieChartData, + errorPieChartData, + } = data; const COLORS = ["#4CAF50", "#FFC107"]; return (

学生仪表板

- +
{/* 题目完成比例模块 */} @@ -102,8 +108,12 @@ export default function StudentDashboard() {
- 已完成题目:{completionData.completed}/{completionData.total} - {completionData.percentage}% + + 已完成题目:{completionData.completed}/{completionData.total} + + + {completionData.percentage}% +
@@ -119,9 +129,17 @@ export default function StudentDashboard() { paddingAngle={5} dataKey="value" > - {pieChartData.map((entry: { name: string; value: number }, index: number) => ( - - ))} + {pieChartData.map( + ( + entry: { name: string; value: number }, + index: number + ) => ( + + ) + )} @@ -138,7 +156,9 @@ export default function StudentDashboard() {
- 错题数量:{errorData.wrong}/{errorData.total} + + 错题数量:{errorData.wrong}/{errorData.total} + {errorData.percentage}%
@@ -155,9 +175,17 @@ export default function StudentDashboard() { paddingAngle={5} dataKey="value" > - {errorPieChartData.map((entry: { name: string; value: number }, index: number) => ( - - ))} + {errorPieChartData.map( + ( + entry: { name: string; value: number }, + index: number + ) => ( + + ) + )} @@ -188,14 +216,21 @@ export default function StudentDashboard() { - {difficultProblems.map((problem: { id: string | number; title: string; difficulty: string; errorCount: number }) => ( - - {problem.id} - {problem.title} - {problem.difficulty} - {problem.errorCount} - - ))} + {difficultProblems.map( + (problem: { + id: string | number; + title: string; + difficulty: string; + errorCount: number; + }) => ( + + {problem.id} + {problem.title} + {problem.difficulty} + {problem.errorCount} + + ) + )} ) : ( @@ -208,4 +243,4 @@ export default function StudentDashboard() {
); -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx b/src/app/(protected)/dashboard/teacher/dashboard/page.tsx similarity index 80% rename from src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx rename to src/app/(protected)/dashboard/teacher/dashboard/page.tsx index 01f0ba1..407fa2c 100644 --- a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/teacher/dashboard/page.tsx @@ -1,10 +1,13 @@ "use client"; -import { TrendingUp } from "lucide-react"; -import { Bar, BarChart, XAxis, YAxis, LabelList, CartesianGrid } from "recharts"; -import { Button } from "@/components/ui/button"; -import { useState, useEffect } from "react"; - +import { + Bar, + BarChart, + XAxis, + YAxis, + LabelList, + CartesianGrid, +} from "recharts"; import { Card, CardContent, @@ -27,7 +30,14 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { getDashboardStats, ProblemCompletionData, DifficultProblemData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard"; +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; // 每页显示的题目数量 @@ -45,7 +55,9 @@ const chartConfig = { export default function TeacherDashboard() { const [currentPage, setCurrentPage] = useState(1); const [chartData, setChartData] = useState([]); - const [difficultProblems, setDifficultProblems] = useState([]); + const [difficultProblems, setDifficultProblems] = useState< + DifficultProblemData[] + >([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -57,8 +69,8 @@ export default function TeacherDashboard() { setChartData(data.problemData); setDifficultProblems(data.difficultProblems); } catch (err) { - setError(err instanceof Error ? err.message : '获取数据失败'); - console.error('Failed to fetch dashboard data:', err); + setError(err instanceof Error ? err.message : "获取数据失败"); + console.error("Failed to fetch dashboard data:", err); } finally { setLoading(false); } @@ -68,7 +80,7 @@ export default function TeacherDashboard() { }, []); const totalPages = Math.ceil(chartData.length / ITEMS_PER_PAGE); - + // 获取当前页的数据 const currentPageData = chartData.slice( (currentPage - 1) * ITEMS_PER_PAGE, @@ -128,9 +140,9 @@ export default function TeacherDashboard() { barCategoryGap={20} > - `${value}%`} /> } /> - - `${value}人`} + `${value}人`} /> - - `${value}人`} + `${value}人`} /> @@ -178,7 +190,9 @@ export default function TeacherDashboard() {
{difficultProblems.length === 0 ? (
-
暂无易错题数据
+
+ 暂无易错题数据 +
) : ( @@ -236,7 +254,10 @@ export default function TeacherDashboard() { {difficultProblems.map((problem) => ( - {problem.problemDisplayId || problem.id.substring(0, 8)} + + {problem.problemDisplayId || + problem.id.substring(0, 8)} + {problem.problemTitle} {problem.problemCount} @@ -250,4 +271,4 @@ export default function TeacherDashboard() { ); -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx b/src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx similarity index 81% rename from src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx rename to src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx index bf518d5..bee5f21 100644 --- a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx +++ b/src/app/(protected)/dashboard/teacher/dashboard/test-data.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { getDashboardStats } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard"; +import { getDashboardStats } from "@/app/(protected)/dashboard/actions/teacher-dashboard"; interface DashboardData { problemData: Array<{ @@ -37,8 +37,8 @@ export default function TestDataPage() { const result = await getDashboardStats(); setData(result); } catch (err) { - setError(err instanceof Error ? err.message : '获取数据失败'); - console.error('Failed to fetch data:', err); + setError(err instanceof Error ? err.message : "获取数据失败"); + console.error("Failed to fetch data:", err); } finally { setLoading(false); } @@ -58,7 +58,7 @@ export default function TestDataPage() { return (

数据测试页面

- +

题目完成数据

@@ -77,13 +77,17 @@ export default function TestDataPage() {

统计信息

-            {JSON.stringify({
-              totalProblems: data?.totalProblems,
-              totalDifficultProblems: data?.totalDifficultProblems,
-            }, null, 2)}
+            {JSON.stringify(
+              {
+                totalProblems: data?.totalProblems,
+                totalDifficultProblems: data?.totalDifficultProblems,
+              },
+              null,
+              2
+            )}
           
); -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts b/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts deleted file mode 100644 index ca7ca81..0000000 --- a/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts +++ /dev/null @@ -1,14 +0,0 @@ -'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') -} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts b/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts deleted file mode 100644 index e49269f..0000000 --- a/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts +++ /dev/null @@ -1,46 +0,0 @@ -'use server' -import prisma from '@/lib/prisma' -import { revalidatePath } from 'next/cache' -import bcrypt from 'bcryptjs' -import type { User } from '@/generated/client' -import { Role } 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}`) -} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx b/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx deleted file mode 100644 index a182e77..0000000 --- a/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import ProtectedLayout from "./ProtectedLayout"; - -interface GenericLayoutProps { - children: React.ReactNode; - allowedRoles: string[]; -} - -export default function GenericLayout({ children, allowedRoles }: GenericLayoutProps) { - return {children}; -} \ No newline at end of file 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 index db50dae..711abf1 100644 --- a/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx +++ b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx @@ -1,5 +1,9 @@ -import GenericLayout from "../_components/GenericLayout"; +import GenericLayout from "../components/GenericLayout"; -export default function AdminLayout({ children }: { children: React.ReactNode }) { +export default function AdminLayout({ + children, +}: { + children: React.ReactNode; +}) { return {children}; -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/usermanagement/admin/page.tsx b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx index d911127..96065e8 100644 --- a/src/app/(protected)/dashboard/usermanagement/admin/page.tsx +++ b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx @@ -1,6 +1,6 @@ -import GenericPage from '@/features/user-management/components/generic-page' -import { adminConfig } from '@/features/user-management/config/admin' +import { adminConfig } from "@/features/user-management/config/admin"; +import GenericPage from "@/features/user-management/components/generic-page"; export default function AdminPage() { - return -} \ No newline at end of file + 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 similarity index 80% rename from src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx rename to src/app/(protected)/dashboard/usermanagement/components/ProtectedLayout.tsx index ed7830a..b3843f1 100644 --- a/src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx +++ b/src/app/(protected)/dashboard/usermanagement/components/ProtectedLayout.tsx @@ -1,5 +1,5 @@ -import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; interface ProtectedLayoutProps { @@ -7,7 +7,10 @@ interface ProtectedLayoutProps { allowedRoles: string[]; } -export default async function ProtectedLayout({ children, allowedRoles }: ProtectedLayoutProps) { +export default async function ProtectedLayout({ + children, + allowedRoles, +}: ProtectedLayoutProps) { const session = await auth(); const userId = session?.user?.id; @@ -17,7 +20,7 @@ export default async function ProtectedLayout({ children, allowedRoles }: Protec const user = await prisma.user.findUnique({ where: { id: userId }, - select: { role: true } + select: { role: true }, }); if (!user || !allowedRoles.includes(user.role)) { @@ -25,4 +28,4 @@ export default async function ProtectedLayout({ children, allowedRoles }: Protec } return
{children}
; -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx index 6c084f4..1c4b421 100644 --- a/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx +++ b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx @@ -1,5 +1,13 @@ -import GenericLayout from "../_components/GenericLayout"; +import GenericLayout from "../components/GenericLayout"; -export default function GuestLayout({ children }: { children: React.ReactNode }) { - return {children}; -} \ No newline at end of file +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 index 5d8d1bc..3aa6a30 100644 --- a/src/app/(protected)/dashboard/usermanagement/guest/page.tsx +++ b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx @@ -1,6 +1,6 @@ -import GenericPage from '@/features/user-management/components/generic-page' -import { guestConfig } from '@/features/user-management/config/guest' +import { guestConfig } from "@/features/user-management/config/guest"; +import GenericPage from "@/features/user-management/components/generic-page"; export default function GuestPage() { - return -} \ No newline at end of file + return ; +} diff --git a/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx index b4a002e..5bd5c5a 100644 --- a/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx +++ b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx @@ -1,5 +1,13 @@ -import GenericLayout from "../_components/GenericLayout"; +import GenericLayout from "../components/GenericLayout"; -export default function ProblemLayout({ children }: { children: React.ReactNode }) { - return {children}; -} \ No newline at end of file +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 index e67a3d0..d61ac67 100644 --- a/src/app/(protected)/dashboard/usermanagement/problem/page.tsx +++ b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx @@ -1,6 +1,6 @@ -import GenericPage from '@/features/user-management/components/generic-page' -import { problemConfig } from '@/features/user-management/config/problem' +import { problemConfig } from "@/features/user-management/config/problem"; +import GenericPage from "@/features/user-management/components/generic-page"; export default function ProblemPage() { - return -} \ No newline at end of file + return ; +} diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx index 8051801..53d57cb 100644 --- a/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx +++ b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx @@ -1,5 +1,9 @@ -import GenericLayout from "../_components/GenericLayout"; +import GenericLayout from "../components/GenericLayout"; -export default function TeacherLayout({ children }: { children: React.ReactNode }) { +export default function TeacherLayout({ + children, +}: { + children: React.ReactNode; +}) { return {children}; -} \ No newline at end of file +} diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx index 9bccf6f..26393b9 100644 --- a/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx +++ b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx @@ -1,6 +1,6 @@ -import GenericPage from '@/features/user-management/components/generic-page' -import { teacherConfig } from '@/features/user-management/config/teacher' +import { teacherConfig } from "@/features/user-management/config/teacher"; +import GenericPage from "@/features/user-management/components/generic-page"; export default function TeacherPage() { - return -} \ No newline at end of file + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index a257927..26923bb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -33,13 +33,13 @@ --chart-5: 213 16% 16%; --radius: 0.5rem; --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; + --sidebar-foreground: 213 13% 6%; + --sidebar-primary: 213 13% 16%; + --sidebar-primary-foreground: 213 13% 76%; + --sidebar-accent: 0 0% 85%; + --sidebar-accent-foreground: 0 0% 25%; + --sidebar-border: 0 0% 95%; + --sidebar-ring: 213 13% 16%; } .dark { @@ -67,14 +67,14 @@ --chart-3: 216 28% 22%; --chart-4: 210 7% 28%; --chart-5: 210 20% 82%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; + --sidebar-background: 216 28% 5%; + --sidebar-foreground: 210 17% 92%; + --sidebar-primary: 210 17% 82%; + --sidebar-primary-foreground: 210 17% 22%; + --sidebar-accent: 216 28% 22%; + --sidebar-accent-foreground: 216 28% 82%; + --sidebar-border: 216 18% 12%; + --sidebar-ring: 210 17% 82%; } } @@ -119,14 +119,3 @@ code[data-theme*=" "] span { color: var(--shiki-dark); background-color: var(--shiki-dark-bg); } - - - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7726dce..d50795c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,7 +6,6 @@ import { NextIntlClientProvider } from "next-intl"; import { ThemeProvider } from "@/components/theme-provider"; import { SettingsDialog } from "@/components/settings-dialog"; - export const metadata: Metadata = { title: "Judge4c", description: @@ -38,4 +37,4 @@ export default async function RootLayout({ children }: RootLayoutProps) { ); -} \ No newline at end of file +} diff --git a/src/components/UncompletedProject/sharedialog.tsx b/src/components/UncompletedProject/sharedialog.tsx index f3fc891..c146ca5 100644 --- a/src/components/UncompletedProject/sharedialog.tsx +++ b/src/components/UncompletedProject/sharedialog.tsx @@ -1,4 +1,3 @@ -import { Button } from "@/components/ui/button" import { DialogContent, DialogDescription, @@ -6,9 +5,10 @@ import { DialogHeader, DialogTitle, DialogClose, -} from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" +} 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 ( @@ -35,5 +35,5 @@ export function ShareDialogContent({ link }: { link: string }) { - ) + ); } diff --git a/src/components/UncompletedProject/wrongbook-dialog.tsx b/src/components/UncompletedProject/wrongbook-dialog.tsx index 03241b3..da6b281 100644 --- a/src/components/UncompletedProject/wrongbook-dialog.tsx +++ b/src/components/UncompletedProject/wrongbook-dialog.tsx @@ -1,37 +1,54 @@ -"use client" +"use client"; -import * as React from "react" +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 { Check, X, Info, AlertTriangle, Copy, Check as CheckIcon } from "lucide-react" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import Link from "next/link" +} 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) +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}` + const link = `${window.location.origin}/problems/${item.id}`; try { - await navigator.clipboard.writeText(link) - setCopiedId(item.id) - setTimeout(() => setCopiedId(null), 2000) // 2秒后重置状态 + await navigator.clipboard.writeText(link); + setCopiedId(item.id); + setTimeout(() => setCopiedId(null), 2000); // 2秒后重置状态 } catch (err) { - console.error('Failed to copy link:', err) + console.error("Failed to copy link:", err); } - } + }; return ( - {children ? children : ( - + {children ? ( + children + ) : ( + )} @@ -44,13 +61,18 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
- + {problems.map((item) => ( - + @@ -74,28 +99,46 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string {(() => { if (item.status === "AC") { return ( - - {item.status} + + + {item.status} - ) + ); } else if (item.status === "WA") { return ( - - {item.status} + + + {item.status} - ) - } else if (["RE", "CE", "MLE", "TLE"].includes(item.status)) { + ); + } else if ( + ["RE", "CE", "MLE", "TLE"].includes(item.status) + ) { return ( - - {item.status} + + + {item.status} - ) + ); } else { return ( - - {item.status} + + + {item.status} - ) + ); } })()} @@ -107,5 +150,5 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string - ) + ); } 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 index a52799a..4368a2f 100644 --- a/src/components/dynamic-breadcrumb.tsx +++ b/src/components/dynamic-breadcrumb.tsx @@ -1,6 +1,5 @@ -"use client" +"use client"; -import { usePathname } from "next/navigation" import { Breadcrumb, BreadcrumbItem, @@ -8,76 +7,77 @@ import { BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" +} from "@/components/ui/breadcrumb"; +import { usePathname } from "next/navigation"; interface BreadcrumbItem { - label: string - href?: string + label: string; + href?: string; } export function DynamicBreadcrumb() { - const pathname = usePathname() + const pathname = usePathname(); const generateBreadcrumbs = (): BreadcrumbItem[] => { - const segments = pathname.split('/').filter(Boolean) - const breadcrumbs: BreadcrumbItem[] = [] + const segments = pathname.split("/").filter(Boolean); + const breadcrumbs: BreadcrumbItem[] = []; // 添加首页 - breadcrumbs.push({ label: "首页", href: "/" }) + breadcrumbs.push({ label: "首页", href: "/" }); + + let currentPath = ""; - let currentPath = "" - segments.forEach((segment, index) => { - currentPath += `/${segment}` - + currentPath += `/${segment}`; + // 根据路径段生成标签 - let label = 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': '注册', - } + 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 = "详情" + label = "详情"; } else if (pathMap[segment]) { - label = pathMap[segment] + label = pathMap[segment]; } else { // 将 kebab-case 转换为中文 label = segment - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); } // 最后一个项目不添加链接 if (index === segments.length - 1) { - breadcrumbs.push({ label }) + breadcrumbs.push({ label }); } else { - breadcrumbs.push({ label, href: currentPath }) + breadcrumbs.push({ label, href: currentPath }); } - }) + }); - return breadcrumbs - } + return breadcrumbs; + }; - const breadcrumbs = generateBreadcrumbs() + const breadcrumbs = generateBreadcrumbs(); return ( @@ -86,13 +86,9 @@ export function DynamicBreadcrumb() {
{item.href ? ( - - {item.label} - + {item.label} ) : ( - - {item.label} - + {item.label} )} {index < breadcrumbs.length - 1 && ( @@ -102,5 +98,5 @@ export function DynamicBreadcrumb() { ))} - ) -} \ No newline at end of file + ); +} diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 3481f96..8b2d99c 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -1,12 +1,5 @@ -"use client" +"use client"; -import { ChevronRight, type LucideIcon } from "lucide-react" - -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible" import { SidebarGroup, SidebarGroupLabel, @@ -17,21 +10,27 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ChevronRight, type LucideIcon } from "lucide-react"; export function NavMain({ items, }: { items: { - title: string - url: string - icon: LucideIcon - isActive?: boolean + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; items?: { - title: string - url: string - }[] - }[] + title: string; + url: string; + }[]; + }[]; }) { return ( @@ -74,5 +73,5 @@ export function NavMain({ ))} - ) + ); } diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index acb0c8d..3803013 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -1,4 +1,4 @@ -"use client" +"use client"; import { BookX, @@ -9,19 +9,7 @@ import { X, Info, AlertTriangle, -} from "lucide-react" -import React, { useState } from "react" -import { useRouter } from "next/navigation" -import { - Dialog, -} from "@/components/ui/dialog" - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" +} from "lucide-react"; import { SidebarGroup, SidebarGroupLabel, @@ -30,24 +18,33 @@ import { SidebarMenuButton, SidebarMenuItem, useSidebar, -} from "@/components/ui/sidebar" -import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog" -import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog" +} 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: { - id: string - name: string - status: string - url?: string - }[] + id: string; + name: string; + status: string; + url?: string; + }[]; }) { - const { isMobile } = useSidebar() - const [shareOpen, setShareOpen] = useState(false) - const [shareLink, setShareLink] = useState("") - const router = useRouter() + const { isMobile } = useSidebar(); + const [shareOpen, setShareOpen] = useState(false); + const [shareLink, setShareLink] = useState(""); + const router = useRouter(); return ( <> @@ -59,7 +56,7 @@ export function NavProjects({ - + {item.status} - ) + ); } else if (item.status === "WA") { return ( {item.status} - ) - } else if (["RE", "CE", "MLE", "TLE"].includes(item.status)) { + ); + } else if ( + ["RE", "CE", "MLE", "TLE"].includes(item.status) + ) { return ( {item.status} - ) + ); } else { return ( {item.status} - ) + ); } })()} @@ -114,11 +113,11 @@ export function NavProjects({ > { - e.stopPropagation() + e.stopPropagation(); if (item.url) { - router.push(item.url) + router.push(item.url); } else { - router.push(`/problems/${item.id}`) + router.push(`/problems/${item.id}`); } }} > @@ -127,9 +126,11 @@ export function NavProjects({ { - e.stopPropagation() - setShareLink(`${window.location.origin}/problems/${item.id}`) - setShareOpen(true) + e.stopPropagation(); + setShareLink( + `${window.location.origin}/problems/${item.id}` + ); + setShareOpen(true); }} > @@ -153,5 +154,5 @@ export function NavProjects({ - ) -} \ No newline at end of file + ); +} diff --git a/src/components/nav-secondary.tsx b/src/components/nav-secondary.tsx index a931a7e..1d9a13c 100644 --- a/src/components/nav-secondary.tsx +++ b/src/components/nav-secondary.tsx @@ -1,23 +1,22 @@ -import * as React from "react" -import { type LucideIcon } from "lucide-react" - +import * as React from "react"; import { SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" +} from "@/components/ui/sidebar"; +import { type LucideIcon } from "lucide-react"; export function NavSecondary({ items, ...props }: { items: { - title: string - url: string - icon: LucideIcon - }[] + title: string; + url: string; + icon: LucideIcon; + }[]; } & React.ComponentPropsWithoutRef) { return ( @@ -36,5 +35,5 @@ export function NavSecondary({ - ) + ); } diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index d915947..ad9e4aa 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -1,21 +1,11 @@ -"use client" +"use client"; import { - BadgeCheck, - // Bell, - ChevronsUpDown, - UserPen, - LogOut, - // Sparkles, -} from "lucide-react" -import { useRouter } from "next/navigation" -import { signOut } from "next-auth/react" - -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/ui/avatar" + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; import { DropdownMenu, DropdownMenuContent, @@ -24,30 +14,28 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar" +} 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 function NavUser({ user, }: { user: { - name: string - email: string - avatar: string - } + name: string; + email: string; + avatar: string; + }; }) { - const { isMobile } = useSidebar() - const router = useRouter() + const { isMobile } = useSidebar(); + const router = useRouter(); async function handleLogout() { - await signOut({ + await signOut({ callbackUrl: "/sign-in", - redirect: true + redirect: true, }); } @@ -98,26 +86,15 @@ export function NavUser({
- {/* - - - Update - - */} - {/* */} Account - router.push("/sign-in")}> + router.push("/sign-in")}> Switch User - {/* - - Notifications - */} @@ -128,5 +105,5 @@ export function NavUser({ - ) + ); } diff --git a/src/components/sidebar/admin-sidebar.tsx b/src/components/sidebar/admin-sidebar.tsx index 4525c75..26b716a 100644 --- a/src/components/sidebar/admin-sidebar.tsx +++ b/src/components/sidebar/admin-sidebar.tsx @@ -1,15 +1,6 @@ -"use client" -import { siteConfig } from "@/config/site" -import * as React from "react" -import { - LifeBuoy, - Send, - Shield, -} from "lucide-react" +"use client"; -import { NavMain } from "@/components/nav-main" -import { NavSecondary } from "@/components/nav-secondary" -import { NavUser } from "@/components/nav-user" +import * as React from "react"; import { Sidebar, SidebarContent, @@ -18,14 +9,19 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" -import { User } from "next-auth" +} 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: "#", + url: "/dashboard", icon: Shield, isActive: true, items: [ @@ -35,19 +31,21 @@ const adminData = { { title: "题目管理", url: "/dashboard/usermanagement/problem" }, ], }, - ], navSecondary: [ { title: "帮助", url: "/", icon: LifeBuoy }, { title: "反馈", url: siteConfig.url.repo.github, icon: Send }, ], -} +}; interface AdminSidebarProps { user: User; } -export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.ComponentProps) { +export function AdminSidebar({ + user, + ...props +}: AdminSidebarProps & React.ComponentProps) { const userInfo = { name: user.name ?? "管理员", email: user.email ?? "", @@ -81,5 +79,5 @@ export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.Compo - ) + ); } diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 84af52c..fcaf119 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -1,20 +1,6 @@ "use client"; -import { siteConfig } from "@/config/site"; import * as React from "react"; -import { - // BookOpen, - Command, - LifeBuoy, - Send, - // Settings2, - SquareTerminal, -} from "lucide-react"; - -import { NavMain } from "@/components/nav-main"; -import { NavProjects } from "@/components/nav-projects"; -import { NavSecondary } from "@/components/nav-secondary"; -import { NavUser } from "@/components/nav-user"; import { Sidebar, SidebarContent, @@ -25,6 +11,12 @@ import { 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, SquareTerminal } from "lucide-react"; const data = { navMain: [ diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx index 78abc3a..6338fa3 100644 --- a/src/components/sidebar/teacher-sidebar.tsx +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -1,18 +1,13 @@ -"use client" -import { siteConfig } from "@/config/site" -import * as React from "react" +"use client"; + import { Command, LifeBuoy, PieChart, Send, - // Settings2, SquareTerminal, -} from "lucide-react" - -import { NavMain } from "@/components/nav-main" -import { NavSecondary } from "@/components/nav-secondary" -import { NavUser } from "@/components/nav-user" +} from "lucide-react"; +import * as React from "react"; import { Sidebar, SidebarContent, @@ -21,8 +16,12 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" -import { User } from "next-auth" +} 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: [ @@ -81,13 +80,16 @@ const data = { icon: Send, }, ], -} +}; interface TeacherSidebarProps { user: User; } -export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.ComponentProps) { +export function TeacherSidebar({ + user, + ...props +}: TeacherSidebarProps & React.ComponentProps) { const userInfo = { name: user.name ?? "", email: user.email ?? "", @@ -121,5 +123,5 @@ export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.C - ) + ); } 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/admin/ui/layouts/protected-layout.tsx b/src/features/admin/ui/layouts/protected-layout.tsx new file mode 100644 index 0000000..e1d6b7a --- /dev/null +++ b/src/features/admin/ui/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/admin/ui/views/problem-edit-view.tsx b/src/features/admin/ui/views/problem-edit-view.tsx index a9f6f22..57e17a3 100644 --- a/src/features/admin/ui/views/problem-edit-view.tsx +++ b/src/features/admin/ui/views/problem-edit-view.tsx @@ -1,28 +1,26 @@ -// import EditCodePanel from "@/components/creater/edit-code-panel"; -// import EditDetailPanel from "@/components/creater/edit-detail-panel"; -// import EditSolutionPanel from "@/components/creater/edit-solution-panel"; -// import EditTestcasePanel from "@/components/creater/edit-testcase-panel"; -// import EditDescriptionPanel from "@/components/creater/edit-description-panel"; -// import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout"; +import EditCodePanel from "@/components/creater/edit-code-panel"; +import EditDetailPanel from "@/components/creater/edit-detail-panel"; +import EditSolutionPanel from "@/components/creater/edit-solution-panel"; +import EditTestcasePanel from "@/components/creater/edit-testcase-panel"; +import EditDescriptionPanel from "@/components/creater/edit-description-panel"; +import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout"; -// interface ProblemEditViewProps { -// problemId: string; -// } +interface ProblemEditViewProps { + problemId: string; +} -export const ProblemEditView = ( - // { problemId }: ProblemEditViewProps -) => { - // const components: Record = { - // description: , - // solution: , - // detail: , - // code: , - // testcase: , - // }; +export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => { + const components: Record = { + detail: , + description: , + solution: , + code: , + testcase: , + }; return (
- {/* */} +
); }; diff --git a/src/features/user-management/components/generic-page.tsx b/src/features/user-management/components/generic-page.tsx index 7063fea..090c63e 100644 --- a/src/features/user-management/components/generic-page.tsx +++ b/src/features/user-management/components/generic-page.tsx @@ -1,21 +1,24 @@ -import { UserTable } from './user-table' -import { UserConfig } from './user-table' -import prisma from '@/lib/prisma' -import type { User, Problem } from '@/generated/client' -import { Role } from '@/generated/client' +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 + 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 +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 + const role = userType.toUpperCase() as Role; + const data: User[] = await prisma.user.findMany({ where: { role } }); + return ; } -} \ No newline at end of file +} diff --git a/src/features/user-management/components/user-table.tsx b/src/features/user-management/components/user-table.tsx index ad5710e..2235187 100644 --- a/src/features/user-management/components/user-table.tsx +++ b/src/features/user-management/components/user-table.tsx @@ -1,6 +1,25 @@ -"use client" +"use client"; -import * as React from "react" +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, @@ -14,24 +33,15 @@ import { getPaginationRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table" +} from "@tanstack/react-table"; +import { toast } from "sonner"; import { - ChevronLeftIcon, - ChevronRightIcon, - ChevronsLeftIcon, - ChevronsRightIcon, - PlusIcon, - PencilIcon, - TrashIcon, - ListFilter, -} from "lucide-react" -import { toast } from "sonner" -import { useState, useEffect } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { useRouter } from "next/navigation" - + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Dialog, DialogContent, @@ -39,89 +49,82 @@ import { DialogFooter, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" +} from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" +} 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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" + createUser, + updateUser, + deleteUser, +} from "@/app/(protected)/dashboard/usermanagement/actions/userActions"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { - Tabs, -} from "@/components/ui/tabs" - -import { createUser, updateUser, deleteUser } from '@/app/(protected)/dashboard/usermanagement/_actions/userActions' -import { createProblem, deleteProblem } from '@/app/(protected)/dashboard/usermanagement/_actions/problemActions' -import type { User, Problem } from '@/generated/client' -import { Difficulty, Role } from '@/generated/client' + createProblem, + deleteProblem, +} from "@/app/(protected)/dashboard/usermanagement/actions/problemActions"; export interface UserConfig { - userType: string - title: string - apiPath: string + userType: string; + title: string; + apiPath: string; columns: Array<{ - key: string - label: string - sortable?: boolean - searchable?: boolean - placeholder?: string - }> + 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 }> - }> + 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 } - } + 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 - } + pageSizes: number[]; + defaultPageSize: number; + }; } type UserTableProps = | { config: UserConfig; data: User[] } - | { config: UserConfig; data: Problem[] } + | { config: UserConfig; data: Problem[] }; type UserForm = { - id?: string - name: string - email: string - password: string - createdAt: string - role: Role - image: string | null - emailVerified: Date | null -} + id?: string; + name: string; + email: string; + password: string; + createdAt: string; + role: Role; + image: string | null; + emailVerified: Date | null; +}; // 新增用户表单类型 -type AddUserForm = Omit +type AddUserForm = Omit; const addUserSchema = z.object({ name: z.string(), @@ -131,10 +134,10 @@ const addUserSchema = z.object({ image: z.string().nullable(), emailVerified: z.date().nullable(), role: z.nativeEnum(Role), -}) +}); const editUserSchema = z.object({ - id: z.string().default(''), + id: z.string().default(""), name: z.string(), email: z.string().email(), password: z.string(), @@ -142,38 +145,40 @@ const editUserSchema = z.object({ 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 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 [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(null) + }); + 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]) + setPageInput(pagination.pageIndex + 1); + }, [pagination.pageIndex]); // 表格列 const tableColumns = React.useMemo[]>(() => { @@ -183,7 +188,9 @@ export function UserTable(props: UserTableProps) { header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} + onCheckedChange={(value) => + table.toggleAllPageRowsSelected(!!value) + } aria-label="选择所有" /> ), @@ -197,41 +204,43 @@ export function UserTable(props: UserTableProps) { 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 === "displayId" && isProblem) { + return (row.original as Problem).displayId; } - if ((col.key === 'createdAt' || col.key === 'updatedAt')) { - const value = row.getValue(col.key) + if (col.key === "createdAt" || col.key === "updatedAt") { + const value = row.getValue(col.key); if (value instanceof Date) { - return value.toLocaleString() + return value.toLocaleString(); } - if (typeof value === 'string' && !isNaN(Date.parse(value))) { - return new Date(value).toLocaleString() + if (typeof value === "string" && !isNaN(Date.parse(value))) { + return new Date(value).toLocaleString(); } } - return row.getValue(col.key) + 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) - }) + 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 + const item = row.original; return (
- ) + ); }, - }) - return columns - }, [props.config, router, isProblem]) + }); + return columns; + }, [props.config, router, isProblem]); const table = useReactTable({ data: props.data, @@ -293,50 +304,77 @@ export function UserTable(props: UserTableProps) { getSortedRowModel: getSortedRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getFacetedUniqueValues(), - }) + }); // 添加用户对话框组件(仅用户) - function AddUserDialogUser({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { - const [isLoading, setIsLoading] = useState(false) + 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 }, - }) + 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 }) + form.reset({ + name: "", + email: "", + password: "", + createdAt: "", + image: null, + emailVerified: null, + role: Role.GUEST, + }); } - }, [open, form]) + }, [open, form]); async function onSubmit(data: AddUserForm) { try { - setIsLoading(true) - + setIsLoading(true); + // 验证必填字段 - if (!data.password || data.password.trim() === '') { - toast.error('密码不能为空', { duration: 1500 }) - return + 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() + }; + 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 }) + console.error("添加失败:", error); + toast.error("添加失败", { duration: 1500 }); } finally { - setIsLoading(false) + setIsLoading(false); } } return ( @@ -344,47 +382,84 @@ export function UserTable(props: UserTableProps) { {props.config.actions.add.label} - - 请填写信息,ID自动生成。 - + 请填写信息,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} -

- )} -
- ))} + {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 + } +

+ )} +
+ ))}
操作题目名称 + 题目名称 + 状态
- + {item.name}
{table.getHeaderGroups().map((headerGroup) => ( @@ -741,7 +888,7 @@ export function UserTable(props: UserTableProps) { header.getContext() )} - ) + ); })} ))} @@ -788,11 +935,13 @@ export function UserTable(props: UserTableProps) {