From 21142f9b99ac798df096221464434d993b769973 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:26:25 +0800 Subject: [PATCH] feat(app): add error/loading boundaries across all dashboard routes and new routes - Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes - Add admin elective detail and components, files, course-plans, users, scheduling boundaries - Add messages group-compose route - Add parent course-plans, elective, grades report-card, practice routes - Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries - Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar - Add auth loading, onboarding loading, api cron --- src/app/(auth)/layout.tsx | 18 +- src/app/(auth)/loading.tsx | 19 ++ src/app/(auth)/register/page.tsx | 156 +---------------- .../admin/announcements/[id]/edit/page.tsx | 50 ++++++ .../admin/announcements/[id]/page.tsx | 42 ++--- .../admin/announcements/loading.tsx | 39 +---- .../(dashboard)/admin/attendance/error.tsx | 21 +-- src/app/(dashboard)/admin/attendance/page.tsx | 86 ++++++--- .../admin/audit-logs/data-changes/error.tsx | 26 +++ .../admin/audit-logs/data-changes/loading.tsx | 16 ++ .../admin/audit-logs/data-changes/page.tsx | 27 +-- .../(dashboard)/admin/audit-logs/error.tsx | 26 +++ .../(dashboard)/admin/audit-logs/loading.tsx | 16 ++ .../admin/audit-logs/login-logs/error.tsx | 26 +++ .../admin/audit-logs/login-logs/loading.tsx | 16 ++ .../admin/audit-logs/login-logs/page.tsx | 21 ++- .../admin/audit-logs/overview/error.tsx | 26 +++ .../admin/audit-logs/overview/loading.tsx | 40 +++++ .../admin/audit-logs/overview/page.tsx | 40 +++++ src/app/(dashboard)/admin/audit-logs/page.tsx | 23 ++- .../admin/course-plans/[id]/edit/error.tsx | 20 +++ .../admin/course-plans/[id]/edit/loading.tsx | 28 +++ .../admin/course-plans/[id]/edit/page.tsx | 14 +- .../admin/course-plans/[id]/error.tsx | 20 +++ .../admin/course-plans/[id]/loading.tsx | 27 +++ .../admin/course-plans/[id]/page.tsx | 8 +- .../admin/course-plans/create/error.tsx | 20 +++ .../admin/course-plans/create/loading.tsx | 28 +++ .../admin/course-plans/create/page.tsx | 11 +- .../(dashboard)/admin/course-plans/error.tsx | 20 +++ .../admin/course-plans/loading.tsx | 21 +++ .../(dashboard)/admin/course-plans/page.tsx | 15 +- .../admin/curriculum-map/error.tsx | 20 +++ .../admin/curriculum-map/loading.tsx | 20 +++ .../(dashboard)/admin/curriculum-map/page.tsx | 59 +++++++ .../admin/elective/[id]/loading.tsx | 38 ++++ .../(dashboard)/admin/elective/[id]/page.tsx | 47 +++++ .../_components/course-list-loader.tsx | 34 ++++ .../_components/stats-cards-loader.tsx | 12 ++ src/app/(dashboard)/admin/elective/error.tsx | 6 +- .../(dashboard)/admin/elective/loading.tsx | 74 ++++++-- src/app/(dashboard)/admin/elective/page.tsx | 35 ++-- src/app/(dashboard)/admin/error-book/page.tsx | 49 ++++-- src/app/(dashboard)/admin/files/error.tsx | 33 ++++ src/app/(dashboard)/admin/files/loading.tsx | 54 ++++++ .../admin/invitation-codes/error.tsx | 25 +++ .../admin/invitation-codes/loading.tsx | 42 +++++ .../admin/invitation-codes/page.tsx | 32 ++++ .../admin/lesson-plans/[planId]/view/page.tsx | 27 ++- .../(dashboard)/admin/lesson-plans/page.tsx | 33 ++-- .../(dashboard)/admin/permissions/error.tsx | 22 +++ .../(dashboard)/admin/permissions/loading.tsx | 30 ++++ .../(dashboard)/admin/permissions/page.tsx | 37 ++++ src/app/(dashboard)/admin/questions/error.tsx | 29 ++++ .../(dashboard)/admin/questions/loading.tsx | 29 ++++ src/app/(dashboard)/admin/questions/page.tsx | 138 +++++++++++++++ .../(dashboard)/admin/roles/[id]/error.tsx | 22 +++ .../(dashboard)/admin/roles/[id]/loading.tsx | 34 ++++ src/app/(dashboard)/admin/roles/[id]/page.tsx | 94 ++++++++++ src/app/(dashboard)/admin/roles/error.tsx | 22 +++ src/app/(dashboard)/admin/roles/loading.tsx | 35 ++++ src/app/(dashboard)/admin/roles/page.tsx | 37 ++++ .../(dashboard)/admin/school/classes/page.tsx | 7 +- .../(dashboard)/admin/school/grades/error.tsx | 16 +- .../admin/school/grades/insights/error.tsx | 11 +- .../admin/school/grades/insights/loading.tsx | 3 +- .../admin/school/grades/loading.tsx | 3 +- src/app/(dashboard)/admin/users/error.tsx | 22 +++ src/app/(dashboard)/admin/users/loading.tsx | 41 +++++ src/app/(dashboard)/admin/users/page.tsx | 11 +- .../(dashboard)/announcements/[id]/page.tsx | 28 +-- src/app/(dashboard)/announcements/loading.tsx | 36 +--- src/app/(dashboard)/announcements/page.tsx | 130 +++++--------- src/app/(dashboard)/dashboard/page.tsx | 2 +- src/app/(dashboard)/layout.tsx | 29 +--- .../management/grade/dashboard/page.tsx | 93 +++++----- .../management/grade/practice/error.tsx | 28 ++- src/app/(dashboard)/messages/[id]/page.tsx | 28 ++- src/app/(dashboard)/messages/compose/page.tsx | 30 +++- .../messages/group-compose/error.tsx | 24 +++ .../messages/group-compose/loading.tsx | 40 +++++ .../messages/group-compose/page.tsx | 52 ++++++ src/app/(dashboard)/messages/page.tsx | 59 ++++++- .../(dashboard)/parent/attendance/error.tsx | 21 +-- .../(dashboard)/parent/attendance/page.tsx | 51 +++++- .../parent/course-plans/[id]/error.tsx | 20 +++ .../parent/course-plans/[id]/loading.tsx | 23 +++ .../parent/course-plans/[id]/page.tsx | 45 +++++ .../(dashboard)/parent/course-plans/error.tsx | 20 +++ .../parent/course-plans/loading.tsx | 17 ++ .../(dashboard)/parent/course-plans/page.tsx | 45 +++++ src/app/(dashboard)/parent/dashboard/page.tsx | 65 ++++++- .../(dashboard)/parent/diagnostic/error.tsx | 8 +- .../(dashboard)/parent/diagnostic/page.tsx | 10 +- src/app/(dashboard)/parent/elective/error.tsx | 24 +++ .../(dashboard)/parent/elective/loading.tsx | 31 ++++ src/app/(dashboard)/parent/elective/page.tsx | 81 +++++++++ .../(dashboard)/parent/error-book/page.tsx | 133 +++++++------- src/app/(dashboard)/parent/grades/error.tsx | 11 +- src/app/(dashboard)/parent/grades/loading.tsx | 3 +- src/app/(dashboard)/parent/grades/page.tsx | 38 ++-- .../parent/grades/report-card/error.tsx | 35 ++++ .../parent/grades/report-card/loading.tsx | 12 ++ .../parent/grades/report-card/page.tsx | 116 +++++++++++++ src/app/(dashboard)/parent/leave/page.tsx | 136 ++++++++++----- .../lesson-plans/[planId]/view/page.tsx | 44 +++-- .../(dashboard)/parent/lesson-plans/page.tsx | 41 +++-- src/app/(dashboard)/parent/practice/error.tsx | 30 ++++ .../(dashboard)/parent/practice/loading.tsx | 33 ++++ src/app/(dashboard)/parent/practice/page.tsx | 127 ++++++++++++++ src/app/(dashboard)/profile/page.tsx | 24 +-- src/app/(dashboard)/settings/page.tsx | 5 +- .../(dashboard)/student/attendance/error.tsx | 21 +-- .../(dashboard)/student/attendance/page.tsx | 8 +- .../student/course-plans/[id]/error.tsx | 20 +++ .../student/course-plans/[id]/loading.tsx | 23 +++ .../student/course-plans/[id]/page.tsx | 42 +++++ .../student/course-plans/error.tsx | 20 +++ .../student/course-plans/loading.tsx | 17 ++ .../(dashboard)/student/course-plans/page.tsx | 41 +++++ .../(dashboard)/student/diagnostic/page.tsx | 2 +- .../student/elective/[id]/loading.tsx | 28 +++ .../student/elective/[id]/page.tsx | 47 +++++ .../_components/available-courses-loader.tsx | 51 ++++++ .../_components/my-selections-loader.tsx | 17 ++ .../(dashboard)/student/elective/error.tsx | 4 +- .../(dashboard)/student/elective/loading.tsx | 50 +++++- src/app/(dashboard)/student/elective/page.tsx | 43 ++--- .../add-error-book-dialog-with-questions.tsx | 32 ++++ .../(dashboard)/student/error-book/page.tsx | 64 +++---- .../student-error-book-list-client.tsx | 120 +++++++++++++ src/app/(dashboard)/student/grades/error.tsx | 3 +- .../(dashboard)/student/grades/loading.tsx | 3 +- src/app/(dashboard)/student/grades/page.tsx | 19 +- .../student/grades/report-card/error.tsx | 38 ++++ .../student/grades/report-card/loading.tsx | 15 ++ .../student/grades/report-card/page.tsx | 96 ++++++++++ .../assignments/[assignmentId]/page.tsx | 2 +- .../[assignmentId]/result/error.tsx | 28 +++ .../[assignmentId]/result/loading.tsx | 22 +++ .../[assignmentId]/result/page.tsx | 3 +- .../student/learning/assignments/error.tsx | 28 +++ .../student/learning/assignments/page.tsx | 2 +- .../learning/courses/[classId]/error.tsx | 13 ++ .../learning/courses/[classId]/page.tsx | 12 ++ .../student/learning/courses/error.tsx | 13 ++ .../student/learning/courses/page.tsx | 12 ++ .../(dashboard)/student/learning/error.tsx | 13 ++ .../(dashboard)/student/learning/loading.tsx | 34 ++++ src/app/(dashboard)/student/learning/page.tsx | 14 +- .../student/learning/study-path/error.tsx | 13 ++ .../student/learning/study-path/loading.tsx | 30 ++++ .../student/learning/study-path/page.tsx | 50 ++++++ .../student/learning/textbooks/[id]/error.tsx | 22 +-- .../learning/textbooks/[id]/loading.tsx | 7 +- .../student/learning/textbooks/[id]/page.tsx | 7 +- .../student/learning/textbooks/error.tsx | 22 +-- .../student/learning/textbooks/loading.tsx | 25 ++- .../student/learning/textbooks/page.tsx | 7 +- src/app/(dashboard)/student/leave/error.tsx | 7 + src/app/(dashboard)/student/leave/loading.tsx | 25 +++ src/app/(dashboard)/student/leave/page.tsx | 87 ++++++++++ .../lesson-plans/[planId]/view/page.tsx | 44 +++-- .../(dashboard)/student/lesson-plans/page.tsx | 41 +++-- .../student/practice/[sessionId]/page.tsx | 9 +- src/app/(dashboard)/student/practice/page.tsx | 41 +++-- .../(dashboard)/student/schedule/error.tsx | 13 ++ src/app/(dashboard)/student/schedule/page.tsx | 12 ++ .../(dashboard)/teacher/attendance/error.tsx | 21 +-- .../(dashboard)/teacher/attendance/page.tsx | 15 +- .../teacher/attendance/report/error.tsx | 7 + .../teacher/attendance/report/loading.tsx | 35 ++++ .../teacher/attendance/report/page.tsx | 77 ++++++++ .../teacher/attendance/sheet/error.tsx | 7 + .../teacher/attendance/sheet/page.tsx | 3 + .../teacher/attendance/stats/error.tsx | 7 + .../teacher/attendance/stats/page.tsx | 45 +++-- .../teacher/classes/my/[id]/error.tsx | 13 ++ .../teacher/classes/my/[id]/loading.tsx | 41 +++++ .../teacher/classes/my/[id]/page.tsx | 81 +++++---- .../(dashboard)/teacher/classes/my/error.tsx | 13 ++ .../(dashboard)/teacher/classes/my/page.tsx | 15 +- .../teacher/classes/schedule/error.tsx | 13 ++ .../teacher/classes/schedule/page.tsx | 32 +++- .../teacher/classes/students/error.tsx | 13 ++ .../teacher/classes/students/page.tsx | 36 ++-- .../teacher/course-plans/[id]/error.tsx | 20 +++ .../teacher/course-plans/[id]/loading.tsx | 27 +++ .../teacher/course-plans/[id]/page.tsx | 11 +- .../teacher/course-plans/error.tsx | 20 +++ .../teacher/course-plans/loading.tsx | 21 +++ .../(dashboard)/teacher/course-plans/page.tsx | 31 ++-- .../diagnostic/class/[classId]/error.tsx | 8 +- .../diagnostic/class/[classId]/page.tsx | 31 +++- .../(dashboard)/teacher/diagnostic/error.tsx | 8 +- .../(dashboard)/teacher/diagnostic/page.tsx | 63 ++++--- .../diagnostic/student/[studentId]/error.tsx | 8 +- .../diagnostic/student/[studentId]/page.tsx | 12 +- .../teacher/elective/[id]/edit/loading.tsx | 27 +++ .../teacher/elective/[id]/edit/page.tsx | 52 ++++++ .../teacher/elective/create/loading.tsx | 27 +++ .../teacher/elective/create/page.tsx | 36 ++++ .../(dashboard)/teacher/elective/error.tsx | 6 +- src/app/(dashboard)/teacher/elective/page.tsx | 4 +- .../(dashboard)/teacher/error-book/page.tsx | 132 ++++++++------ .../teacher/exams/[id]/analytics/error.tsx | 24 +++ .../teacher/exams/[id]/analytics/loading.tsx | 22 +++ .../teacher/exams/[id]/analytics/page.tsx | 3 + .../teacher/exams/[id]/build/page.tsx | 39 +---- .../teacher/exams/[id]/edit-rich/error.tsx | 24 +++ .../teacher/exams/[id]/edit-rich/loading.tsx | 16 ++ .../teacher/exams/[id]/edit-rich/page.tsx | 80 +++++++++ .../teacher/exams/[id]/proctoring/page.tsx | 8 +- .../(dashboard)/teacher/exams/all/error.tsx | 24 +++ .../(dashboard)/teacher/exams/all/page.tsx | 7 +- .../teacher/exams/create/error.tsx | 24 +++ .../(dashboard)/teacher/exams/create/page.tsx | 3 + .../(dashboard)/teacher/exams/new/error.tsx | 24 +++ .../(dashboard)/teacher/exams/new/loading.tsx | 16 ++ .../(dashboard)/teacher/exams/new/page.tsx | 3 + .../teacher/grades/analytics/loading.tsx | 3 +- .../teacher/grades/analytics/page.tsx | 149 +++++++++------- .../teacher/grades/entry/loading.tsx | 3 +- .../(dashboard)/teacher/grades/entry/page.tsx | 12 +- src/app/(dashboard)/teacher/grades/error.tsx | 11 +- .../(dashboard)/teacher/grades/loading.tsx | 3 +- src/app/(dashboard)/teacher/grades/page.tsx | 49 +++--- .../teacher/grades/report-card/error.tsx | 35 ++++ .../teacher/grades/report-card/loading.tsx | 12 ++ .../teacher/grades/report-card/page.tsx | 116 +++++++++++++ .../teacher/grades/stats/loading.tsx | 3 +- .../(dashboard)/teacher/grades/stats/page.tsx | 24 +-- .../homework/assignments/[id]/page.tsx | 53 ++++-- .../assignments/[id]/submissions/page.tsx | 7 +- .../homework/assignments/create/page.tsx | 12 +- .../teacher/homework/assignments/error.tsx | 28 +++ .../teacher/homework/assignments/loading.tsx | 27 +++ .../teacher/homework/assignments/page.tsx | 5 +- .../(dashboard)/teacher/homework/error.tsx | 28 +++ .../(dashboard)/teacher/homework/loading.tsx | 17 ++ .../submissions/[submissionId]/error.tsx | 28 +++ .../submissions/[submissionId]/loading.tsx | 30 ++++ .../submissions/[submissionId]/page.tsx | 42 +---- .../[submissionId]/scan-grading/error.tsx | 28 +++ .../[submissionId]/scan-grading/loading.tsx | 24 +++ .../[submissionId]/scan-grading/page.tsx | 3 + .../teacher/homework/submissions/error.tsx | 28 +++ .../teacher/homework/submissions/loading.tsx | 24 +++ .../teacher/homework/submissions/page.tsx | 33 ++-- src/app/(dashboard)/teacher/leave/error.tsx | 7 + src/app/(dashboard)/teacher/leave/loading.tsx | 26 +++ src/app/(dashboard)/teacher/leave/page.tsx | 80 +++++++++ .../edit/ai-content-generator-slot.tsx | 27 +++ .../lesson-plans/[planId]/edit/page.tsx | 57 ++---- .../teacher/lesson-plans/calendar/error.tsx | 20 +++ .../teacher/lesson-plans/calendar/loading.tsx | 35 ++++ .../teacher/lesson-plans/calendar/page.tsx | 34 ++++ .../teacher/lesson-plans/new/page.tsx | 4 + .../(dashboard)/teacher/lesson-plans/page.tsx | 6 +- .../(dashboard)/teacher/practice/error.tsx | 28 ++- src/app/(dashboard)/teacher/practice/page.tsx | 21 +-- .../(dashboard)/teacher/questions/error.tsx | 29 ++++ .../(dashboard)/teacher/questions/page.tsx | 89 +++++----- .../teacher/textbooks/[id]/error.tsx | 22 +-- .../teacher/textbooks/[id]/loading.tsx | 75 ++++---- .../(dashboard)/teacher/textbooks/error.tsx | 22 +-- .../(dashboard)/teacher/textbooks/loading.tsx | 7 +- src/app/(onboarding)/loading.tsx | 42 +++++ src/app/(onboarding)/onboarding/page.tsx | 29 ++-- src/app/api/ai/chat/route.ts | 164 +++++++++++++++++- src/app/api/ai/chat/stream/route.ts | 2 +- src/app/api/cron/audit-cleanup/route.ts | 78 +++++++++ src/app/api/export/route.ts | 7 +- src/app/api/files/[id]/route.ts | 73 ++++---- src/app/api/files/batch-delete/route.ts | 49 +++--- src/app/api/notifications/stream/route.ts | 13 +- src/app/api/rate-limit-test/route.ts | 4 +- src/app/api/upload/route.ts | 128 ++++---------- src/app/globals.css | 64 +++++++ src/app/layout.tsx | 19 +- 280 files changed, 7137 insertions(+), 1855 deletions(-) create mode 100644 src/app/(auth)/loading.tsx create mode 100644 src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/data-changes/loading.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/error.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/loading.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/login-logs/error.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/login-logs/loading.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/overview/error.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/overview/loading.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/overview/page.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/error.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/loading.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/create/error.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/create/loading.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/error.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/loading.tsx create mode 100644 src/app/(dashboard)/admin/curriculum-map/error.tsx create mode 100644 src/app/(dashboard)/admin/curriculum-map/loading.tsx create mode 100644 src/app/(dashboard)/admin/curriculum-map/page.tsx create mode 100644 src/app/(dashboard)/admin/elective/[id]/loading.tsx create mode 100644 src/app/(dashboard)/admin/elective/[id]/page.tsx create mode 100644 src/app/(dashboard)/admin/elective/_components/course-list-loader.tsx create mode 100644 src/app/(dashboard)/admin/elective/_components/stats-cards-loader.tsx create mode 100644 src/app/(dashboard)/admin/files/error.tsx create mode 100644 src/app/(dashboard)/admin/files/loading.tsx create mode 100644 src/app/(dashboard)/admin/invitation-codes/error.tsx create mode 100644 src/app/(dashboard)/admin/invitation-codes/loading.tsx create mode 100644 src/app/(dashboard)/admin/invitation-codes/page.tsx create mode 100644 src/app/(dashboard)/admin/permissions/error.tsx create mode 100644 src/app/(dashboard)/admin/permissions/loading.tsx create mode 100644 src/app/(dashboard)/admin/permissions/page.tsx create mode 100644 src/app/(dashboard)/admin/questions/error.tsx create mode 100644 src/app/(dashboard)/admin/questions/loading.tsx create mode 100644 src/app/(dashboard)/admin/questions/page.tsx create mode 100644 src/app/(dashboard)/admin/roles/[id]/error.tsx create mode 100644 src/app/(dashboard)/admin/roles/[id]/loading.tsx create mode 100644 src/app/(dashboard)/admin/roles/[id]/page.tsx create mode 100644 src/app/(dashboard)/admin/roles/error.tsx create mode 100644 src/app/(dashboard)/admin/roles/loading.tsx create mode 100644 src/app/(dashboard)/admin/roles/page.tsx create mode 100644 src/app/(dashboard)/admin/users/error.tsx create mode 100644 src/app/(dashboard)/admin/users/loading.tsx create mode 100644 src/app/(dashboard)/messages/group-compose/error.tsx create mode 100644 src/app/(dashboard)/messages/group-compose/loading.tsx create mode 100644 src/app/(dashboard)/messages/group-compose/page.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/[id]/error.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/[id]/loading.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/[id]/page.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/error.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/loading.tsx create mode 100644 src/app/(dashboard)/parent/course-plans/page.tsx create mode 100644 src/app/(dashboard)/parent/elective/error.tsx create mode 100644 src/app/(dashboard)/parent/elective/loading.tsx create mode 100644 src/app/(dashboard)/parent/elective/page.tsx create mode 100644 src/app/(dashboard)/parent/grades/report-card/error.tsx create mode 100644 src/app/(dashboard)/parent/grades/report-card/loading.tsx create mode 100644 src/app/(dashboard)/parent/grades/report-card/page.tsx create mode 100644 src/app/(dashboard)/parent/practice/error.tsx create mode 100644 src/app/(dashboard)/parent/practice/loading.tsx create mode 100644 src/app/(dashboard)/parent/practice/page.tsx create mode 100644 src/app/(dashboard)/student/course-plans/[id]/error.tsx create mode 100644 src/app/(dashboard)/student/course-plans/[id]/loading.tsx create mode 100644 src/app/(dashboard)/student/course-plans/[id]/page.tsx create mode 100644 src/app/(dashboard)/student/course-plans/error.tsx create mode 100644 src/app/(dashboard)/student/course-plans/loading.tsx create mode 100644 src/app/(dashboard)/student/course-plans/page.tsx create mode 100644 src/app/(dashboard)/student/elective/[id]/loading.tsx create mode 100644 src/app/(dashboard)/student/elective/[id]/page.tsx create mode 100644 src/app/(dashboard)/student/elective/_components/available-courses-loader.tsx create mode 100644 src/app/(dashboard)/student/elective/_components/my-selections-loader.tsx create mode 100644 src/app/(dashboard)/student/error-book/add-error-book-dialog-with-questions.tsx create mode 100644 src/app/(dashboard)/student/error-book/student-error-book-list-client.tsx create mode 100644 src/app/(dashboard)/student/grades/report-card/error.tsx create mode 100644 src/app/(dashboard)/student/grades/report-card/loading.tsx create mode 100644 src/app/(dashboard)/student/grades/report-card/page.tsx create mode 100644 src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/error.tsx create mode 100644 src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/loading.tsx create mode 100644 src/app/(dashboard)/student/learning/assignments/error.tsx create mode 100644 src/app/(dashboard)/student/learning/courses/[classId]/error.tsx create mode 100644 src/app/(dashboard)/student/learning/courses/error.tsx create mode 100644 src/app/(dashboard)/student/learning/error.tsx create mode 100644 src/app/(dashboard)/student/learning/loading.tsx create mode 100644 src/app/(dashboard)/student/learning/study-path/error.tsx create mode 100644 src/app/(dashboard)/student/learning/study-path/loading.tsx create mode 100644 src/app/(dashboard)/student/learning/study-path/page.tsx create mode 100644 src/app/(dashboard)/student/leave/error.tsx create mode 100644 src/app/(dashboard)/student/leave/loading.tsx create mode 100644 src/app/(dashboard)/student/leave/page.tsx create mode 100644 src/app/(dashboard)/student/schedule/error.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/report/error.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/report/loading.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/report/page.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/sheet/error.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/stats/error.tsx create mode 100644 src/app/(dashboard)/teacher/classes/my/[id]/error.tsx create mode 100644 src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx create mode 100644 src/app/(dashboard)/teacher/classes/my/error.tsx create mode 100644 src/app/(dashboard)/teacher/classes/schedule/error.tsx create mode 100644 src/app/(dashboard)/teacher/classes/students/error.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/[id]/error.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/error.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/loading.tsx create mode 100644 src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx create mode 100644 src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx create mode 100644 src/app/(dashboard)/teacher/elective/create/loading.tsx create mode 100644 src/app/(dashboard)/teacher/elective/create/page.tsx create mode 100644 src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx create mode 100644 src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx create mode 100644 src/app/(dashboard)/teacher/exams/[id]/edit-rich/error.tsx create mode 100644 src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx create mode 100644 src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx create mode 100644 src/app/(dashboard)/teacher/exams/all/error.tsx create mode 100644 src/app/(dashboard)/teacher/exams/create/error.tsx create mode 100644 src/app/(dashboard)/teacher/exams/new/error.tsx create mode 100644 src/app/(dashboard)/teacher/exams/new/loading.tsx create mode 100644 src/app/(dashboard)/teacher/grades/report-card/error.tsx create mode 100644 src/app/(dashboard)/teacher/grades/report-card/loading.tsx create mode 100644 src/app/(dashboard)/teacher/grades/report-card/page.tsx create mode 100644 src/app/(dashboard)/teacher/homework/assignments/error.tsx create mode 100644 src/app/(dashboard)/teacher/homework/assignments/loading.tsx create mode 100644 src/app/(dashboard)/teacher/homework/error.tsx create mode 100644 src/app/(dashboard)/teacher/homework/loading.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/[submissionId]/error.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/[submissionId]/loading.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/error.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/[submissionId]/scan-grading/loading.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/error.tsx create mode 100644 src/app/(dashboard)/teacher/homework/submissions/loading.tsx create mode 100644 src/app/(dashboard)/teacher/leave/error.tsx create mode 100644 src/app/(dashboard)/teacher/leave/loading.tsx create mode 100644 src/app/(dashboard)/teacher/leave/page.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/ai-content-generator-slot.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/calendar/error.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/calendar/loading.tsx create mode 100644 src/app/(dashboard)/teacher/lesson-plans/calendar/page.tsx create mode 100644 src/app/(dashboard)/teacher/questions/error.tsx create mode 100644 src/app/(onboarding)/loading.tsx create mode 100644 src/app/api/cron/audit-cleanup/route.ts diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index fdcd0ae..9dc8933 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -1,5 +1,19 @@ import { AuthLayout } from "@/modules/auth/components/auth-layout" +import { getBrandConfig } from "@/modules/settings/data-access-brand" -export default function Layout({ children }: { children: React.ReactNode }) { - return {children} +/** + * 认证页面布局(audit-P2-6: 注入品牌配置) + * + * Server Component:在渲染前从 system_settings 表读取品牌配置, + * 失败时 AuthLayout 使用 DEFAULT_BRAND_CONFIG 默认值。 + */ +export default async function Layout({ children }: { children: React.ReactNode }) { + let brand + try { + brand = await getBrandConfig() + } catch { + // 数据库不可用时使用默认品牌配置,不阻断认证页面渲染 + } + + return {children} } diff --git a/src/app/(auth)/loading.tsx b/src/app/(auth)/loading.tsx new file mode 100644 index 0000000..2c3676d --- /dev/null +++ b/src/app/(auth)/loading.tsx @@ -0,0 +1,19 @@ +import { Loader2 } from "lucide-react" + +/** + * 认证路由组 loading.tsx(audit-P1-5 新增) + * + * 在 (auth) 路由组下的页面(login/register/privacy/terms)进行服务端渲染时 + * 展示的骨架屏,避免白屏闪烁。 + */ +export default function AuthLoading() { + return ( +
+ +
+ ) +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 5d7c0e3..49318b0 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,167 +1,13 @@ import { Metadata } from "next" -import { hash } from "bcryptjs" -import { createId } from "@paralleldrive/cuid2" -import { eq } from "drizzle-orm" -import type { ActionState } from "@/shared/types/action-state" import { RegisterForm } from "@/modules/auth/components/register-form" +import { registerAction } from "@/modules/auth/actions" export const metadata: Metadata = { title: "Register - Next_Edu", description: "Create an account", } -const ADULT_AGE = 18 - -const normalizeBcryptHash = (value: string) => { - if (value.startsWith("$2")) return value - if (value.startsWith("$")) return `$2b${value}` - return `$2b$${value}` -} - -function calcAge(birth: string): number | null { - if (!birth) return null - const birthDate = new Date(birth) - if (Number.isNaN(birthDate.getTime())) return null - const now = new Date() - let age = now.getFullYear() - birthDate.getFullYear() - const monthDiff = now.getMonth() - birthDate.getMonth() - if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) { - age -= 1 - } - return age >= 0 ? age : null -} - export default function RegisterPage() { - async function registerAction(formData: FormData): Promise { - "use server" - - const databaseUrl = process.env.DATABASE_URL - if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" } - - try { - const [{ db }, { roles, users, usersToRoles }] = await Promise.all([ - import("@/shared/db"), - import("@/shared/db/schema"), - ]) - - const name = String(formData.get("name") ?? "").trim() - const email = String(formData.get("email") ?? "").trim().toLowerCase() - const password = String(formData.get("password") ?? "") - const birthDateRaw = String(formData.get("birthDate") ?? "").trim() - const guardianName = String(formData.get("guardianName") ?? "").trim() - const guardianPhone = String(formData.get("guardianPhone") ?? "").trim() - const guardianRelation = String(formData.get("guardianRelation") ?? "").trim() - - if (!email) return { success: false, message: "请输入邮箱" } - if (!password) return { success: false, message: "请输入密码" } - if (password.length < 6) return { success: false, message: "密码至少 6 位" } - - const age = calcAge(birthDateRaw) - const isMinor = age !== null && age < ADULT_AGE - if (isMinor) { - if (!guardianName) return { success: false, message: "未成年人须填写监护人姓名" } - if (!guardianPhone) return { success: false, message: "未成年人须填写监护人电话" } - if (!guardianRelation) return { success: false, message: "未成年人须选择监护人关系" } - } - - const existing = await db.query.users.findFirst({ - where: eq(users.email, email), - columns: { id: true }, - }) - if (existing) return { success: false, message: "该邮箱已注册" } - - const hashedPassword = normalizeBcryptHash(await hash(password, 10)) - const userId = createId() - await db.insert(users).values({ - id: userId, - name: name.length ? name : null, - email, - password: hashedPassword, - birthDate: birthDateRaw ? new Date(birthDateRaw) : null, - age: age ?? null, - guardianName: guardianName || null, - guardianPhone: guardianPhone || null, - guardianRelation: guardianRelation || null, - consentAcceptedAt: new Date(), - }) - const roleRow = await db.query.roles.findFirst({ - where: eq(roles.name, "student"), - columns: { id: true }, - }) - if (!roleRow) { - await db.insert(roles).values({ name: "student" }) - } - const resolvedRole = roleRow - ?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } })) - if (resolvedRole?.id) { - await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id }) - } - - return { success: true, message: "账户创建成功" } - } catch (error) { - const isProd = process.env.NODE_ENV === "production" - - const anyErr = error as unknown as { - code?: string - message?: string - sqlMessage?: string - cause?: unknown - } - - const cause1 = anyErr?.cause as - | { code?: string; message?: string; sqlMessage?: string; cause?: unknown } - | undefined - const cause2 = (cause1?.cause ?? undefined) as - | { code?: string; message?: string; sqlMessage?: string } - | undefined - - const code = String(cause2?.code ?? cause1?.code ?? anyErr?.code ?? "").trim() - const msg = String( - cause2?.sqlMessage ?? - cause1?.sqlMessage ?? - anyErr?.sqlMessage ?? - cause2?.message ?? - cause1?.message ?? - anyErr?.message ?? - "" - ).trim() - const msgLower = msg.toLowerCase() - - if ( - code === "ER_DUP_ENTRY" || - msgLower.includes("duplicate") || - msgLower.includes("unique") - ) { - return { success: false, message: "该邮箱已注册" } - } - - if ( - code === "ER_NO_SUCH_TABLE" || - msgLower.includes("doesn't exist") || - msgLower.includes("unknown column") - ) { - return { - success: false, - message: "数据库未初始化或未迁移,请先运行 npm run db:migrate", - } - } - - if (code === "ER_ACCESS_DENIED_ERROR") { - return { success: false, message: "数据库账号/权限错误,请检查 DATABASE_URL" } - } - - if (code === "ECONNREFUSED" || code === "ENOTFOUND") { - return { success: false, message: "数据库连接失败,请检查 DATABASE_URL 与网络" } - } - - if (!isProd && msg) { - return { success: false, message: `创建账户失败:${msg}` } - } - - return { success: false, message: "创建账户失败,请稍后重试" } - } - } - return } diff --git a/src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx new file mode 100644 index 0000000..8fdcc7a --- /dev/null +++ b/src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx @@ -0,0 +1,50 @@ +import { notFound } from "next/navigation" +import type { Metadata } from "next" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getEditAnnouncementPageData } from "@/modules/announcements/data-access" +import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" +import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context" + +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const t = await getTranslations("announcements") + return { + title: t("title.edit"), + description: t("description.edit"), + } +} + +export default async function EditAnnouncementPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + const { id } = await params + const t = await getTranslations("announcements") + + const { announcement, grades } = await getEditAnnouncementPageData(id) + + if (!announcement) notFound() + + return ( + +
+
+

{t("title.edit")}

+

{t("description.edit")}

+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/[id]/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/page.tsx index 825f688..81edd7b 100644 --- a/src/app/(dashboard)/admin/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/admin/announcements/[id]/page.tsx @@ -5,43 +5,45 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getEditAnnouncementPageData } from "@/modules/announcements/data-access" -import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" +import { getAdminAnnouncementDetailPageData } from "@/modules/announcements/data-access" +import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail" +import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context" export const dynamic = "force-dynamic" export async function generateMetadata(): Promise { const t = await getTranslations("announcements") - return { - title: t("title.edit"), - description: t("description.edit"), - } + return { title: t("title.detail"), description: t("description.detail") } } -export default async function EditAnnouncementPage({ +/** + * P1-2: 新增管理端公告详情页。 + * + * 此前 `AnnouncementDetail canManage=true` 分支为死代码(无任何页面使用), + * 管理员只能通过编辑表单间接查看公告,无法在 UI 中执行发布/归档/删除/置顶。 + * 本页面启用该分支,提供完整的管理操作入口。 + */ +export default async function AdminAnnouncementDetailPage({ params, }: { params: Promise<{ id: string }> }): Promise { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const { id } = await params - const t = await getTranslations("announcements") - - const { announcement, grades } = await getEditAnnouncementPageData(id) + const { announcement } = await getAdminAnnouncementDetailPageData(id) if (!announcement) notFound() return ( -
-
-

{t("title.edit")}

-

{t("description.edit")}

+ +
+
- -
+ ) } diff --git a/src/app/(dashboard)/admin/announcements/loading.tsx b/src/app/(dashboard)/admin/announcements/loading.tsx index 4ca3b4d..7d27fd7 100644 --- a/src/app/(dashboard)/admin/announcements/loading.tsx +++ b/src/app/(dashboard)/admin/announcements/loading.tsx @@ -1,40 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" -import { Skeleton } from "@/shared/components/ui/skeleton" +import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton" export default function AdminAnnouncementsLoading() { - return ( -
-
-
- - -
- -
- -
- -
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - - - - - - - - -
- - -
-
-
- ))} -
-
- ) + return } diff --git a/src/app/(dashboard)/admin/attendance/error.tsx b/src/app/(dashboard)/admin/attendance/error.tsx index d5ca91d..91c29b3 100644 --- a/src/app/(dashboard)/admin/attendance/error.tsx +++ b/src/app/(dashboard)/admin/attendance/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("attendance") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/admin/attendance/page.tsx b/src/app/(dashboard)/admin/attendance/page.tsx index 0408528..6dc1669 100644 --- a/src/app/(dashboard)/admin/attendance/page.tsx +++ b/src/app/(dashboard)/admin/attendance/page.tsx @@ -1,5 +1,4 @@ import Link from "next/link" -import type { Metadata } from "next" import type { JSX } from "react" import { BarChart3, ClipboardList } from "lucide-react" import { getTranslations } from "next-intl/server" @@ -10,17 +9,22 @@ import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import { getAdminClasses } from "@/modules/classes/data-access" +import { getGrades } from "@/modules/school/data-access" import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access" +import { getClassComparison } from "@/modules/attendance/data-access-stats" +import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation" import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards" import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout" +import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card" +import { AttendanceGradeCorrelationCard } from "@/modules/attendance/components/attendance-grade-correlation-card" import type { AttendanceStatus } from "@/modules/attendance/types" export const dynamic = "force-dynamic" const isValidAttendanceStatus = (v?: string): v is AttendanceStatus => - v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused" + v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused" || v === "school_activity" export default async function AdminAttendancePage({ searchParams, @@ -37,24 +41,46 @@ export default async function AdminAttendancePage({ const status = statusParam && statusParam !== "all" && isValidAttendanceStatus(statusParam) ? statusParam : undefined const date = getSearchParam(sp, "date") + // L-7:跨班级对比的年级选择 + const gradeId = getSearchParam(sp, "gradeId") + const startDate = getSearchParam(sp, "startDate") + const endDate = getSearchParam(sp, "endDate") const classes = await getAdminClasses() const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) - const result = await getAttendanceRecords({ - scope: ctx.dataScope, - currentUserId: ctx.userId, - classId: classId && classId !== "all" ? classId : undefined, - status, - date: date && date.length > 0 ? date : undefined, - }) + // 并行获取:记录、统计、年级列表、对比汇总 + const [result, stats, grades, comparison, correlation] = await Promise.all([ + getAttendanceRecords({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + classId: classId && classId !== "all" ? classId : undefined, + status, + date: date && date.length > 0 ? date : undefined, + }), + getAttendanceStats({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + classId: classId && classId !== "all" ? classId : undefined, + date: date && date.length > 0 ? date : undefined, + }), + getGrades(), + // L-7:若指定 gradeId 则获取对比汇总 + gradeId + ? getClassComparison(gradeId, startDate ?? undefined, endDate ?? undefined) + : Promise.resolve(null), + // L-9:考勤-成绩关联分析(仅当指定班级时) + classId && classId !== "all" + ? getAttendanceGradeCorrelation( + classId, + startDate ?? undefined, + endDate ?? undefined, + ctx.dataScope + ) + : Promise.resolve(null), + ]) - const stats = await getAttendanceStats({ - scope: ctx.dataScope, - currentUserId: ctx.userId, - classId: classId && classId !== "all" ? classId : undefined, - date: date && date.length > 0 ? date : undefined, - }) + const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name })) const header = (
@@ -77,15 +103,29 @@ export default async function AdminAttendancePage({ stats={} filters={} > - {result.items.length === 0 && !classId && !status && !date ? ( - + {result.items.length === 0 && !classId && !status && !date ? ( + + ) : ( + + )} + + - ) : ( - - )} + + {/* L-9:考勤与成绩关联分析(仅当指定班级时显示) */} + {classId && classId !== "all" && ( + + )} +
) } + diff --git a/src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx b/src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx new file mode 100644 index 0000000..ce7b83c --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx @@ -0,0 +1,26 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function DataChangeLogsError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("audit") + return ( +
+ reset() }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/audit-logs/data-changes/loading.tsx b/src/app/(dashboard)/admin/audit-logs/data-changes/loading.tsx new file mode 100644 index 0000000..f3534be --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/data-changes/loading.tsx @@ -0,0 +1,16 @@ +import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton" + +export default function DataChangeLogsLoading() { + return ( + ) } diff --git a/src/app/(dashboard)/admin/audit-logs/error.tsx b/src/app/(dashboard)/admin/audit-logs/error.tsx new file mode 100644 index 0000000..b59dad5 --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/error.tsx @@ -0,0 +1,26 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AuditLogsError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("audit") + return ( +
+ reset() }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/audit-logs/loading.tsx b/src/app/(dashboard)/admin/audit-logs/loading.tsx new file mode 100644 index 0000000..076bdf2 --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/loading.tsx @@ -0,0 +1,16 @@ +import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton" + +export default function AuditLogsLoading() { + return ( + ) } diff --git a/src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx b/src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx new file mode 100644 index 0000000..e089e7c --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function EditCoursePlanError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx b/src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx new file mode 100644 index 0000000..0b8e856 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function EditCoursePlanLoading() { + return ( +
+
+ + +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+ + +
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx index c458849..4da6504 100644 --- a/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx @@ -3,9 +3,11 @@ import type { Metadata } from "next" import type { JSX } from "react" import { getTranslations } from "next-intl/server" -import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getCoursePlanById } from "@/modules/course-plans/data-access" import { getAdminClasses } from "@/modules/classes/data-access" -import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" +import { getAcademicYears, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" export async function generateMetadata(): Promise { @@ -24,10 +26,13 @@ export default async function EditCoursePlanPage({ params: Promise<{ id: string }> }): Promise { const t = await getTranslations("coursePlans") + // P0-2:admin 页面补充 requirePermission 校验 + const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE) const { id } = await params const [plan, classes, subjects, teachers, academicYears] = await Promise.all([ - getCoursePlanById(id), + // P0-1:携带 scope 过滤 + getCoursePlanById(id, { userId: ctx.userId, isAdmin: true }), getAdminClasses(), getSubjectOptions(), getStaffOptions(), @@ -47,9 +52,10 @@ export default async function EditCoursePlanPage({ plan={plan} classes={classes.map((c) => ({ id: c.id, name: c.name }))} subjects={subjects} - teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + teachers={teachers.map((tch) => ({ id: tch.id, name: tch.name }))} academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))} backHref={`/admin/course-plans/${plan.id}`} + successHref={`/admin/course-plans/${plan.id}`} />
) diff --git a/src/app/(dashboard)/admin/course-plans/[id]/error.tsx b/src/app/(dashboard)/admin/course-plans/[id]/error.tsx new file mode 100644 index 0000000..0beee6f --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminCoursePlanDetailError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/loading.tsx b/src/app/(dashboard)/admin/course-plans/[id]/loading.tsx new file mode 100644 index 0000000..a88b368 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminCoursePlanDetailLoading() { + return ( +
+
+ +
+ + +
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/page.tsx b/src/app/(dashboard)/admin/course-plans/[id]/page.tsx index affc17b..14a2bd6 100644 --- a/src/app/(dashboard)/admin/course-plans/[id]/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/[id]/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from "next" import type { JSX } from "react" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getCoursePlanById } from "@/modules/course-plans/data-access" import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail" @@ -21,8 +23,11 @@ export default async function CoursePlanDetailPage({ }: { params: Promise<{ id: string }> }): Promise { + // P0-2:admin 页面补充 requirePermission 校验 + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) const { id } = await params - const plan = await getCoursePlanById(id) + // P0-1/P0-3:携带 scope 过滤,admin 视角为全局 + const plan = await getCoursePlanById(id, { userId: ctx.userId, isAdmin: true }) if (!plan) notFound() @@ -32,6 +37,7 @@ export default async function CoursePlanDetailPage({ plan={plan} editHref={`/admin/course-plans/${plan.id}/edit`} backHref="/admin/course-plans" + successHref="/admin/course-plans" /> ) diff --git a/src/app/(dashboard)/admin/course-plans/create/error.tsx b/src/app/(dashboard)/admin/course-plans/create/error.tsx new file mode 100644 index 0000000..27f63d2 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/create/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function CreateCoursePlanError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/create/loading.tsx b/src/app/(dashboard)/admin/course-plans/create/loading.tsx new file mode 100644 index 0000000..8ef82f4 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/create/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function CreateCoursePlanLoading() { + return ( +
+
+ + +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+ + +
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/create/page.tsx b/src/app/(dashboard)/admin/course-plans/create/page.tsx index f98faab..03773af 100644 --- a/src/app/(dashboard)/admin/course-plans/create/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/create/page.tsx @@ -2,9 +2,10 @@ import type { Metadata } from "next" import type { JSX } from "react" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getAdminClasses } from "@/modules/classes/data-access" -import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" -import { getSubjectOptions } from "@/modules/course-plans/data-access" +import { getAcademicYears, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" export async function generateMetadata(): Promise { @@ -19,6 +20,9 @@ export const dynamic = "force-dynamic" export default async function CreateCoursePlanPage(): Promise { const t = await getTranslations("coursePlans") + // P0-2:admin 页面补充 requirePermission 校验 + await requirePermission(Permissions.COURSE_PLAN_MANAGE) + const [classes, subjects, teachers, academicYears] = await Promise.all([ getAdminClasses(), getSubjectOptions(), @@ -36,9 +40,10 @@ export default async function CreateCoursePlanPage(): Promise { mode="create" classes={classes.map((c) => ({ id: c.id, name: c.name }))} subjects={subjects} - teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + teachers={teachers.map((tch) => ({ id: tch.id, name: tch.name }))} academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))} backHref="/admin/course-plans" + successHref="/admin/course-plans" /> ) diff --git a/src/app/(dashboard)/admin/course-plans/error.tsx b/src/app/(dashboard)/admin/course-plans/error.tsx new file mode 100644 index 0000000..639705d --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminCoursePlansError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/loading.tsx b/src/app/(dashboard)/admin/course-plans/loading.tsx new file mode 100644 index 0000000..ce8778c --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminCoursePlansLoading() { + return ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/page.tsx b/src/app/(dashboard)/admin/course-plans/page.tsx index 0854634..6a11fde 100644 --- a/src/app/(dashboard)/admin/course-plans/page.tsx +++ b/src/app/(dashboard)/admin/course-plans/page.tsx @@ -2,10 +2,12 @@ import type { Metadata } from "next" import type { JSX } from "react" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getCoursePlans } from "@/modules/course-plans/data-access" import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" -import type { CoursePlanStatus } from "@/modules/course-plans/types" +import { isCoursePlanStatus } from "@/modules/course-plans/types" export async function generateMetadata(): Promise { const t = await getTranslations("coursePlans") @@ -17,20 +19,21 @@ export async function generateMetadata(): Promise { export const dynamic = "force-dynamic" -const isValidStatus = (v?: string): v is CoursePlanStatus => - v === "planning" || v === "active" || v === "completed" || v === "paused" - export default async function AdminCoursePlansPage({ searchParams, }: { searchParams: Promise }): Promise { + // P0-2:admin 页面补充 requirePermission 校验 + await requirePermission(Permissions.COURSE_PLAN_READ) + const t = await getTranslations("coursePlans") const sp = await searchParams const statusParam = getSearchParam(sp, "status") - const status = isValidStatus(statusParam) ? statusParam : undefined + const status = isCoursePlanStatus(statusParam) ? statusParam : undefined - const plans = await getCoursePlans({ status }) + // admin 视角:scope.isAdmin = true,全局视图 + const plans = await getCoursePlans({ status }, { userId: "", isAdmin: true }) return (
diff --git a/src/app/(dashboard)/admin/curriculum-map/error.tsx b/src/app/(dashboard)/admin/curriculum-map/error.tsx new file mode 100644 index 0000000..8fe9b43 --- /dev/null +++ b/src/app/(dashboard)/admin/curriculum-map/error.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Map } from "lucide-react"; +import { EmptyState } from "@/shared/components/ui/empty-state"; + +export default function CurriculumMapError() { + const t = useTranslations("lessonPreparation"); + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/admin/curriculum-map/loading.tsx b/src/app/(dashboard)/admin/curriculum-map/loading.tsx new file mode 100644 index 0000000..88a07a9 --- /dev/null +++ b/src/app/(dashboard)/admin/curriculum-map/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@/shared/components/ui/skeleton"; + +export default function CurriculumMapLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/admin/curriculum-map/page.tsx b/src/app/(dashboard)/admin/curriculum-map/page.tsx new file mode 100644 index 0000000..aaebae5 --- /dev/null +++ b/src/app/(dashboard)/admin/curriculum-map/page.tsx @@ -0,0 +1,59 @@ +import type { JSX } from "react"; +import { getTranslations } from "next-intl/server"; +import { requirePermission } from "@/shared/lib/auth-guard"; +import { Permissions } from "@/shared/types/permissions"; +import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"; +import { getStandardsCoverageHeatmapAction, getGlobalLessonPlanStatsAction } from "@/modules/lesson-preparation/actions-analytics"; +import { CurriculumMapView } from "@/modules/lesson-preparation/components/curriculum-map-view"; + +export const dynamic = "force-dynamic"; + +export default async function CurriculumMapPage(): Promise { + const t = await getTranslations("lessonPreparation"); + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); + void ctx; + + const [grades, subjects, heatmapRes, statsRes] = await Promise.all([ + getGradeOptions(), + getSubjectOptions(), + getStandardsCoverageHeatmapAction(), + getGlobalLessonPlanStatsAction(), + ]); + + const heatmap = heatmapRes.success && heatmapRes.data ? heatmapRes.data.items : []; + const stats = statsRes.success && statsRes.data ? statsRes.data.stats : null; + + return ( +
+
+

{t("analytics.standardsCoverage")}

+

{t("analytics.title")}

+
+ + {stats !== null && ( +
+ + + + + +
+ )} + + +
+ ); +} + +function StatCard({ label, value }: { label: string; value: number }): JSX.Element { + return ( +
+

{label}

+

{value}

+
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/admin/elective/[id]/loading.tsx b/src/app/(dashboard)/admin/elective/[id]/loading.tsx new file mode 100644 index 0000000..a6b1a3b --- /dev/null +++ b/src/app/(dashboard)/admin/elective/[id]/loading.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + +
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/elective/[id]/page.tsx b/src/app/(dashboard)/admin/elective/[id]/page.tsx new file mode 100644 index 0000000..fb253e2 --- /dev/null +++ b/src/app/(dashboard)/admin/elective/[id]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getElectiveCourseById } from "@/modules/elective/data-access" +import { getCourseSelections } from "@/modules/elective/data-access-selections" +import { ElectiveCourseDetail } from "@/modules/elective/components/elective-course-detail" +import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout" + +export const dynamic = "force-dynamic" + +export default async function AdminElectiveDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const t = await getTranslations("elective") + await requirePermission(Permissions.ELECTIVE_READ) + const { id } = await params + + const [course, selections] = await Promise.all([ + getElectiveCourseById(id), + getCourseSelections(id), + ]) + + if (!course) notFound() + + const header = ( +
+

{t("title.detail")}

+

{t("description.detail")}

+
+ ) + + return ( + + + + ) +} diff --git a/src/app/(dashboard)/admin/elective/_components/course-list-loader.tsx b/src/app/(dashboard)/admin/elective/_components/course-list-loader.tsx new file mode 100644 index 0000000..6272f72 --- /dev/null +++ b/src/app/(dashboard)/admin/elective/_components/course-list-loader.tsx @@ -0,0 +1,34 @@ +import type { JSX } from "react" +import { getElectiveCourses } from "@/modules/elective/data-access" +import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import { getSearchParam, type SearchParams } from "@/shared/lib/utils" +import type { ElectiveCourseStatus } from "@/modules/elective/types" + +const isValidStatus = (v?: string): v is ElectiveCourseStatus => + v === "draft" || v === "open" || v === "closed" || v === "cancelled" + +/** + * 管理员课程列表 - 异步 RSC loader + * 独立 Suspense 边界,可流式渲染 + * 过滤逻辑(status)在本 loader 内完成,保持组件纯净 + */ +export async function CourseListLoader({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const sp = await searchParams + const statusParam = getSearchParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const courses = await getElectiveCourses({ status }) + + return ( + + ) +} diff --git a/src/app/(dashboard)/admin/elective/_components/stats-cards-loader.tsx b/src/app/(dashboard)/admin/elective/_components/stats-cards-loader.tsx new file mode 100644 index 0000000..da7281e --- /dev/null +++ b/src/app/(dashboard)/admin/elective/_components/stats-cards-loader.tsx @@ -0,0 +1,12 @@ +import type { JSX } from "react" +import { getElectiveOverviewStats } from "@/modules/elective/data-access-stats" +import { ElectiveStatsCards } from "@/modules/elective/components/elective-stats-cards" + +/** + * 管理员概览统计卡片 - 异步 RSC loader + * 独立 Suspense 边界,可流式渲染 + */ +export async function StatsCardsLoader(): Promise { + const stats = await getElectiveOverviewStats() + return +} diff --git a/src/app/(dashboard)/admin/elective/error.tsx b/src/app/(dashboard)/admin/elective/error.tsx index 6a0b78e..ab189fb 100644 --- a/src/app/(dashboard)/admin/elective/error.tsx +++ b/src/app/(dashboard)/admin/elective/error.tsx @@ -11,10 +11,10 @@ export default function AdminElectiveError({ reset }: { error: Error & { digest?
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/admin/elective/loading.tsx b/src/app/(dashboard)/admin/elective/loading.tsx index dd8cd0c..85bab5e 100644 --- a/src/app/(dashboard)/admin/elective/loading.tsx +++ b/src/app/(dashboard)/admin/elective/loading.tsx @@ -1,6 +1,9 @@ import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" +/** + * 全页骨架屏 - 路由级 loading.tsx 默认导出 + */ export default function Loading() { return (
@@ -8,20 +11,63 @@ export default function Loading() {
-
- {Array.from({ length: 6 }).map((_, i) => ( - - - - - - - - - - - ))} -
+ + +
+ ) +} + +/** + * 统计卡片骨架屏 + * 用于独立 Suspense 边界 fallback + */ +export function StatsCardsSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ ) +} + +/** + * 课程列表骨架屏 + * 用于独立 Suspense 边界 fallback + */ +export function CourseListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + ))}
) } diff --git a/src/app/(dashboard)/admin/elective/page.tsx b/src/app/(dashboard)/admin/elective/page.tsx index 7f42543..c964b83 100644 --- a/src/app/(dashboard)/admin/elective/page.tsx +++ b/src/app/(dashboard)/admin/elective/page.tsx @@ -1,29 +1,26 @@ -import type { Metadata } from "next" import type { JSX } from "react" +import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getElectiveCourses } from "@/modules/elective/data-access" -import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout" -import { getSearchParam, type SearchParams } from "@/shared/lib/utils" -import type { ElectiveCourseStatus } from "@/modules/elective/types" +import { type SearchParams } from "@/shared/lib/utils" + +import { StatsCardsLoader } from "./_components/stats-cards-loader" +import { CourseListLoader } from "./_components/course-list-loader" +import { StatsCardsSkeleton, CourseListSkeleton } from "./loading" export const dynamic = "force-dynamic" -const isValidStatus = (v?: string): v is ElectiveCourseStatus => - v === "draft" || v === "open" || v === "closed" || v === "cancelled" - export default async function AdminElectivePage({ searchParams, }: { searchParams: Promise }): Promise { - const sp = await searchParams const t = await getTranslations("elective") - const statusParam = getSearchParam(sp, "status") - const status = isValidStatus(statusParam) ? statusParam : undefined - - const courses = await getElectiveCourses({ status }) + // 纵深防御:除中间件外,Server Component 入口再次校验权限 + await requirePermission(Permissions.ELECTIVE_READ) const header = (
@@ -36,12 +33,12 @@ export default async function AdminElectivePage({ return ( - + }> + + + }> + + ) } diff --git a/src/app/(dashboard)/admin/error-book/page.tsx b/src/app/(dashboard)/admin/error-book/page.tsx index 2c93c00..882e302 100644 --- a/src/app/(dashboard)/admin/error-book/page.tsx +++ b/src/app/(dashboard)/admin/error-book/page.tsx @@ -8,6 +8,7 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { @@ -19,7 +20,7 @@ import { getSubjectErrorDistribution, getChapterWeakness, getAllStudentIds, -} from "@/modules/error-book/data-access" +} from "@/modules/error-book/data-access-analytics" import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" import { SubjectTabs } from "@/modules/error-book/components/subject-tabs" import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards" @@ -137,24 +138,30 @@ async function AdminErrorBookContent({ ) : null} {/* 统计卡片 */} - + + + {/* 学科错题分布图(仅在"全部学科"视图下显示) */} {!subjectParam && subjectDist.length > 0 ? ( - + + + ) : null} {/* 章节错题分布 + 知识点薄弱度(并排) */}
{chapterWeakness.length > 0 ? ( - + + + ) : ( )} {weakKps.length > 0 ? ( - + + + ) : (
{sortedSummaries.length > 0 ? ( - + + + ) : ( 0 ? ( - + + + ) : null}
) diff --git a/src/app/(dashboard)/admin/files/error.tsx b/src/app/(dashboard)/admin/files/error.tsx new file mode 100644 index 0000000..f3248e8 --- /dev/null +++ b/src/app/(dashboard)/admin/files/error.tsx @@ -0,0 +1,33 @@ +"use client" + +import { FileWarning } from "lucide-react" +import type { ReactElement } from "react" +import { useTranslations } from "next-intl" +import { Button } from "@/shared/components/ui/button" + +export default function AdminFilesError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + const t = useTranslations("files") + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/files/loading.tsx b/src/app/(dashboard)/admin/files/loading.tsx new file mode 100644 index 0000000..3a1d35f --- /dev/null +++ b/src/app/(dashboard)/admin/files/loading.tsx @@ -0,0 +1,54 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminFilesLoading() { + return ( +
+
+ + +
+ + {/* 统计卡片骨架 */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ + {/* 上传区骨架 */} + + + {/* 工具栏骨架 */} +
+ + +
+ + {/* 列表骨架 */} + + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ + + + + + + +
+ ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/invitation-codes/error.tsx b/src/app/(dashboard)/admin/invitation-codes/error.tsx new file mode 100644 index 0000000..db4765b --- /dev/null +++ b/src/app/(dashboard)/admin/invitation-codes/error.tsx @@ -0,0 +1,25 @@ +"use client" + +import { TicketPlus } from "lucide-react" +import type { ReactElement } from "react" +import { Button } from "@/shared/components/ui/button" +import { useTranslations } from "next-intl" + +export default function AdminInvitationCodesError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + const t = useTranslations("invitationCodes") + const tCommon = useTranslations("common") + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/invitation-codes/loading.tsx b/src/app/(dashboard)/admin/invitation-codes/loading.tsx new file mode 100644 index 0000000..4853a35 --- /dev/null +++ b/src/app/(dashboard)/admin/invitation-codes/loading.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminInvitationCodesLoading() { + return ( +
+
+
+ + +
+ +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/invitation-codes/page.tsx b/src/app/(dashboard)/admin/invitation-codes/page.tsx new file mode 100644 index 0000000..d38f54e --- /dev/null +++ b/src/app/(dashboard)/admin/invitation-codes/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { listInvitationCodes } from "@/modules/invitation-codes/data-access" +import { InvitationCodesView } from "@/modules/invitation-codes/components/invitation-codes-view" + +export async function generateMetadata(): Promise { + const t = await getTranslations("invitationCodes") + return { + title: `${t("title")} - Next_Edu`, + description: t("description"), + } +} + +export const dynamic = "force-dynamic" + +export default async function AdminInvitationCodesPage(): Promise { + await requirePermission(Permissions.USER_MANAGE) + + // 直接调用 data-access 获取初始数据(SSR) + // 后续生成/删除通过 Server Action + router.refresh() 触发更新 + const codes = await listInvitationCodes(200, true) + // 在 Server Component 中计算「当前时间」传给 Client Component, + // 避免 Client 端在 render 阶段调用 Date.now()(react-hooks/purity 规则)。 + // 此处使用 new Date().getTime() 与现有 student/learning/page.tsx 模式一致。 + const now = new Date().getTime() + + return +} diff --git a/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx index 3a09080..d0e067c 100644 --- a/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx +++ b/src/app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx @@ -1,9 +1,10 @@ import type { JSX } from "react" import { Suspense } from "react" import { notFound } from "next/navigation" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" -import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access" import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" import { Skeleton } from "@/shared/components/ui/skeleton" @@ -15,7 +16,8 @@ export default async function AdminLessonPlanViewPage({ params: Promise<{ planId: string }> }): Promise { const { planId } = await params - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) const plan = await getLessonPlanById(planId, ctx.userId) if (!plan) notFound() @@ -23,21 +25,14 @@ export default async function AdminLessonPlanViewPage({ let textbookTitle: string | undefined let chapterTitle: string | undefined if (plan.textbookId) { - const textbook = await getTextbookById(plan.textbookId) + // V4 P2-1 修复:textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询 + const [textbook, chapters] = await Promise.all([ + getTextbookById(plan.textbookId), + getChaptersByTextbookId(plan.textbookId), + ]) textbookTitle = textbook?.title if (plan.chapterId) { - const chapters = await getChaptersByTextbookId(plan.textbookId) - const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { - for (const ch of list) { - if (ch.id === plan.chapterId) return ch - if (ch.children && ch.children.length > 0) { - const found = findChapter(ch.children as typeof chapters) - if (found) return found - } - } - return undefined - } - const chapter = findChapter(chapters) + const chapter = findChapterById(chapters, plan.chapterId) chapterTitle = chapter?.title } } diff --git a/src/app/(dashboard)/admin/lesson-plans/page.tsx b/src/app/(dashboard)/admin/lesson-plans/page.tsx index 53f89f9..607552f 100644 --- a/src/app/(dashboard)/admin/lesson-plans/page.tsx +++ b/src/app/(dashboard)/admin/lesson-plans/page.tsx @@ -1,10 +1,13 @@ import type { JSX } from "react" import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlans, getLessonPlanStats } from "@/modules/lesson-preparation/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" +import { ADMIN_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider" import { Skeleton } from "@/shared/components/ui/skeleton" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -12,7 +15,8 @@ export const dynamic = "force-dynamic" export default async function AdminLessonPlansPage(): Promise { const t = await getTranslations("lessonPreparation") - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) // 通过 data-access 层查询,避免 app 层直接访问数据库(P0-1 修复) const [items, subjects, stats] = await Promise.all([ @@ -72,17 +76,20 @@ export default async function AdminLessonPlansPage(): Promise {
- - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - } - > - - + {/* P0-13 修复:包裹 LessonPlanProviderSetup,注入 admin 角色配置,使筛选功能生效 */} + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + } + > + + + ) } diff --git a/src/app/(dashboard)/admin/permissions/error.tsx b/src/app/(dashboard)/admin/permissions/error.tsx new file mode 100644 index 0000000..bf8e60f --- /dev/null +++ b/src/app/(dashboard)/admin/permissions/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { KeyRound } from "lucide-react" +import type { ReactElement } from "react" +import { Button } from "@/shared/components/ui/button" + +export default function AdminPermissionsError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/permissions/loading.tsx b/src/app/(dashboard)/admin/permissions/loading.tsx new file mode 100644 index 0000000..7d7ece1 --- /dev/null +++ b/src/app/(dashboard)/admin/permissions/loading.tsx @@ -0,0 +1,30 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminPermissionsLoading() { + return ( +
+
+ + +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ +
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/permissions/page.tsx b/src/app/(dashboard)/admin/permissions/page.tsx new file mode 100644 index 0000000..08a092c --- /dev/null +++ b/src/app/(dashboard)/admin/permissions/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getPermissionRoleCounts } from "@/modules/rbac/data-access-permissions" +import { PermissionCatalogView } from "@/modules/rbac/components/permission-catalog-view" + +export async function generateMetadata(): Promise { + const t = await getTranslations("rbac") + return { + title: `${t("permissions.title")} - Next_Edu`, + description: t("permissions.description"), + } +} + +export const dynamic = "force-dynamic" + +export default async function AdminPermissionsPage(): Promise { + await requirePermission(Permissions.PERMISSION_READ) + + const roleCountsByPermission = await getPermissionRoleCounts() + + return ( +
+
+

Permission Catalog

+

+ All permission points defined in the system, grouped by module. +

+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/admin/questions/error.tsx b/src/app/(dashboard)/admin/questions/error.tsx new file mode 100644 index 0000000..9d34f03 --- /dev/null +++ b/src/app/(dashboard)/admin/questions/error.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AdminQuestionsError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + const t = useTranslations("questions") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/questions/loading.tsx b/src/app/(dashboard)/admin/questions/loading.tsx new file mode 100644 index 0000000..174cbdd --- /dev/null +++ b/src/app/(dashboard)/admin/questions/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ +
+ +
+
+ +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/questions/page.tsx b/src/app/(dashboard)/admin/questions/page.tsx new file mode 100644 index 0000000..529055a --- /dev/null +++ b/src/app/(dashboard)/admin/questions/page.tsx @@ -0,0 +1,138 @@ +import type { JSX } from "react" +import { Suspense } from "react" +import { ClipboardList } from "lucide-react" +import { getTranslations } from "next-intl/server" + +import { QuestionFilters } from "@/modules/questions/components/question-filters" +import { CreateQuestionButton } from "@/modules/questions/components/create-question-button" +import { QuestionBankResultsClient } from "@/modules/questions/components/question-bank-results-client" +import { ImportExportButtons } from "@/modules/questions/components/import-export-buttons" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { getQuestions } from "@/modules/questions/data-access" +import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import type { QuestionType } from "@/modules/questions/types" + +export const dynamic = "force-dynamic" + +const VALID_QUESTION_TYPES: ReadonlySet = new Set([ + "single_choice", + "multiple_choice", + "text", + "judgment", + "composite", +]) + +function parseQuestionType(v?: string): QuestionType | undefined { + return v && VALID_QUESTION_TYPES.has(v) ? (v as QuestionType) : undefined +} + +async function QuestionBankResults({ searchParams }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.QUESTION_READ) + + const params = await searchParams + + const q = getParam(params, "q") + const type = getParam(params, "type") + const difficulty = getParam(params, "difficulty") + const knowledgePointId = getParam(params, "kp") + const textbookId = getParam(params, "tb") + const chapterId = getParam(params, "ch") + + const questionType = parseQuestionType(type) + + const difficultyNum = difficulty && difficulty !== "all" ? Number(difficulty) : undefined + const safeDifficulty = difficultyNum !== undefined && Number.isFinite(difficultyNum) ? difficultyNum : undefined + + const { data: questions } = await getQuestions({ + q: q || undefined, + type: questionType, + difficulty: safeDifficulty, + knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined, + textbookId: textbookId && textbookId !== "all" ? textbookId : undefined, + chapterId: chapterId && chapterId !== "all" ? chapterId : undefined, + pageSize: 200, + }) + + const hasFilters = Boolean( + q || + (type && type !== "all") || + (difficulty && difficulty !== "all") || + (knowledgePointId && knowledgePointId !== "all") || + (textbookId && textbookId !== "all") || + (chapterId && chapterId !== "all") + ) + + if (questions.length === 0) { + const t = await getTranslations("questions") + return ( + + ) + } + + return ( +
+ +
+ ) +} + +export default async function AdminQuestionBankPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const t = await getTranslations("questions") + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+
+ + +
+
+ +
+ }> + + + + }> + + +
+
+ ) +} + +function QuestionBankResultsFallback() { + return ( +
+
+ +
+
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/[id]/error.tsx b/src/app/(dashboard)/admin/roles/[id]/error.tsx new file mode 100644 index 0000000..8d30c0b --- /dev/null +++ b/src/app/(dashboard)/admin/roles/[id]/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { ShieldAlert } from "lucide-react" +import type { ReactElement } from "react" +import { Button } from "@/shared/components/ui/button" + +export default function AdminRoleDetailError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/[id]/loading.tsx b/src/app/(dashboard)/admin/roles/[id]/loading.tsx new file mode 100644 index 0000000..9f9d390 --- /dev/null +++ b/src/app/(dashboard)/admin/roles/[id]/loading.tsx @@ -0,0 +1,34 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminRoleDetailLoading() { + return ( +
+
+ +
+ + +
+ +
+ + + + + + {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/[id]/page.tsx b/src/app/(dashboard)/admin/roles/[id]/page.tsx new file mode 100644 index 0000000..bc53da5 --- /dev/null +++ b/src/app/(dashboard)/admin/roles/[id]/page.tsx @@ -0,0 +1,94 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" +import Link from "next/link" +import { ArrowLeft, ShieldCheck, Shield } from "lucide-react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { Button } from "@/shared/components/ui/button" +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent } from "@/shared/components/ui/card" +import { getRoleById } from "@/modules/rbac/data-access" +import { RolePermissionMatrix } from "@/modules/rbac/components/role-permission-matrix" +import { RoleDetailEditButton } from "@/modules/rbac/components/role-detail-edit-button" + +export async function generateMetadata(): Promise { + const t = await getTranslations("rbac") + return { + title: `${t("roles.detail")} - Next_Edu`, + } +} + +export const dynamic = "force-dynamic" + +export default async function AdminRoleDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + await requirePermission(Permissions.ROLE_READ) + + const { id } = await params + const role = await getRoleById(id) + + if (!role) notFound() + + const isAdmin = role.name === "admin" + + return ( +
+
+ +
+
+

{role.name}

+ {role.isSystem ? ( + + + System + + ) : ( + + + Custom + + )} + {role.isEnabled ? ( + Enabled + ) : ( + Disabled + )} +
+ {role.description && ( +

{role.description}

+ )} +
+ {!isAdmin && } +
+ + {isAdmin && ( + + + The admin role is fully locked: its name, status, and + permissions cannot be modified. This prevents accidentally locking + yourself out of the system. + + + )} + + +
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/error.tsx b/src/app/(dashboard)/admin/roles/error.tsx new file mode 100644 index 0000000..c6dbcb5 --- /dev/null +++ b/src/app/(dashboard)/admin/roles/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { ShieldAlert } from "lucide-react" +import type { ReactElement } from "react" +import { Button } from "@/shared/components/ui/button" + +export default function AdminRolesError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/loading.tsx b/src/app/(dashboard)/admin/roles/loading.tsx new file mode 100644 index 0000000..f610588 --- /dev/null +++ b/src/app/(dashboard)/admin/roles/loading.tsx @@ -0,0 +1,35 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminRolesLoading() { + return ( +
+
+ + +
+ + + + + + +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + + + + + + +
+ ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/roles/page.tsx b/src/app/(dashboard)/admin/roles/page.tsx new file mode 100644 index 0000000..dc6041e --- /dev/null +++ b/src/app/(dashboard)/admin/roles/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getRoles } from "@/modules/rbac/data-access" +import { RoleManagementView } from "@/modules/rbac/components/role-management-view" + +export async function generateMetadata(): Promise { + const t = await getTranslations("rbac") + return { + title: `${t("roles.title")} - Next_Edu`, + description: t("roles.description"), + } +} + +export const dynamic = "force-dynamic" + +export default async function AdminRolesPage(): Promise { + await requirePermission(Permissions.ROLE_READ) + + const roles = await getRoles() + + return ( +
+
+

Role Management

+

+ Create custom roles and manage permission assignments. +

+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/classes/page.tsx b/src/app/(dashboard)/admin/school/classes/page.tsx index 350fd3c..357e953 100644 --- a/src/app/(dashboard)/admin/school/classes/page.tsx +++ b/src/app/(dashboard)/admin/school/classes/page.tsx @@ -5,8 +5,9 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access" -import { getGrades, getSchools } from "@/modules/school/data-access" +import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary" import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view" +import { getGrades, getSchools } from "@/modules/school/data-access" export const dynamic = "force-dynamic" @@ -34,7 +35,9 @@ export default async function AdminSchoolClassesPage(): Promise {

{t("classManagement.title")}

{t("classManagement.description")}

- + + + ) } diff --git a/src/app/(dashboard)/admin/school/grades/error.tsx b/src/app/(dashboard)/admin/school/grades/error.tsx index 54f48b9..9796d52 100644 --- a/src/app/(dashboard)/admin/school/grades/error.tsx +++ b/src/app/(dashboard)/admin/school/grades/error.tsx @@ -1,18 +1,26 @@ "use client" +import type { JSX } from "react" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" -export default function AdminGradesError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { +export default function AdminGradesError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): JSX.Element { + const t = useTranslations("grades") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/admin/school/grades/insights/error.tsx b/src/app/(dashboard)/admin/school/grades/insights/error.tsx index 958b584..7fbeb96 100644 --- a/src/app/(dashboard)/admin/school/grades/insights/error.tsx +++ b/src/app/(dashboard)/admin/school/grades/insights/error.tsx @@ -1,6 +1,8 @@ "use client" +import type { JSX } from "react" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -9,15 +11,16 @@ export default function AdminGradesInsightsError({ }: { error: Error & { digest?: string } reset: () => void -}) { +}): JSX.Element { + const t = useTranslations("grades") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/admin/school/grades/insights/loading.tsx b/src/app/(dashboard)/admin/school/grades/insights/loading.tsx index bc1ab65..c09c720 100644 --- a/src/app/(dashboard)/admin/school/grades/insights/loading.tsx +++ b/src/app/(dashboard)/admin/school/grades/insights/loading.tsx @@ -1,6 +1,7 @@ +import type { JSX } from "react" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function AdminGradesInsightsLoading() { +export default function AdminGradesInsightsLoading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/admin/school/grades/loading.tsx b/src/app/(dashboard)/admin/school/grades/loading.tsx index 0d6a4d6..15a5856 100644 --- a/src/app/(dashboard)/admin/school/grades/loading.tsx +++ b/src/app/(dashboard)/admin/school/grades/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function AdminGradesLoading() { +export default function AdminGradesLoading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/admin/users/error.tsx b/src/app/(dashboard)/admin/users/error.tsx new file mode 100644 index 0000000..56fd71f --- /dev/null +++ b/src/app/(dashboard)/admin/users/error.tsx @@ -0,0 +1,22 @@ +"use client" + +import { Users } from "lucide-react" +import type { ReactElement } from "react" +import { Button } from "@/shared/components/ui/button" + +export default function AdminUsersError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): ReactElement { + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/users/loading.tsx b/src/app/(dashboard)/admin/users/loading.tsx new file mode 100644 index 0000000..bc421cf --- /dev/null +++ b/src/app/(dashboard)/admin/users/loading.tsx @@ -0,0 +1,41 @@ +import { Card, CardContent } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminUsersLoading() { + return ( +
+
+
+ + +
+ +
+ + +
+ + + +
+
+
+ + +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + + + +
+ ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/users/page.tsx b/src/app/(dashboard)/admin/users/page.tsx index a6a1fc1..b8470f1 100644 --- a/src/app/(dashboard)/admin/users/page.tsx +++ b/src/app/(dashboard)/admin/users/page.tsx @@ -7,6 +7,7 @@ import { Permissions } from "@/shared/types/permissions" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import { getAdminUsers, getAdminUserRoles } from "@/modules/users/data-access" import { AdminUsersView } from "@/modules/users/components/admin-users-view" +import { getRoles } from "@/modules/rbac/data-access" export async function generateMetadata(): Promise { const t = await getTranslations("users") @@ -30,9 +31,10 @@ export default async function AdminUsersPage({ const search = getSearchParam(sp, "search") ?? "" const role = getSearchParam(sp, "role") ?? "" - const [result, roleOptions] = await Promise.all([ + const [result, roleOptions, roles] = await Promise.all([ getAdminUsers({ page, search: search || undefined, role: role || undefined }), getAdminUserRoles(), + getRoles(), ]) return ( @@ -46,6 +48,13 @@ export default async function AdminUsersPage({ totalPages={result.totalPages} search={search} roleFilter={role} + assignableRoles={roles.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + isSystem: r.isSystem, + isEnabled: r.isEnabled, + }))} />
) diff --git a/src/app/(dashboard)/announcements/[id]/page.tsx b/src/app/(dashboard)/announcements/[id]/page.tsx index 0ec042b..ee5a80e 100644 --- a/src/app/(dashboard)/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/announcements/[id]/page.tsx @@ -5,14 +5,15 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getAnnouncementById, isAnnouncementReadByUser } from "@/modules/announcements/data-access" +import { getAnnouncementByIdForUser } from "@/modules/announcements/data-access" import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail" +import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context" export const dynamic = "force-dynamic" export async function generateMetadata(): Promise { const t = await getTranslations("announcements") - return { title: t("title.detail") } + return { title: t("title.detail"), description: t("description.detail") } } export default async function AnnouncementDetailPage({ @@ -23,19 +24,20 @@ export default async function AnnouncementDetailPage({ const { id } = await params const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) - const announcement = await getAnnouncementById(id) + // P0-1 修复:在 data-access 层结合受众与 status 过滤,避免越权读取草稿/归档/他人班级公告。 + // 不可见时统一返回 404,不暴露公告存在性。 + const announcement = await getAnnouncementByIdForUser(id, ctx.userId, ctx.dataScope) if (!announcement) notFound() - // 获取当前用户的已读状态 - const isReadByCurrentUser = await isAnnouncementReadByUser(id, ctx.userId) - return ( -
- -
+ +
+ +
+
) } diff --git a/src/app/(dashboard)/announcements/loading.tsx b/src/app/(dashboard)/announcements/loading.tsx index e7236e3..24f5cc1 100644 --- a/src/app/(dashboard)/announcements/loading.tsx +++ b/src/app/(dashboard)/announcements/loading.tsx @@ -1,37 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" -import { Skeleton } from "@/shared/components/ui/skeleton" +import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton" export default function AnnouncementsLoading() { - return ( -
-
- - -
- -
- -
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - - - - - - - - -
- - -
-
-
- ))} -
-
- ) + return } diff --git a/src/app/(dashboard)/announcements/page.tsx b/src/app/(dashboard)/announcements/page.tsx index 3fca5ac..6791589 100644 --- a/src/app/(dashboard)/announcements/page.tsx +++ b/src/app/(dashboard)/announcements/page.tsx @@ -3,102 +3,68 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getAnnouncements } from "@/modules/announcements/data-access" +import { getUserAnnouncementsPageData } from "@/modules/announcements/data-access" import { AnnouncementList } from "@/modules/announcements/components/announcement-list" -import { - getStudentActiveClassId, - getStudentActiveGradeId, - getClassGradeId, -} from "@/modules/classes/data-access" +import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context" export const dynamic = "force-dynamic" export async function generateMetadata(): Promise { const t = await getTranslations("announcements") - return { title: t("title.list") } + return { title: t("title.list"), description: t("description.list") } } -/** - * 根据当前用户身份解析受众信息(gradeId / classId)。 - * - admin:返回 null(管理端可见所有公告) - * - student / teacher:使用首个 classId 并查询其 gradeId - * - grade_head / teaching_head:使用首个 gradeId - * - parent:使用首个孩子的活跃班级信息 - * - 其他:返回 null(仅显示 school 类型公告由 audience.gradeId/classId 均缺失时的兜底处理) - */ -async function resolveAudience(ctx: { - userId: string - dataScope: - | { type: "all" } - | { type: "owned"; userId: string } - | { type: "class_members"; classIds: string[] } - | { type: "grade_managed"; gradeIds: string[] } - | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } - | { type: "children"; childrenIds: string[] } -}): Promise<{ gradeId?: string; classId?: string } | null> { - const { dataScope } = ctx +const PAGE_SIZE = 12 - if (dataScope.type === "all") return null - - if (dataScope.type === "grade_managed") { - const gradeId = dataScope.gradeIds[0] - return gradeId ? { gradeId } : null - } - - if (dataScope.type === "class_members" || dataScope.type === "class_taught") { - const classId = dataScope.classIds[0] - if (!classId) return null - const gradeId = await getClassGradeId(classId) - return { classId, gradeId: gradeId ?? undefined } - } - - if (dataScope.type === "children") { - const childId = dataScope.childrenIds[0] - if (!childId) return null - const [classId, gradeId] = await Promise.all([ - getStudentActiveClassId(childId), - getStudentActiveGradeId(childId), - ]) - return { - classId: classId ?? undefined, - gradeId: gradeId ?? undefined, - } - } - - // owned / 其他:尝试用当前 userId 查询(兼容 student 角色直接访问) - const [classId, gradeId] = await Promise.all([ - getStudentActiveClassId(ctx.userId), - getStudentActiveGradeId(ctx.userId), - ]) - if (!classId && !gradeId) return null - return { - classId: classId ?? undefined, - gradeId: gradeId ?? undefined, - } -} - -export default async function AnnouncementsPage() { +export default async function AnnouncementsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string; status?: string }> +}) { const t = await getTranslations("announcements") const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) - const audience = await resolveAudience(ctx) - const announcements = await getAnnouncements({ - status: "published", - audience: audience ?? undefined, - }) + const sp = await searchParams + const requestedPage = Number(sp.page) + const page = Number.isFinite(requestedPage) && requestedPage > 0 ? Math.floor(requestedPage) : 1 + + const { items, total, page: currentPage, pageSize } = await getUserAnnouncementsPageData( + ctx.userId, + ctx.dataScope, + page, + PAGE_SIZE + ) + + // 构建分页 URL:保留 ?status= 过滤参数(与 AnnouncementList 的过滤机制一致) + const buildPageHref = (targetPage: number): string => { + const params = new URLSearchParams() + if (sp.status) params.set("status", sp.status) + if (targetPage > 1) params.set("page", String(targetPage)) + const qs = params.toString() + return qs ? `/announcements?${qs}` : "/announcements" + } return ( -
-
-

{t("title.list")}

-

- {t("description.list")} -

+ +
+
+

{t("title.list")}

+

+ {t("description.list")} +

+
+
- -
+ ) } diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index bbf4cd5..9fd1572 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -12,7 +12,7 @@ export default async function DashboardPage(): Promise { if (!session?.user) redirect("/login") const roles = (session.user.roles ?? []).filter(isRole) - const permissions = resolvePermissions(roles) + const permissions = await resolvePermissions(roles) // 按优先级匹配仪表盘权限(admin > student > parent > teacher) if (permissions.includes(Permissions.DASHBOARD_ADMIN_READ)) { diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 54680e4..e99d4b3 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -5,36 +5,17 @@ import { AiClientProvider, } from "@/modules/ai/context/ai-client-provider" import { AiAssistantWidget } from "@/modules/ai/components/ai-assistant-widget" -import { - aiChatAction, - suggestSimilarQuestionsAction, - suggestGradingAction, - generateLessonContentAction, - generateQuestionVariantAction, - analyzeWeaknessAction, - generateChildSummaryAction, - recommendStudyPathAction, - getAiUsageStatsAction, -} from "@/modules/ai/actions" -import type { AiClientService } from "@/modules/ai/types" - -const aiClientService: AiClientService = { - chat: aiChatAction, - suggestSimilarQuestions: suggestSimilarQuestionsAction, - suggestGrading: suggestGradingAction, - generateLessonContent: generateLessonContentAction, - generateQuestionVariant: generateQuestionVariantAction, - analyzeWeakness: analyzeWeaknessAction, - generateChildSummary: generateChildSummaryAction, - recommendStudyPath: recommendStudyPathAction, - getAiUsageStats: getAiUsageStatsAction, -} +import { createFullAiClientService } from "@/modules/ai/context/create-ai-client-service" export default function DashboardLayout({ children, }: { children: React.ReactNode }): React.ReactNode { + // 在 Server Component 函数体内创建 service,避免模块级单例在未来扩展请求级状态时泄露上下文。 + // service 对象本身仅是 Server Action 引用集合,无共享可变状态,Action 内部通过 requirePermission() 动态解析当前用户。 + const aiClientService = createFullAiClientService() + return ( }> diff --git a/src/app/(dashboard)/management/grade/dashboard/page.tsx b/src/app/(dashboard)/management/grade/dashboard/page.tsx index a78439a..e9f3c6b 100644 --- a/src/app/(dashboard)/management/grade/dashboard/page.tsx +++ b/src/app/(dashboard)/management/grade/dashboard/page.tsx @@ -15,6 +15,7 @@ import { GradeDistributionPanel } from "@/modules/school/components/grade-dashbo import { GradeHomeworkPanel } from "@/modules/school/components/grade-dashboard/grade-homework-panel" import { GradeExamsPanel } from "@/modules/school/components/grade-dashboard/grade-exams-panel" import { GradeProgressPanel } from "@/modules/school/components/grade-dashboard/grade-progress-panel" +import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" import { EmptyState } from "@/shared/components/ui/empty-state" import { ChipNav } from "@/shared/components/ui/chip-nav" import { BarChart3 } from "lucide-react" @@ -125,54 +126,56 @@ export default async function GradeDashboardPage({

{t("grades.gradeDashboard.description")}

- ({ id: g.id, name: g.name, schoolName: g.school.name }))} - currentGradeId={selected || "all"} - buildHref={buildHref} - /> - - {!selected ? ( - + ({ id: g.id, name: g.name, schoolName: g.school.name }))} + currentGradeId={selected || "all"} + buildHref={buildHref} /> - ) : ( -
- - - {tab === "distribution" && distributionData && ( - - )} - {tab === "homework" && homeworkData && ( - - )} - {tab === "exams" && examsData && ( - - )} - {tab === "progress" && progressData && ( - - )} - - {/* Fallback: data was null (e.g. homework insights returned null) */} - {((tab === "distribution" && !distributionData) || - (tab === "homework" && !homeworkData) || - (tab === "exams" && !examsData) || - (tab === "progress" && !progressData)) && ( - + - )} -
- )} + + {tab === "distribution" && distributionData && ( + + )} + {tab === "homework" && homeworkData && ( + + )} + {tab === "exams" && examsData && ( + + )} + {tab === "progress" && progressData && ( + + )} + + {/* Fallback: data was null (e.g. homework insights returned null) */} + {((tab === "distribution" && !distributionData) || + (tab === "homework" && !homeworkData) || + (tab === "exams" && !examsData) || + (tab === "progress" && !progressData)) && ( + + )} +
+ )} +
) } diff --git a/src/app/(dashboard)/management/grade/practice/error.tsx b/src/app/(dashboard)/management/grade/practice/error.tsx index 0d3339f..ca3caea 100644 --- a/src/app/(dashboard)/management/grade/practice/error.tsx +++ b/src/app/(dashboard)/management/grade/practice/error.tsx @@ -2,24 +2,38 @@ import { useEffect } from "react" import { BarChart3 } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" -export default function Error() { +export default function GradePracticeError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): React.ReactNode { + const t = useTranslations("practice") useEffect(() => { - console.error("Grade practice analytics page error") - }, []) + console.error("Grade practice analytics page error:", error) + }, [error]) return (
-

年级专项练习总览

-

加载年级练习数据时发生错误

+

+ {t("errors.pageErrorTitleGrade")} +

+

{t("errors.pageErrorGrade")}

reset(), + }} className="h-[360px] bg-card" />
diff --git a/src/app/(dashboard)/messages/[id]/page.tsx b/src/app/(dashboard)/messages/[id]/page.tsx index 1133985..258b9b6 100644 --- a/src/app/(dashboard)/messages/[id]/page.tsx +++ b/src/app/(dashboard)/messages/[id]/page.tsx @@ -1,11 +1,14 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" import type { JSX } from "react" +import { Suspense } from "react" import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" import { getMessageDetailPageData } from "@/modules/messaging/data-access" import { MessageDetail } from "@/modules/messaging/components/message-detail" +import { Skeleton } from "@/shared/components/ui/skeleton" export const dynamic = "force-dynamic" @@ -14,6 +17,24 @@ export async function generateMetadata(): Promise { return { title: t("title.detail") } } +/** + * P2-9: 消息详情骨架屏。 + */ +function MessageDetailSkeleton(): JSX.Element { + return ( +
+ + + +
+ + + +
+
+ ) +} + export default async function MessageDetailPage({ params, }: { @@ -27,7 +48,12 @@ export default async function MessageDetailPage({ return (
- + {/* P2-8 + P2-9: 详情区块 ErrorBoundary 包裹 */} + + }> + + +
) } diff --git a/src/app/(dashboard)/messages/compose/page.tsx b/src/app/(dashboard)/messages/compose/page.tsx index f9044f5..7b7489a 100644 --- a/src/app/(dashboard)/messages/compose/page.tsx +++ b/src/app/(dashboard)/messages/compose/page.tsx @@ -3,8 +3,10 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getRecipients } from "@/modules/messaging/data-access" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" +import { getRecipients, getMessageDrafts } from "@/modules/messaging/data-access" import { MessageCompose } from "@/modules/messaging/components/message-compose" +import { MessageDraftList } from "@/modules/messaging/components/message-draft-list" export const dynamic = "force-dynamic" @@ -25,7 +27,11 @@ export default async function ComposeMessagePage({ const sp = await searchParams const t = await getTranslations("messages") - const recipients = await getRecipients(ctx.userId, ctx.dataScope) + const [recipients, drafts] = await Promise.all([ + getRecipients(ctx.userId, ctx.dataScope), + // P1-1: 获取草稿列表用于展示与恢复编辑 + getMessageDrafts(ctx.userId), + ]) return (
@@ -34,12 +40,20 @@ export default async function ComposeMessagePage({

{t("title.compose")}

{t("description.compose")}

- + {/* P2-8: 撰写表单区块独立 ErrorBoundary,失败不影响草稿列表 */} + + + + {/* P2-8: 草稿列表区块独立 ErrorBoundary */} + + {/* P1-1: 草稿列表 - 允许用户查看、继续编辑、删除未发送的草稿 */} + +
) diff --git a/src/app/(dashboard)/messages/group-compose/error.tsx b/src/app/(dashboard)/messages/group-compose/error.tsx new file mode 100644 index 0000000..f9443f7 --- /dev/null +++ b/src/app/(dashboard)/messages/group-compose/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function GroupComposeMessageError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("messages") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/messages/group-compose/loading.tsx b/src/app/(dashboard)/messages/group-compose/loading.tsx new file mode 100644 index 0000000..d62f386 --- /dev/null +++ b/src/app/(dashboard)/messages/group-compose/loading.tsx @@ -0,0 +1,40 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function GroupComposeLoading() { + return ( +
+
+
+ + +
+ + + + + + +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/messages/group-compose/page.tsx b/src/app/(dashboard)/messages/group-compose/page.tsx new file mode 100644 index 0000000..6c6baf7 --- /dev/null +++ b/src/app/(dashboard)/messages/group-compose/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" +import { getClassNamesByIds } from "@/modules/classes/data-access" + +import { MessageGroupCompose } from "@/modules/messaging/components/message-group-compose" + +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const t = await getTranslations("messages") + return { + title: t("title.groupCompose"), + description: t("description.groupCompose"), + } +} + +export default async function GroupComposeMessagePage(): Promise { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const t = await getTranslations("messages") + + // P2-2: 群发仅限教师(class_taught DataScope);其他角色无权访问此页面 + if (ctx.dataScope.type !== "class_taught") { + notFound() + } + + const classIds = ctx.dataScope.classIds + const classNameMap = await getClassNamesByIds(classIds) + const classOptions = classIds.map((id) => ({ + id, + name: classNameMap.get(id) ?? id, + })) + + return ( +
+
+
+

{t("title.groupCompose")}

+

{t("description.groupCompose")}

+
+ + + +
+
+ ) +} diff --git a/src/app/(dashboard)/messages/page.tsx b/src/app/(dashboard)/messages/page.tsx index 55239b9..334e416 100644 --- a/src/app/(dashboard)/messages/page.tsx +++ b/src/app/(dashboard)/messages/page.tsx @@ -1,10 +1,16 @@ +import { Suspense } from "react" +import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getMessagesPageData } from "@/modules/messaging/data-access" -import { MessageList } from "@/modules/messaging/components/message-list" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" +import { getNotifications } from "@/modules/notifications/data-access" import { NotificationList } from "@/modules/notifications/components/notification-list" +import { MessageListSection } from "@/modules/messaging/components/message-list-section" +import { MessageListSkeleton } from "@/modules/messaging/components/message-list-skeleton" +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" export const dynamic = "force-dynamic" @@ -13,13 +19,40 @@ export async function generateMetadata() { return { title: t("title.list") } } -export default async function MessagesPage() { +/** + * P2-9: 通知列表区块骨架屏(Suspense fallback)。 + */ +function NotificationListSkeleton(): JSX.Element { + return ( +
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) +} + +/** + * P2-9: 通知列表区块(Server Component,流式加载)。 + */ +async function NotificationListSection({ userId }: { userId: string }): Promise { + const result = await getNotifications(userId, { page: 1, pageSize: 20 }) + return +} + +export default async function MessagesPage(): Promise { const t = await getTranslations("messages") const ctx = await requirePermission(Permissions.MESSAGE_READ) - const { messages: messagesResult, notifications: notificationsResult } = - await getMessagesPageData(ctx.userId) - return (
@@ -29,9 +62,19 @@ export default async function MessagesPage() {

- + {/* P2-8 + P2-9: 消息列表区块独立 Suspense + ErrorBoundary,局部失败不影响通知区块 */} + }> + + + + - + {/* P2-8 + P2-9: 通知列表区块独立 Suspense + ErrorBoundary */} + }> + + + +
) } diff --git a/src/app/(dashboard)/parent/attendance/error.tsx b/src/app/(dashboard)/parent/attendance/error.tsx index 326af9b..ee5536f 100644 --- a/src/app/(dashboard)/parent/attendance/error.tsx +++ b/src/app/(dashboard)/parent/attendance/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function ParentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("attendance") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/parent/attendance/page.tsx b/src/app/(dashboard)/parent/attendance/page.tsx index 9dfc901..1291887 100644 --- a/src/app/(dashboard)/parent/attendance/page.tsx +++ b/src/app/(dashboard)/parent/attendance/page.tsx @@ -1,7 +1,9 @@ import { getTranslations } from "next-intl/server" import { getAuthContext } from "@/shared/lib/auth-guard" -import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats" -import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view" +import { CalendarCheck } from "lucide-react" + +import { createAttendanceReadService } from "@/modules/attendance/services/attendance-data-service" +import type { AttendanceReadService } from "@/modules/attendance/services/types" import { ParentChildrenDataPage, ParentNoChildrenPage, @@ -9,10 +11,39 @@ import { import { ParentAttendanceWarning } from "@/modules/parent/components/parent-attendance-warning" import { ParentAttendanceRateCard } from "@/modules/parent/components/parent-attendance-rate-card" import { ParentAttendanceCalendar } from "@/modules/parent/components/parent-attendance-calendar" -import { CalendarCheck } from "lucide-react" +import { ParentStudentAttendanceDetail } from "@/modules/parent/components/parent-student-attendance-detail" +import type { ParentStudentAttendanceSummary } from "@/modules/parent/types" export const dynamic = "force-dynamic" +/** + * 将 attendance 模块的 StudentAttendanceSummary 映射为 parent 模块的视图模型。 + * parent 模块仅消费自身类型,不依赖 attendance 内部类型变更(P1-2 解耦)。 + */ +function toParentSummary( + summary: Awaited>, +): ParentStudentAttendanceSummary | null { + if (!summary) return null + return { + studentId: summary.studentId, + studentName: summary.studentName, + stats: { + total: summary.stats.total, + present: summary.stats.present, + absent: summary.stats.absent, + late: summary.stats.late, + presentRate: summary.stats.presentRate, + }, + recentRecords: summary.recentRecords.map((r) => ({ + id: r.id, + date: r.date, + status: r.status, + reason: r.reason, + remark: r.remark, + })), + } +} + export default async function ParentAttendancePage() { const t = await getTranslations("attendance") const ctx = await getAuthContext() @@ -29,16 +60,18 @@ export default async function ParentAttendancePage() { ) } + // 通过接口抽象消费 attendance 数据(P1-2 修复:不再直接 import data-access) + const attendanceService = createAttendanceReadService(ctx.dataScope) + // 使用 allSettled 容错:单个子女查询失败不影响其他子女展示 const results = await Promise.allSettled( - ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id)), + ctx.dataScope.childrenIds.map((id) => attendanceService.getStudentSummary(id)), ) const validSummaries = results - .filter( - (r): r is PromiseFulfilledResult>>> => - r.status === "fulfilled" && r.value !== null, + .map((r) => + r.status === "fulfilled" ? toParentSummary(r.value) : null, ) - .map((r) => r.value) + .filter((s): s is ParentStudentAttendanceSummary => s !== null) return (

{summary.studentName}

- +
)} headerExtra={ diff --git a/src/app/(dashboard)/parent/course-plans/[id]/error.tsx b/src/app/(dashboard)/parent/course-plans/[id]/error.tsx new file mode 100644 index 0000000..2b8503a --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/[id]/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentCoursePlanDetailError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/course-plans/[id]/loading.tsx b/src/app/(dashboard)/parent/course-plans/[id]/loading.tsx new file mode 100644 index 0000000..13b8bbe --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/[id]/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentCoursePlanDetailLoading() { + return ( +
+
+ +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/course-plans/[id]/page.tsx b/src/app/(dashboard)/parent/course-plans/[id]/page.tsx new file mode 100644 index 0000000..cc706dc --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/[id]/page.tsx @@ -0,0 +1,45 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getCoursePlanById } from "@/modules/course-plans/data-access" +import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail" +import { getStudentActiveClassId } from "@/modules/classes/data-access" + +export const dynamic = "force-dynamic" + +export default async function ParentCoursePlanDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) + const { id } = await params + + // 家长视角:解析所有孩子的班级 ID,仅允许查看孩子所在班级的计划 + let classIds: string[] = [] + if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) { + const results = await Promise.all( + ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid)) + ) + classIds = results.filter((cid): cid is string => cid !== null) + } + + const plan = + classIds.length > 0 + ? await getCoursePlanById(id, { userId: ctx.userId, isAdmin: false, classIds }) + : null + + if (!plan) notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/parent/course-plans/error.tsx b/src/app/(dashboard)/parent/course-plans/error.tsx new file mode 100644 index 0000000..ef3e4e7 --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentCoursePlansError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/course-plans/loading.tsx b/src/app/(dashboard)/parent/course-plans/loading.tsx new file mode 100644 index 0000000..fa96a9b --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentCoursePlansLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/course-plans/page.tsx b/src/app/(dashboard)/parent/course-plans/page.tsx new file mode 100644 index 0000000..1e1fb9e --- /dev/null +++ b/src/app/(dashboard)/parent/course-plans/page.tsx @@ -0,0 +1,45 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getCoursePlans } from "@/modules/course-plans/data-access" +import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list" +import { getStudentActiveClassId } from "@/modules/classes/data-access" + +export const dynamic = "force-dynamic" + +export default async function ParentCoursePlansPage(): Promise { + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) + const t = await getTranslations("coursePlans") + + // 家长视角:解析所有孩子的班级 ID,用于过滤课程计划 + let classIds: string[] = [] + if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) { + const results = await Promise.all( + ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid)) + ) + classIds = results.filter((id): id is string => id !== null) + } + + const plans = + classIds.length > 0 + ? await getCoursePlans( + { status: "active" }, + { userId: ctx.userId, isAdmin: false, classIds } + ) + : [] + + return ( +
+
+

{t("parent.title")}

+

{t("parent.description")}

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/page.tsx b/src/app/(dashboard)/parent/dashboard/page.tsx index fac447f..2861091 100644 --- a/src/app/(dashboard)/parent/dashboard/page.tsx +++ b/src/app/(dashboard)/parent/dashboard/page.tsx @@ -5,7 +5,10 @@ import { use } from "react" import { Users } from "lucide-react" import { getParentDashboardAction } from "@/modules/dashboard/actions" -import { ParentDashboard } from "@/modules/parent/components/parent-dashboard" +import { ParentDashboard } from "@/modules/dashboard/components/parent-dashboard/parent-dashboard" +import { AiChildSummary } from "@/modules/ai/components/ai-child-summary" +import { ChildCard } from "@/modules/parent/components/child-card" +import { ParentAttentionBanner } from "@/modules/parent/components/parent-attention-banner" import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page" import type { ActionState } from "@/shared/types/action-state" import type { ParentDashboardData } from "@/modules/parent/types" @@ -62,9 +65,67 @@ async function ParentDashboardBody({ ) } + const { parentName, children } = data + + // 组合注入:子女卡片列表(移动端水平滑动 + 桌面端网格) + const childrenSlot = ( + <> +
+ {children.map((child) => ( +
+ +
+ ))} +
+
+ {children.map((child) => ( + + ))} +
+ + ) + + // AI 学情摘要区域:每个子女一张卡片 + // AiChildSummary 是客户端组件,由 AiClientProvider(dashboard layout 注入)提供数据服务 + // 仅传入已知安全字段(studentId / studentName / grade / homeworkCompletionRate), + // recentGrades 字段因当前数据源不含 subject 信息暂不传,AI 仍可基于基本信息生成摘要 + const aiSummarySlot = ( +
+

{t("title.parent")}

+
+ {children.map((child) => { + const { pendingCount, submittedCount, gradedCount, overdueCount } = child.homeworkSummary + const totalHomework = pendingCount + submittedCount + gradedCount + overdueCount + const homeworkCompletionRate = totalHomework > 0 + ? (submittedCount + gradedCount) / totalHomework + : undefined + return ( + + ) + })} +
+
+ ) + return (
- + } + aiSummarySlot={aiSummarySlot} + />
) } + diff --git a/src/app/(dashboard)/parent/diagnostic/error.tsx b/src/app/(dashboard)/parent/diagnostic/error.tsx index b195dd9..dde1679 100644 --- a/src/app/(dashboard)/parent/diagnostic/error.tsx +++ b/src/app/(dashboard)/parent/diagnostic/error.tsx @@ -1,6 +1,7 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -10,14 +11,15 @@ export default function ParentDiagnosticError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("diagnostic") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/parent/diagnostic/page.tsx b/src/app/(dashboard)/parent/diagnostic/page.tsx index db15bfe..6f0e376 100644 --- a/src/app/(dashboard)/parent/diagnostic/page.tsx +++ b/src/app/(dashboard)/parent/diagnostic/page.tsx @@ -93,8 +93,8 @@ export default async function ParentDiagnosticPage() { title={t("parent.title")} description={t("parent.description")} icon={Stethoscope} - noRecordsTitle={t("parent.noReports")} - noRecordsDescription={t("parent.noReports")} + noRecordsTitle={t("parent.noReportsTitle")} + noRecordsDescription={t("parent.noRecordsDescription")} items={items} renderItem={(item) => ( <> @@ -105,7 +105,7 @@ export default async function ParentDiagnosticPage() { ) : ( // v4-P1-9: 错误卡片,提示家长该子女数据加载失败 @@ -113,10 +113,10 @@ export default async function ParentDiagnosticPage() { diff --git a/src/app/(dashboard)/parent/elective/error.tsx b/src/app/(dashboard)/parent/elective/error.tsx new file mode 100644 index 0000000..aceb8df --- /dev/null +++ b/src/app/(dashboard)/parent/elective/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("elective") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/elective/loading.tsx b/src/app/(dashboard)/parent/elective/loading.tsx new file mode 100644 index 0000000..73931c7 --- /dev/null +++ b/src/app/(dashboard)/parent/elective/loading.tsx @@ -0,0 +1,31 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + +
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/elective/page.tsx b/src/app/(dashboard)/parent/elective/page.tsx new file mode 100644 index 0000000..09bd0b3 --- /dev/null +++ b/src/app/(dashboard)/parent/elective/page.tsx @@ -0,0 +1,81 @@ +import { BookOpen } from "lucide-react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { + getChildren, + getChildBasicInfo, +} from "@/modules/parent/data-access" +import { getStudentSelections } from "@/modules/elective/data-access-selections" +import { + ParentChildrenDataPage, + ParentNoChildrenPage, +} from "@/modules/parent/components/parent-children-data-page" +import { ParentSelectionView } from "@/modules/elective/components/parent-selection-view" + +export const dynamic = "force-dynamic" + +interface ChildSelectionData { + studentId: string + studentName: string + selections: Awaited> +} + +export default async function ParentElectivePage() { + const t = await getTranslations("elective") + const ctx = await requirePermission(Permissions.ELECTIVE_READ) + const parentId = ctx.userId + + // dataScope 校验:家长必须有关联子女才能查看选课信息 + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( + + ) + } + + // 并行拉取每个子女的选课记录,使用 allSettled 容错 + const relations = await getChildren(parentId) + const results = await Promise.allSettled( + relations.map(async (r): Promise => { + // 双重校验:parentId + studentId 必须存在关联关系,防止跨家庭信息泄露 + const basicInfo = await getChildBasicInfo(r.studentId, r.relation) + const selections = await getStudentSelections(r.studentId) + return { + studentId: r.studentId, + studentName: basicInfo?.name ?? r.studentId, + selections, + } + }) + ) + + const validItems = results + .filter( + (r): r is PromiseFulfilledResult => + r.status === "fulfilled" + ) + .map((r) => r.value) + + return ( + ( + + )} + /> + ) +} diff --git a/src/app/(dashboard)/parent/error-book/page.tsx b/src/app/(dashboard)/parent/error-book/page.tsx index 9cbeda1..eabe7e1 100644 --- a/src/app/(dashboard)/parent/error-book/page.tsx +++ b/src/app/(dashboard)/parent/error-book/page.tsx @@ -7,14 +7,15 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Progress } from "@/shared/components/ui/progress" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { formatNumber } from "@/shared/lib/utils" +import { getErrorBookStats } from "@/modules/error-book/data-access" import { - getErrorBookStats, getStudentNameMap, getTopWrongQuestionsByStudentIds, getKnowledgePointWeakness, -} from "@/modules/error-book/data-access" +} from "@/modules/error-book/data-access-analytics" import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards" import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" import { getTranslations } from "next-intl/server" @@ -64,77 +65,85 @@ export default async function ParentErrorBookPage(): Promise { {childrenIds.length === 1 ? ( // 单子女:直接展示统计卡片 - + + + ) : ( // 多子女:每个子女一张卡片 -
- {childrenIds.map((childId, idx) => { - const stats = childStatsList[idx] - const name = nameMap.get(childId) ?? t("parent.unknown") - return ( - - - - {name} - - {t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })} - - - - -
-
-
{t("parent.totalErrors")}
-
{stats.totalCount}
-
-
-
{t("parent.dueReview")}
-
- {stats.dueReviewCount} + +
+ {childrenIds.map((childId, idx) => { + const stats = childStatsList[idx] + const name = nameMap.get(childId) ?? t("parent.unknown") + return ( + + + + {name} + + {t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })} + + + + +
+
+
{t("parent.totalErrors")}
+
{stats.totalCount}
+
+
+
{t("parent.dueReview")}
+
+ {stats.dueReviewCount} +
+
+
+
{t("parent.newItems")}
+
{stats.newCount}
+
+
+
{t("parent.mastered")}
+
{stats.masteredCount}
-
-
{t("parent.newItems")}
-
{stats.newCount}
-
-
-
{t("parent.mastered")}
-
{stats.masteredCount}
-
-
- - - - ) - })} -
+ + + + ) + })} +
+ )} {/* 薄弱知识点 */} {weakKps.length > 0 ? ( - - - {t("parent.weakPoints")} - - -
- {weakKps.map((kp) => ( -
-
- {kp.knowledgePointName} - - {t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })} - + + + + {t("parent.weakPoints")} + + +
+ {weakKps.map((kp) => ( +
+
+ {kp.knowledgePointName} + + {t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })} + +
+
- -
- ))} -
- - + ))} +
+ + + ) : null} - + + +
) } diff --git a/src/app/(dashboard)/parent/grades/error.tsx b/src/app/(dashboard)/parent/grades/error.tsx index be5c1ae..03aff2c 100644 --- a/src/app/(dashboard)/parent/grades/error.tsx +++ b/src/app/(dashboard)/parent/grades/error.tsx @@ -1,6 +1,8 @@ "use client" +import type { JSX } from "react" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -9,15 +11,16 @@ export default function ParentGradesError({ }: { error: Error & { digest?: string } reset: () => void -}) { +}): JSX.Element { + const t = useTranslations("grades") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/parent/grades/loading.tsx b/src/app/(dashboard)/parent/grades/loading.tsx index bb1a447..ada9f1f 100644 --- a/src/app/(dashboard)/parent/grades/loading.tsx +++ b/src/app/(dashboard)/parent/grades/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/parent/grades/page.tsx b/src/app/(dashboard)/parent/grades/page.tsx index a11e5e9..b00044d 100644 --- a/src/app/(dashboard)/parent/grades/page.tsx +++ b/src/app/(dashboard)/parent/grades/page.tsx @@ -2,15 +2,17 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getStudentGradeSummary } from "@/modules/grades/data-access" import { getClassAverageTrend } from "@/modules/grades/data-access-ranking" +import { getStudentGrowthArchive } from "@/modules/grades/data-access-analytics" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" +import { GrowthArchiveChart } from "@/modules/grades/components/growth-archive-chart" import { ParentChildrenDataPage, ParentNoChildrenPage, } from "@/modules/parent/components/parent-children-data-page" import { ParentExportButton } from "@/modules/parent/components/parent-export-button" import { GraduationCap } from "lucide-react" -import type { ClassAverageTrendResult } from "@/modules/grades/types" +import type { ClassAverageTrendResult, StudentGrowthArchiveResult } from "@/modules/grades/types" import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -19,6 +21,7 @@ interface ChildGradeItem { studentId: string summary: NonNullable>> classAverageTrend: ClassAverageTrendResult | null + growthArchive: StudentGrowthArchiveResult | null } export default async function ParentGradesPage() { @@ -40,29 +43,24 @@ export default async function ParentGradesPage() { // 使用 allSettled 容错:单个子女查询失败不影响其他子女展示 const results = await Promise.allSettled( ctx.dataScope.childrenIds.map(async (id) => { - const [summary, classAverageTrend] = await Promise.all([ + const [summary, classAverageTrend, growthArchive] = await Promise.all([ getStudentGradeSummary(id, ctx.dataScope), // v3-P2-8:家长页面补齐趋势图,复用班级平均对比线 getClassAverageTrend(id, undefined, undefined, ctx.dataScope), + // P3-4:子女纵向成长档案 + getStudentGrowthArchive(id, ctx.dataScope), ]) - return { summary, classAverageTrend, studentId: id } + return { summary, classAverageTrend, growthArchive, studentId: id } }), ) - const validItems: ChildGradeItem[] = results - .filter( - ( - r, - ): r is PromiseFulfilledResult<{ - summary: Awaited> - classAverageTrend: ClassAverageTrendResult | null - studentId: string - }> => r.status === "fulfilled" && r.value.summary !== null, - ) - .map((r) => ({ - studentId: r.value.studentId, - summary: r.value.summary as NonNullable, - classAverageTrend: r.value.classAverageTrend, - })) + // P1-8 修复:用循环 + 类型守卫替代 `as` 断言 + const validItems: ChildGradeItem[] = [] + for (const r of results) { + if (r.status !== "fulfilled") continue + const { summary, classAverageTrend, growthArchive, studentId } = r.value + if (summary === null) continue + validItems.push({ studentId, summary, classAverageTrend, growthArchive }) + } return ( ( + renderItem={({ studentId, summary, classAverageTrend, growthArchive }) => ( <>

{summary.studentName}

@@ -82,6 +80,8 @@ export default async function ParentGradesPage() { {summary.records.length > 0 && ( )} + {/* P3-4:子女纵向成长档案 */} + )} diff --git a/src/app/(dashboard)/parent/grades/report-card/error.tsx b/src/app/(dashboard)/parent/grades/report-card/error.tsx new file mode 100644 index 0000000..f86c105 --- /dev/null +++ b/src/app/(dashboard)/parent/grades/report-card/error.tsx @@ -0,0 +1,35 @@ +"use client" + +import type { JSX } from "react" +import { useEffect } from "react" +import { AlertTriangle } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { useTranslations } from "next-intl" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): JSX.Element { + const t = useTranslations("grades") + useEffect(() => { + console.error("[ReportCard] Route error:", error) + }, [error]) + + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/grades/report-card/loading.tsx b/src/app/(dashboard)/parent/grades/report-card/loading.tsx new file mode 100644 index 0000000..0268bf4 --- /dev/null +++ b/src/app/(dashboard)/parent/grades/report-card/loading.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from "lucide-react" +import { getTranslations } from "next-intl/server" + +export default async function Loading() { + const t = await getTranslations("grades") + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/grades/report-card/page.tsx b/src/app/(dashboard)/parent/grades/report-card/page.tsx new file mode 100644 index 0000000..71f53f9 --- /dev/null +++ b/src/app/(dashboard)/parent/grades/report-card/page.tsx @@ -0,0 +1,116 @@ +import type { JSX } from "react" +import { ArrowLeft } from "lucide-react" +import Link from "next/link" +import { getTranslations } from "next-intl/server" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getParam, type SearchParams } from "@/shared/lib/search-params" + +import { getReportCardData } from "@/modules/grades/lib/report-card" +import { ReportCardView } from "@/modules/grades/components/report-card-view" +import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action" +import { getAcademicYears } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +/** + * P3-1: 家长视角的子女成绩报告卡页面。 + * + * 路由:/parent/grades/report-card?studentId=xxx + * 查询参数: + * - studentId: 必填,目标学生 ID(必须在家长子女范围内) + * - academicYearId?: 指定学年 + * - semester?: "1" | "2" + * + * 权限:GRADE_RECORD_READ(children scope 在 data-access 层校验子女归属) + */ +export default async function ParentReportCardPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const sp = await searchParams + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") + + const studentId = getParam(sp, "studentId") + const academicYearIdParam = getParam(sp, "academicYearId") + const semesterParam = getParam(sp, "semester") + const academicYearId = + academicYearIdParam && academicYearIdParam !== "all" + ? academicYearIdParam + : undefined + const semester = + semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined + + if (!studentId) { + return ( +
+ + +
+ ) + } + + const [data, academicYears] = await Promise.all([ + getReportCardData(studentId, ctx.dataScope, { + academicYearId, + semester, + }), + getAcademicYears(), + ]) + + if (!data) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + +
+ + + +

+ {t("reportCard.academicYearsCount", { + count: academicYears.length, + })} +

+
+ ) +} diff --git a/src/app/(dashboard)/parent/leave/page.tsx b/src/app/(dashboard)/parent/leave/page.tsx index ca15932..e36357c 100644 --- a/src/app/(dashboard)/parent/leave/page.tsx +++ b/src/app/(dashboard)/parent/leave/page.tsx @@ -1,23 +1,80 @@ import Link from "next/link" -import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react" +import { getTranslations } from "next-intl/server" +import { ArrowLeft, CalendarDays } from "lucide-react" +import { getAuthContext } from "@/shared/lib/auth-guard" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { EmptyState } from "@/shared/components/ui/empty-state" -import { getTranslations } from "next-intl/server" +import { getChildren, getChildBasicInfo } from "@/modules/parent/data-access" +import { getLeaveRequests } from "@/modules/leave-requests/data-access" +import { + LeaveRequestForm, + type ChildOption, +} from "@/modules/leave-requests/components/leave-request-form" +import { LeaveRequestList } from "@/modules/leave-requests/components/leave-request-list" export const dynamic = "force-dynamic" +/** + * 家长在线请假页面。 + * + * L-5 功能: + * - 顶部:请假申请表单(下拉选择子女,自动写入 classId) + * - 底部:该家长所有子女的请假申请列表(按 dataScope=children 过滤) + */ export default async function ParentLeavePage() { const t = await getTranslations("leave") + const ctx = await getAuthContext() + + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( +
+
+

{t("title.parent")}

+

{t("description.parent")}

+
+ + + {t("empty.parentDesc")} + + + +
+ ) + } + + // 并行:子女关系列表 + 已有请假申请列表 + const [relations, leaveResult] = await Promise.all([ + getChildren(ctx.userId), + getLeaveRequests({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + page: 1, + pageSize: 50, + }), + ]) + + // 构造子女选项(含 activeClass),供表单下拉使用 + const childOptions: ChildOption[] = [] + for (const r of relations) { + const basic = await getChildBasicInfo(r.studentId, r.relation) + if (basic && basic.classId && basic.className) { + childOptions.push({ + id: basic.id, + name: basic.name ?? "Unknown", + classId: basic.classId, + className: basic.className, + }) + } + } return (
-

{t("title")}

-

- {t("description")} -

+

{t("title.parent")}

+

{t("description.parent")}

- - - - - {t("onlineLeave")} - - - - -
-
{t("contactOptions")}
-
    -
  • - - {t("callOffice")} -
  • -
  • - - {t("sendMessage")} -
  • -
  • - - {t("goToMessages")} - -
  • -
-
-
-
+ {childOptions.length > 0 ? ( + + + + + {t("onlineLeave")} + + + + + + + ) : ( + + + {t("empty.parentDesc")} + + + )} + +
+

{t("onlineLeave")}

+ +
) } diff --git a/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx index d8d5d75..bc7c64b 100644 --- a/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx +++ b/src/app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx @@ -1,9 +1,12 @@ import type { JSX } from "react" import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { notFound } from "next/navigation" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" -import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { assertPlanInScope } from "@/modules/lesson-preparation/lib/scope-check" +import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access" import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" import { Skeleton } from "@/shared/components/ui/skeleton" @@ -16,17 +19,17 @@ export default async function ParentLessonPlanViewPage({ }): Promise { const { planId } = await params const t = await getTranslations("lessonPreparation") - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) const plan = await getLessonPlanById(planId, ctx.userId) - if (!plan) { - return ( -
-
- {t("readonly.notFound")} -
-
- ) + if (!plan) notFound() + + // V4 P0-1 修复:家长仅可查看孩子所在年级的已发布课案,防止跨年级信息泄露 + try { + assertPlanInScope(plan, ctx) + } catch { + notFound() } if (plan.status !== "published") { @@ -42,21 +45,14 @@ export default async function ParentLessonPlanViewPage({ let textbookTitle: string | undefined let chapterTitle: string | undefined if (plan.textbookId) { - const textbook = await getTextbookById(plan.textbookId) + // V4 P2-1 修复:textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询 + const [textbook, chapters] = await Promise.all([ + getTextbookById(plan.textbookId), + getChaptersByTextbookId(plan.textbookId), + ]) textbookTitle = textbook?.title if (plan.chapterId) { - const chapters = await getChaptersByTextbookId(plan.textbookId) - const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { - for (const ch of list) { - if (ch.id === plan.chapterId) return ch - if (ch.children && ch.children.length > 0) { - const found = findChapter(ch.children as typeof chapters) - if (found) return found - } - } - return undefined - } - const chapter = findChapter(chapters) + const chapter = findChapterById(chapters, plan.chapterId) chapterTitle = chapter?.title } } diff --git a/src/app/(dashboard)/parent/lesson-plans/page.tsx b/src/app/(dashboard)/parent/lesson-plans/page.tsx index d238aa3..12fc8f7 100644 --- a/src/app/(dashboard)/parent/lesson-plans/page.tsx +++ b/src/app/(dashboard)/parent/lesson-plans/page.tsx @@ -1,17 +1,21 @@ import type { JSX } from "react" import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlans } from "@/modules/lesson-preparation/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" +import { PARENT_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider" import { Skeleton } from "@/shared/components/ui/skeleton" export const dynamic = "force-dynamic" export default async function ParentLessonPlansPage(): Promise { const t = await getTranslations("lessonPreparation") - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) const [items, subjects] = await Promise.all([ getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId), @@ -24,21 +28,24 @@ export default async function ParentLessonPlansPage(): Promise {

{t("parent.title")}

{t("parent.description")}

- - {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- } - > - - + {/* P0-13 修复:包裹 LessonPlanProviderSetup,注入 parent 角色配置,使筛选功能生效 */} + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + + +
) } diff --git a/src/app/(dashboard)/parent/practice/error.tsx b/src/app/(dashboard)/parent/practice/error.tsx new file mode 100644 index 0000000..5888e7a --- /dev/null +++ b/src/app/(dashboard)/parent/practice/error.tsx @@ -0,0 +1,30 @@ +"use client" + +import type { JSX } from "react" +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ParentPracticeError({ + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): JSX.Element { + const t = useTranslations("practice") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/practice/loading.tsx b/src/app/(dashboard)/parent/practice/loading.tsx new file mode 100644 index 0000000..985cbde --- /dev/null +++ b/src/app/(dashboard)/parent/practice/loading.tsx @@ -0,0 +1,33 @@ +import type { JSX } from "react" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function ParentPracticeLoading(): JSX.Element { + return ( +
+
+ + +
+ {/* 默认按多子女布局骨架 */} +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+
+ +
+
+ {Array.from({ length: 4 }).map((_, j) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, j) => ( + + ))} +
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/practice/page.tsx b/src/app/(dashboard)/parent/practice/page.tsx new file mode 100644 index 0000000..6dbaa0c --- /dev/null +++ b/src/app/(dashboard)/parent/practice/page.tsx @@ -0,0 +1,127 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" +import { Target, Users } from "lucide-react" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { WidgetBoundary } from "@/shared/components/widget-boundary" + +import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access" +import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history" +import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards" +import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service" +import type { PracticeSessionSummary, PracticeStats } from "@/modules/adaptive-practice/types" +import { getUserNamesByIds } from "@/modules/users/data-access" + +export const dynamic = "force-dynamic" + +interface ChildPracticeItem { + studentId: string + studentName: string + stats: PracticeStats + sessions: PracticeSessionSummary[] +} + +export default async function ParentPracticePage(): Promise { + const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) + const t = await getTranslations("practice") + + // 家长 dataScope 必须为 children;否则提示无关联子女 + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( +
+
+

{t("parent.title")}

+

{t("parent.description")}

+
+ +
+ ) + } + + const childrenIds = ctx.dataScope.childrenIds + + // 并行查询:姓名映射 + 每个子女的统计与历史 + // 使用 allSettled 容错:单个子女查询失败不影响其他子女展示 + // 注意:nameMapPromise 与 childResults 并行启动,但类型上分离以利于类型收窄 + const nameMapPromise = getUserNamesByIds(childrenIds) + const childResults = await Promise.allSettled( + childrenIds.map(async (id) => { + const [stats, sessionsResult] = await Promise.all([ + getPracticeStats(id), + getPracticeSessions(id, { pageSize: 20 }), + ]) + return { studentId: id, stats, sessions: sessionsResult.data } + }), + ) + const nameMap = await nameMapPromise + + // 过滤掉 rejected 的查询结果 + const validItems: ChildPracticeItem[] = [] + for (const r of childResults) { + if (r.status !== "fulfilled") continue + const { studentId, stats, sessions } = r.value + const studentName = nameMap.get(studentId)?.name ?? t("parent.noChildSelected") + validItems.push({ studentId, studentName, stats, sessions }) + } + + // 单子女:直接展示;多子女:按卡片分组 + const isSingleChild = validItems.length === 1 + + return ( + +
+
+

{t("parent.title")}

+

{t("parent.description")}

+
+ + {isSingleChild ? ( + <> + + + +
+

{t("parent.history")}

+ + {/* 家长无会话详情页,仅展示只读卡片 */} + + +
+ + ) : ( +
+ {validItems.map((item) => ( + +
+
+
+ +
+

+ {t("parent.history")} +

+ {/* 家长无会话详情页,仅展示只读卡片 */} + +
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 99876d7..4843e05 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -10,6 +10,7 @@ import { AvatarUpload } from "@/modules/settings/components/avatar-upload" import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview" import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview" import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary" +import { resolveProfileOverviewType } from "@/modules/settings/config/profile-overview-config" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -19,7 +20,7 @@ import { formatDate } from "@/shared/lib/utils" export const dynamic = "force-dynamic" export async function generateMetadata() { - const t = await getTranslations("settings.profilePage") + const t = await getTranslations("settings.profile.page") return { title: t("title") } } @@ -34,9 +35,8 @@ export default async function ProfilePage(): Promise { } const roles = ctx.roles - const isStudent = roles.includes("student") - const isTeacher = roles.includes("teacher") - const t = await getTranslations("settings.profilePage") + const overviewType = resolveProfileOverviewType(roles) + const t = await getTranslations("settings.profile.page") return (
@@ -50,11 +50,13 @@ export default async function ProfilePage(): Promise { } /> - + + +
@@ -139,7 +141,7 @@ export default async function ProfilePage(): Promise {
- {isStudent ? ( + {overviewType === "student" ? ( }> @@ -147,7 +149,7 @@ export default async function ProfilePage(): Promise { ) : null} - {isTeacher ? ( + {overviewType === "teacher" ? ( }> diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 9826581..37f5b9c 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -16,8 +16,9 @@ import { getNotificationPreferences } from "@/modules/notifications/preferences" export const dynamic = "force-dynamic" -export const metadata = { - title: "Settings", +export async function generateMetadata() { + const t = await getTranslations("settings") + return { title: t("title") } } export default async function SettingsPage() { diff --git a/src/app/(dashboard)/student/attendance/error.tsx b/src/app/(dashboard)/student/attendance/error.tsx index a4b50fd..cb7ae9b 100644 --- a/src/app/(dashboard)/student/attendance/error.tsx +++ b/src/app/(dashboard)/student/attendance/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function StudentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("attendance") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/student/attendance/page.tsx b/src/app/(dashboard)/student/attendance/page.tsx index 19cd9c3..060ae80 100644 --- a/src/app/(dashboard)/student/attendance/page.tsx +++ b/src/app/(dashboard)/student/attendance/page.tsx @@ -1,5 +1,6 @@ import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats" import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -8,6 +9,7 @@ import { UserX } from "lucide-react" export const dynamic = "force-dynamic" export default async function StudentAttendancePage() { + await requirePermission(Permissions.ATTENDANCE_READ) const ctx = await getAuthContext() const t = await getTranslations("attendance") @@ -21,8 +23,8 @@ export default async function StudentAttendancePage() {

{t("description.student")}

diff --git a/src/app/(dashboard)/student/course-plans/[id]/error.tsx b/src/app/(dashboard)/student/course-plans/[id]/error.tsx new file mode 100644 index 0000000..4b5f3fe --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/[id]/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentCoursePlanDetailError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/course-plans/[id]/loading.tsx b/src/app/(dashboard)/student/course-plans/[id]/loading.tsx new file mode 100644 index 0000000..88847fd --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/[id]/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function StudentCoursePlanDetailLoading() { + return ( +
+
+ +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/student/course-plans/[id]/page.tsx b/src/app/(dashboard)/student/course-plans/[id]/page.tsx new file mode 100644 index 0000000..22a961c --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/[id]/page.tsx @@ -0,0 +1,42 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getCoursePlanById } from "@/modules/course-plans/data-access" +import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail" + +export const dynamic = "force-dynamic" + +export default async function StudentCoursePlanDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) + const { id } = await params + + // 学生视角:通过 class_members scope 获取 classIds + let classIds: string[] = [] + if (ctx.dataScope.type === "class_members") { + classIds = ctx.dataScope.classIds + } + + const plan = + classIds.length > 0 + ? await getCoursePlanById(id, { userId: ctx.userId, isAdmin: false, classIds }) + : null + + if (!plan) notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/course-plans/error.tsx b/src/app/(dashboard)/student/course-plans/error.tsx new file mode 100644 index 0000000..3ecc4c9 --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function StudentCoursePlansError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/course-plans/loading.tsx b/src/app/(dashboard)/student/course-plans/loading.tsx new file mode 100644 index 0000000..cfb2b3d --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function StudentCoursePlansLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/student/course-plans/page.tsx b/src/app/(dashboard)/student/course-plans/page.tsx new file mode 100644 index 0000000..6bfbaf6 --- /dev/null +++ b/src/app/(dashboard)/student/course-plans/page.tsx @@ -0,0 +1,41 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getCoursePlans } from "@/modules/course-plans/data-access" +import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list" + +export const dynamic = "force-dynamic" + +export default async function StudentCoursePlansPage(): Promise { + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) + const t = await getTranslations("coursePlans") + + // 学生视角:通过 class_members scope 获取 classIds + let classIds: string[] = [] + if (ctx.dataScope.type === "class_members") { + classIds = ctx.dataScope.classIds + } + + const plans = + classIds.length > 0 + ? await getCoursePlans( + { status: "active" }, + { userId: ctx.userId, isAdmin: false, classIds } + ) + : [] + + return ( +
+
+

{t("student.title")}

+

{t("student.description")}

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/diagnostic/page.tsx b/src/app/(dashboard)/student/diagnostic/page.tsx index 6183b25..2dd7a13 100644 --- a/src/app/(dashboard)/student/diagnostic/page.tsx +++ b/src/app/(dashboard)/student/diagnostic/page.tsx @@ -34,7 +34,7 @@ export default async function StudentDiagnosticPage() { {t("diagnostic.description")}

- +
) } diff --git a/src/app/(dashboard)/student/elective/[id]/loading.tsx b/src/app/(dashboard)/student/elective/[id]/loading.tsx new file mode 100644 index 0000000..622b559 --- /dev/null +++ b/src/app/(dashboard)/student/elective/[id]/loading.tsx @@ -0,0 +1,28 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + +
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/elective/[id]/page.tsx b/src/app/(dashboard)/student/elective/[id]/page.tsx new file mode 100644 index 0000000..629ecf6 --- /dev/null +++ b/src/app/(dashboard)/student/elective/[id]/page.tsx @@ -0,0 +1,47 @@ +import { notFound } from "next/navigation" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getElectiveCourseById } from "@/modules/elective/data-access" +import { ElectiveCourseDetail } from "@/modules/elective/components/elective-course-detail" +import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout" + +export const dynamic = "force-dynamic" + +export default async function StudentElectiveDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const t = await getTranslations("elective") + await requirePermission(Permissions.ELECTIVE_READ) + const { id } = await params + + const course = await getElectiveCourseById(id) + + if (!course) notFound() + + const header = ( +
+

{t("title.detail")}

+

{t("description.detail")}

+
+ ) + + return ( + + {/* + 学生视角:只展示课程信息,不展示选课名单(保护其他学生隐私)。 + selections 传空数组,showEditButton=false(学生无编辑权限)。 + */} + + + ) +} diff --git a/src/app/(dashboard)/student/elective/_components/available-courses-loader.tsx b/src/app/(dashboard)/student/elective/_components/available-courses-loader.tsx new file mode 100644 index 0000000..3fe58e9 --- /dev/null +++ b/src/app/(dashboard)/student/elective/_components/available-courses-loader.tsx @@ -0,0 +1,51 @@ +import type { JSX } from "react" +import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" +import { StudentAvailableCoursesSection } from "@/modules/elective/components/student-selection-view" +import { ElectiveFilters } from "@/modules/elective/components/elective-filters" +import { getParam, type SearchParams } from "@/shared/lib/search-params" + +/** + * 可选课程 - 异步 RSC loader + * 独立 Suspense 边界,可流式渲染 + * 同时获取 mySelections(通过 React cache() 自动去重)以计算 selectedCourseIds + * 过滤逻辑(搜索关键词 + 选课模式)在本 loader 内完成,保持组件纯净 + */ +export async function AvailableCoursesLoader({ + studentId, + searchParams, +}: { + studentId: string + searchParams: Promise +}): Promise { + const [sp, availableCourses, mySelections] = await Promise.all([ + searchParams, + getAvailableCoursesForStudent(studentId), + // 通过 React cache() 去重:若 MySelectionsLoader 已先一步获取,则此处直接返回缓存 + getStudentSelections(studentId), + ]) + + const q = (getParam(sp, "q") || "").toLowerCase().trim() + const modeFilter = getParam(sp, "mode") || "all" + + const filteredCourses = availableCourses.filter((c) => { + if (q && !c.name.toLowerCase().includes(q) && !(c.teacherName?.toLowerCase().includes(q) ?? false)) return false + if (modeFilter !== "all" && c.selectionMode !== modeFilter) return false + return true + }) + + const selectedCourseIds = new Set( + mySelections + .filter((s) => ["selected", "enrolled", "waitlist"].includes(s.status)) + .map((s) => s.courseId) + ) + + return ( +
+ {availableCourses.length > 0 && } + +
+ ) +} diff --git a/src/app/(dashboard)/student/elective/_components/my-selections-loader.tsx b/src/app/(dashboard)/student/elective/_components/my-selections-loader.tsx new file mode 100644 index 0000000..c56538d --- /dev/null +++ b/src/app/(dashboard)/student/elective/_components/my-selections-loader.tsx @@ -0,0 +1,17 @@ +import type { JSX } from "react" +import { getStudentSelections } from "@/modules/elective/data-access-selections" +import { StudentMySelectionsSection } from "@/modules/elective/components/student-selection-view" + +/** + * 我的选课 - 异步 RSC loader + * 独立 Suspense 边界,可流式渲染 + * 数据获取通过 React cache() 自动去重 + */ +export async function MySelectionsLoader({ + studentId, +}: { + studentId: string +}): Promise { + const mySelections = await getStudentSelections(studentId) + return +} diff --git a/src/app/(dashboard)/student/elective/error.tsx b/src/app/(dashboard)/student/elective/error.tsx index 4f1b27d..312e90d 100644 --- a/src/app/(dashboard)/student/elective/error.tsx +++ b/src/app/(dashboard)/student/elective/error.tsx @@ -11,8 +11,8 @@ export default function StudentElectiveError({ reset }: { error: Error & { diges
reset(), diff --git a/src/app/(dashboard)/student/elective/loading.tsx b/src/app/(dashboard)/student/elective/loading.tsx index e107779..2bc2297 100644 --- a/src/app/(dashboard)/student/elective/loading.tsx +++ b/src/app/(dashboard)/student/elective/loading.tsx @@ -1,6 +1,9 @@ import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" +/** + * 全页骨架屏 - 路由级 loading.tsx 默认导出 + */ export default function Loading() { return (
@@ -8,6 +11,51 @@ export default function Loading() {
+ + +
+ ) +} + +/** + * 我的选课区块骨架屏 + * 用于独立 Suspense 边界 fallback + */ +export function MySelectionsSkeleton() { + return ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + ))} +
+
+ ) +} + +/** + * 可选课程区块骨架屏 + * 用于独立 Suspense 边界 fallback + */ +export function AvailableCoursesSkeleton() { + return ( +
+
+ + +
{Array.from({ length: 6 }).map((_, i) => ( @@ -21,6 +69,6 @@ export default function Loading() { ))}
-
+ ) } diff --git a/src/app/(dashboard)/student/elective/page.tsx b/src/app/(dashboard)/student/elective/page.tsx index e56f375..77c98a0 100644 --- a/src/app/(dashboard)/student/elective/page.tsx +++ b/src/app/(dashboard)/student/elective/page.tsx @@ -1,10 +1,12 @@ +import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" -import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" -import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" -import { ElectiveFilters } from "@/modules/elective/components/elective-filters" -import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { MySelectionsLoader } from "./_components/my-selections-loader" +import { AvailableCoursesLoader } from "./_components/available-courses-loader" +import { MySelectionsSkeleton, AvailableCoursesSkeleton } from "./loading" +import type { SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" @@ -14,35 +16,24 @@ export default async function StudentElectivePage({ searchParams: Promise }) { const t = await getTranslations("elective") - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.ELECTIVE_READ) const studentId = ctx.userId - const [sp, availableCourses, mySelections] = await Promise.all([ - searchParams, - getAvailableCoursesForStudent(studentId), - getStudentSelections(studentId), - ]) - - const q = (getParam(sp, "q") || "").toLowerCase().trim() - const modeFilter = getParam(sp, "mode") || "all" - - const filteredCourses = availableCourses.filter((c) => { - if (q && !c.name.toLowerCase().includes(q) && !(c.teacherName?.toLowerCase().includes(q) ?? false)) return false - if (modeFilter !== "all" && c.selectionMode !== modeFilter) return false - return true - }) - return (

{t("title.student")}

{t("description.student")}

- {availableCourses.length > 0 && } - + }> + + + }> + +
) } diff --git a/src/app/(dashboard)/student/error-book/add-error-book-dialog-with-questions.tsx b/src/app/(dashboard)/student/error-book/add-error-book-dialog-with-questions.tsx new file mode 100644 index 0000000..5934b46 --- /dev/null +++ b/src/app/(dashboard)/student/error-book/add-error-book-dialog-with-questions.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useCallback } from "react" +import type { JSX } from "react" + +import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog" +import { getQuestionsAction } from "@/modules/questions/actions" +import { extractQuestionPreview } from "@/shared/lib/question-content" + +/** + * 手动添加错题对话框包装组件(位于 app 层)。 + * + * 负责从 questions 模块加载题目列表并注入到 AddErrorBookDialog, + * 避免 error-book 模块直接 import questions 模块(解耦)。 + * app 层可以 import 任何模块,因此跨模块依赖在此合法。 + */ +export function AddErrorBookDialogWithQuestions(): JSX.Element { + const onLoadQuestions = useCallback(async (): Promise< + Array<{ id: string; preview: string }> + > => { + const res = await getQuestionsAction({ pageSize: 100 }) + if (res.success && res.data) { + return res.data.data.map((q) => ({ + id: q.id, + preview: extractQuestionPreview(q.content, "", 60), + })) + } + return [] + }, []) + + return +} diff --git a/src/app/(dashboard)/student/error-book/page.tsx b/src/app/(dashboard)/student/error-book/page.tsx index 660811d..0690f9b 100644 --- a/src/app/(dashboard)/student/error-book/page.tsx +++ b/src/app/(dashboard)/student/error-book/page.tsx @@ -6,54 +6,40 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { Skeleton } from "@/shared/components/ui/skeleton" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access" import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards" import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters" -import { ErrorBookList } from "@/modules/error-book/components/error-book-list" -import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog" import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types" import { AiClientProvider, - type AiClientService, } from "@/modules/ai/context/ai-client-provider" -import { - aiChatAction, - suggestSimilarQuestionsAction, - suggestGradingAction, - generateLessonContentAction, - generateQuestionVariantAction, - analyzeWeaknessAction, -} from "@/modules/ai/actions" +import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service" +import { StudentErrorBookListClient } from "./student-error-book-list-client" +import { AddErrorBookDialogWithQuestions } from "./add-error-book-dialog-with-questions" export const dynamic = "force-dynamic" const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"]) const VALID_SOURCE = new Set(["exam", "homework", "manual"]) +function isErrorBookStatus(v: string): v is ErrorBookStatusValue { + return VALID_STATUS.has(v) +} + +function isErrorBookSource(v: string): v is ErrorBookSourceTypeValue { + return VALID_SOURCE.has(v) +} + function parseStatus(v?: string): ErrorBookStatusValue | undefined { - return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined + if (!v || !isErrorBookStatus(v)) return undefined + return v } function parseSource(v?: string): ErrorBookSourceTypeValue | undefined { - return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined -} - -/** - * 构建 AI 客户端服务(Server Action 引用集合) - * - * 通过 React Context 注入,客户端组件不直接 import actions, - * 遵循依赖注入模式,便于测试时替换为 mock。 - */ -function createAiClientService(): AiClientService { - return { - chat: aiChatAction, - suggestSimilarQuestions: suggestSimilarQuestionsAction, - suggestGrading: suggestGradingAction, - generateLessonContent: generateLessonContentAction, - generateQuestionVariant: generateQuestionVariantAction, - analyzeWeakness: analyzeWeaknessAction, - } + if (!v || !isErrorBookSource(v)) return undefined + return v } async function ErrorBookResults({ searchParams }: { searchParams: Promise }): Promise { @@ -74,7 +60,7 @@ async function ErrorBookResults({ searchParams }: { searchParams: Promise + return } function ErrorBookResultsFallback() { @@ -95,7 +81,7 @@ export default async function StudentErrorBookPage({ const ctx = await requirePermission(Permissions.ERROR_BOOK_READ) const t = await getTranslations("student") const stats = await getErrorBookStats(ctx.userId) - const aiClientService = createAiClientService() + const aiClientService = createCoreAiClientService() return ( @@ -108,20 +94,24 @@ export default async function StudentErrorBookPage({

- +
- + + +
}> - }> - - + + }> + + +
diff --git a/src/app/(dashboard)/student/error-book/student-error-book-list-client.tsx b/src/app/(dashboard)/student/error-book/student-error-book-list-client.tsx new file mode 100644 index 0000000..73e44f6 --- /dev/null +++ b/src/app/(dashboard)/student/error-book/student-error-book-list-client.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useTransition } from "react" +import type { JSX } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { useTranslations } from "next-intl" + +import { ErrorBookList } from "@/modules/error-book/components/error-book-list" +import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis" +import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions" +import type { ErrorBookItem } from "@/modules/error-book/types" + +/** + * 从题目内容中提取纯文本(用于 AI 分析) + */ +function extractQuestionText(content: unknown): string { + if (!content) return "" + if (typeof content === "string") return content + if (typeof content === "object" && content !== null && "text" in content) { + const textValue = (content as Record).text + if (typeof textValue === "string") return textValue + } + try { + return JSON.stringify(content) + } catch { + return "" + } +} + +/** + * 将错题条目转换为 AI 薄弱点分析所需的输入格式 + */ +function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{ + questionText: string + questionType: string + knowledgePointIds?: string[] + errorCount: number + masteryLevel: number +}> { + return items.map((it) => ({ + questionText: extractQuestionText(it.question?.content), + questionType: it.question?.type ?? "unknown", + knowledgePointIds: it.knowledgePointIds ?? undefined, + errorCount: it.reviewCount > 0 ? it.reviewCount : 1, + masteryLevel: it.masteryLevel, + })) +} + +interface StudentErrorBookListClientProps { + items: ErrorBookItem[] + studentId: string +} + +/** + * 学生端错题本列表客户端包装组件(位于 app 层)。 + * + * 负责将 AI 分析和变式练习功能通过 props 注入到 ErrorBookList, + * 避免 error-book 模块直接 import ai / adaptive-practice 模块(解耦)。 + * app 层可以 import 任何模块,因此跨模块依赖在此合法。 + */ +export function StudentErrorBookListClient({ + items, + studentId, +}: StudentErrorBookListClientProps): JSX.Element { + const router = useRouter() + const t = useTranslations("error-book") + const tPractice = useTranslations("practice") + const [, startTransition] = useTransition() + + const aiAnalysisSlot = (item: ErrorBookItem): React.ReactNode => { + const currentQuestionText = extractQuestionText(item.question?.content) + if (!currentQuestionText) return null + + const aiErrorItems = mapErrorItemsForAnalysis(items) + + return ( + + ) + } + + const onStartVariantPractice = (item: ErrorBookItem): void => { + startTransition(async () => { + const formData = new FormData() + formData.append( + "json", + JSON.stringify({ + practiceType: "error_variant", + subjectId: item.subjectId ?? undefined, + sourceMeta: { + errorBookItemIds: [item.id], + sourceQuestionIds: [item.questionId], + }, + questionCount: 10, + }), + ) + const res = await createPracticeSessionAction(undefined, formData) + if (res.success && res.data) { + toast.success(res.message ?? tPractice("starter.title")) + router.push(`/student/practice/${res.data.sessionId}`) + } else { + toast.error(res.message ?? t("messages.saveFailed")) + } + }) + } + + return ( + + ) +} diff --git a/src/app/(dashboard)/student/grades/error.tsx b/src/app/(dashboard)/student/grades/error.tsx index de6ea08..c908ee7 100644 --- a/src/app/(dashboard)/student/grades/error.tsx +++ b/src/app/(dashboard)/student/grades/error.tsx @@ -1,5 +1,6 @@ "use client" +import type { JSX } from "react" import { AlertCircle } from "lucide-react" import { useTranslations } from "next-intl" @@ -10,7 +11,7 @@ export default function StudentGradesError({ }: { error: Error & { digest?: string } reset: () => void -}) { +}): JSX.Element { const t = useTranslations("student") return (
diff --git a/src/app/(dashboard)/student/grades/loading.tsx b/src/app/(dashboard)/student/grades/loading.tsx index f80ac48..38a24fe 100644 --- a/src/app/(dashboard)/student/grades/loading.tsx +++ b/src/app/(dashboard)/student/grades/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/student/grades/page.tsx b/src/app/(dashboard)/student/grades/page.tsx index 6051175..3bd87de 100644 --- a/src/app/(dashboard)/student/grades/page.tsx +++ b/src/app/(dashboard)/student/grades/page.tsx @@ -3,11 +3,14 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getStudentGradeSummary } from "@/modules/grades/data-access" import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-access-ranking" +import { getStudentPositionInClassDistribution, getStudentGrowthArchive } from "@/modules/grades/data-access-analytics" import { getSubjectOptions } from "@/modules/school/data-access" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" import { GradeFilters } from "@/modules/grades/components/grade-filters" import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" import { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card" +import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart" +import { GrowthArchiveChart } from "@/modules/grades/components/growth-archive-chart" import { EmptyState } from "@/shared/components/ui/empty-state" import { UserX } from "lucide-react" import { getParam, type SearchParams } from "@/shared/lib/search-params" @@ -21,7 +24,7 @@ export default async function StudentGradesPage({ }) { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const t = await getTranslations("grades") - const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([ + const [sp, summary, rankingTrend, classAverageTrend, subjectOptions, classDistribution, growthArchive] = await Promise.all([ searchParams, getStudentGradeSummary(ctx.userId, ctx.dataScope), // v3-P1-3:接入排名趋势图 @@ -30,6 +33,10 @@ export default async function StudentGradesPage({ getClassAverageTrend(ctx.userId, undefined, undefined, ctx.dataScope), // v3-P2-1:获取科目列表用于过滤器 getSubjectOptions(), + // P3-9:获取学生所在班级的分布 + 本人位置标注 + getStudentPositionInClassDistribution(ctx.userId, ctx.dataScope), + // P3-4:获取学生纵向成长档案(跨学年/学期聚合) + getStudentGrowthArchive(ctx.userId, ctx.dataScope), ]) if (!summary) { @@ -82,6 +89,16 @@ export default async function StudentGradesPage({
)} + {/* P3-9:班级分布 + 学生位置标注(隐私保护视图) */} + {classDistribution && ( + + )} + {/* P3-4:学生纵向成长档案(跨学年/学期聚合) */} +
) diff --git a/src/app/(dashboard)/student/grades/report-card/error.tsx b/src/app/(dashboard)/student/grades/report-card/error.tsx new file mode 100644 index 0000000..caf278d --- /dev/null +++ b/src/app/(dashboard)/student/grades/report-card/error.tsx @@ -0,0 +1,38 @@ +"use client" + +import type { JSX } from "react" +import { useEffect } from "react" +import { AlertTriangle } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { useTranslations } from "next-intl" + +/** + * P3-1: 学生报告卡路由错误边界。 + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): JSX.Element { + const t = useTranslations("grades") + useEffect(() => { + console.error("[ReportCard] Route error:", error) + }, [error]) + + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/student/grades/report-card/loading.tsx b/src/app/(dashboard)/student/grades/report-card/loading.tsx new file mode 100644 index 0000000..43427e7 --- /dev/null +++ b/src/app/(dashboard)/student/grades/report-card/loading.tsx @@ -0,0 +1,15 @@ +import { Loader2 } from "lucide-react" +import { getTranslations } from "next-intl/server" + +/** + * P3-1: 学生报告卡路由加载状态。 + */ +export default async function Loading() { + const t = await getTranslations("grades") + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/student/grades/report-card/page.tsx b/src/app/(dashboard)/student/grades/report-card/page.tsx new file mode 100644 index 0000000..6f70adf --- /dev/null +++ b/src/app/(dashboard)/student/grades/report-card/page.tsx @@ -0,0 +1,96 @@ +import type { JSX } from "react" +import { ArrowLeft } from "lucide-react" +import Link from "next/link" +import { getTranslations } from "next-intl/server" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getParam, type SearchParams } from "@/shared/lib/search-params" + +import { getReportCardData } from "@/modules/grades/lib/report-card" +import { ReportCardView } from "@/modules/grades/components/report-card-view" +import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action" +import { getAcademicYears } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +/** + * P3-1: 学生视角的成绩报告卡页面。 + * + * 路由:/student/grades/report-card + * 查询参数: + * - academicYearId?: 指定学年(不传则使用当前活跃学年) + * - semester?: "1" | "2"(不传则全部学期) + * + * 权限:GRADE_RECORD_READ(学生 scope 自动限制为本人) + */ +export default async function StudentReportCardPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const sp = await searchParams + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") + + const academicYearIdParam = getParam(sp, "academicYearId") + const semesterParam = getParam(sp, "semester") + const academicYearId = + academicYearIdParam && academicYearIdParam !== "all" + ? academicYearIdParam + : undefined + const semester = + semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined + + // 学生视角下 studentId 即为 ctx.userId(class_members scope) + const [data, academicYears] = await Promise.all([ + getReportCardData(ctx.userId, ctx.dataScope, { + academicYearId, + semester, + }), + getAcademicYears(), + ]) + + if (!data) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + +
+ + + +

+ {t("reportCard.academicYearsCount", { + count: academicYears.length, + })} +

+
+ ) +} diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx index 5295d19..13c4c62 100644 --- a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from "next/navigation" import { getTranslations } from "next-intl/server" -import { getStudentHomeworkTakeData } from "@/modules/homework/data-access" +import { getStudentHomeworkTakeData } from "@/modules/homework/data-access-student" import { getCurrentStudentUser } from "@/modules/users/data-access" import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view" import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view" diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/error.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/error.tsx new file mode 100644 index 0000000..dd6291b --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/error.tsx @@ -0,0 +1,28 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * student/learning/assignments/[assignmentId]/result/error.tsx + * 学生作业结果页错误边界。 + */ +export default function StudentResultError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/loading.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/loading.tsx new file mode 100644 index 0000000..25a768f --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +/** + * student/learning/assignments/[assignmentId]/result/loading.tsx + * 学生作业结果页骨架屏:标题 + 分数汇总卡片 + 错题预览骨架。 + */ +export default function Loading() { + return ( +
+
+ + +
+
+ + + +
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx index 697e003..66c16de 100644 --- a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/result/page.tsx @@ -1,7 +1,8 @@ import type { JSX } from "react" import { notFound } from "next/navigation" import { getTranslations } from "next-intl/server" -import { getHomeworkAssignmentById, getStudentSubmissionResult } from "@/modules/homework/data-access" +import { getHomeworkAssignmentById } from "@/modules/homework/data-access" +import { getStudentSubmissionResult } from "@/modules/homework/data-access-student" import { HomeworkSubmissionResult } from "@/modules/homework/components/homework-submission-result" import { getSession } from "@/shared/lib/session" diff --git a/src/app/(dashboard)/student/learning/assignments/error.tsx b/src/app/(dashboard)/student/learning/assignments/error.tsx new file mode 100644 index 0000000..4c5dda6 --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/error.tsx @@ -0,0 +1,28 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * student/learning/assignments/error.tsx + * 学生作业列表错误边界。 + */ +export default function StudentAssignmentsListError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/assignments/page.tsx b/src/app/(dashboard)/student/learning/assignments/page.tsx index 11f01a1..8d46439 100644 --- a/src/app/(dashboard)/student/learning/assignments/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/page.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui import { StatusBadge } from "@/shared/components/ui/status-badge" import { formatDate, cn } from "@/shared/lib/utils" import { getParam, type SearchParams } from "@/shared/lib/search-params" -import { getStudentHomeworkAssignments } from "@/modules/homework/data-access" +import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student" import { getCurrentStudentUser } from "@/modules/users/data-access" import { AssignmentFilters } from "@/modules/homework/components/assignment-filters" import { Inbox, UserX, TriangleAlert } from "lucide-react" diff --git a/src/app/(dashboard)/student/learning/courses/[classId]/error.tsx b/src/app/(dashboard)/student/learning/courses/[classId]/error.tsx new file mode 100644 index 0000000..5b1490f --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/[classId]/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function StudentClassDetailError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx index 99ff44f..ba01c50 100644 --- a/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/[classId]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next" import Link from "next/link" import { notFound } from "next/navigation" import { @@ -14,6 +15,8 @@ import { getTranslations } from "next-intl/server" import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -21,6 +24,14 @@ import { EmptyState } from "@/shared/components/ui/empty-state" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.studentClassDetail")} - Next_Edu`, + description: t("metadata.studentClassDetail"), + } +} + const WEEKDAY_KEYS: Record = { 1: "mon", 2: "tue", @@ -37,6 +48,7 @@ export default async function StudentClassDetailPage({ params: Promise<{ classId: string }> }) { const { classId } = await params + await requirePermission(Permissions.CLASS_READ) const student = await getCurrentStudentUser() if (!student) return notFound() diff --git a/src/app/(dashboard)/student/learning/courses/error.tsx b/src/app/(dashboard)/student/learning/courses/error.tsx new file mode 100644 index 0000000..f5fd068 --- /dev/null +++ b/src/app/(dashboard)/student/learning/courses/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function StudentCoursesError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/student/learning/courses/page.tsx b/src/app/(dashboard)/student/learning/courses/page.tsx index daf4eab..85ad15d 100644 --- a/src/app/(dashboard)/student/learning/courses/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next" import { UserX } from "lucide-react" import { getTranslations } from "next-intl/server" @@ -5,17 +6,28 @@ import { getStudentClasses } from "@/modules/classes/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" import { StudentCoursesView } from "@/modules/student/components/student-courses-view" import { CourseFilters } from "@/modules/student/components/course-filters" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.studentCourses")} - Next_Edu`, + description: t("metadata.studentCourses"), + } +} + export default async function StudentCoursesPage({ searchParams, }: { searchParams: Promise }) { const t = await getTranslations("student") + await requirePermission(Permissions.CLASS_READ) const student = await getCurrentStudentUser() if (!student) { return ( diff --git a/src/app/(dashboard)/student/learning/error.tsx b/src/app/(dashboard)/student/learning/error.tsx new file mode 100644 index 0000000..6c9170e --- /dev/null +++ b/src/app/(dashboard)/student/learning/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function StudentLearningError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/student/learning/loading.tsx b/src/app/(dashboard)/student/learning/loading.tsx new file mode 100644 index 0000000..1a9dc52 --- /dev/null +++ b/src/app/(dashboard)/student/learning/loading.tsx @@ -0,0 +1,34 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/learning/page.tsx b/src/app/(dashboard)/student/learning/page.tsx index 21b6f4d..a7022a3 100644 --- a/src/app/(dashboard)/student/learning/page.tsx +++ b/src/app/(dashboard)/student/learning/page.tsx @@ -1,18 +1,30 @@ +import type { Metadata } from "next" import Link from "next/link" import { BookOpen, PenTool, Library, ArrowRight, UserX } from "lucide-react" import { getTranslations } from "next-intl/server" import { getStudentClasses } from "@/modules/classes/data-access" -import { getStudentHomeworkAssignments } from "@/modules/homework/data-access" +import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student" import { getCurrentStudentUser } from "@/modules/users/data-access" import { getTextbooks } from "@/modules/textbooks/data-access" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.studentLearning")} - Next_Edu`, + description: t("metadata.studentLearning"), + } +} + export default async function StudentLearningPage() { const t = await getTranslations("student") + await requirePermission(Permissions.CLASS_READ) const student = await getCurrentStudentUser() if (!student) { return ( diff --git a/src/app/(dashboard)/student/learning/study-path/error.tsx b/src/app/(dashboard)/student/learning/study-path/error.tsx new file mode 100644 index 0000000..565a4a1 --- /dev/null +++ b/src/app/(dashboard)/student/learning/study-path/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { RouteError } from "@/shared/components/route-error" + +export default function StudentStudyPathError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/student/learning/study-path/loading.tsx b/src/app/(dashboard)/student/learning/study-path/loading.tsx new file mode 100644 index 0000000..eeac2c9 --- /dev/null +++ b/src/app/(dashboard)/student/learning/study-path/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + + + + + + + +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/study-path/page.tsx b/src/app/(dashboard)/student/learning/study-path/page.tsx new file mode 100644 index 0000000..0c13472 --- /dev/null +++ b/src/app/(dashboard)/student/learning/study-path/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from "next" +import type { JSX } from "react" +import { UserX } from "lucide-react" +import { getTranslations } from "next-intl/server" + +import { AiStudyPath } from "@/modules/ai/components/ai-study-path" +import { getCurrentStudentUser } from "@/modules/users/data-access" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +export const dynamic = "force-dynamic" + +export async function generateMetadata(): Promise { + const t = await getTranslations("student.studyPath") + return { + title: t("title"), + description: t("description"), + } +} + +export default async function StudentStudyPathPage(): Promise { + const t = await getTranslations("student.studyPath") + // P0 显式权限校验:与 AI_CHAT 一致,防止 student 角色被关闭后页面仍可访问 + await requirePermission(Permissions.AI_CHAT) + const student = await getCurrentStudentUser() + + if (!student) { + return ( +
+ +
+ ) + } + + return ( +
+
+

{t("title")}

+

{t("description")}

+
+ {/* AiStudyPath 是客户端组件,AiClientProvider 由 dashboard layout 注入 */} + +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx index c997226..5413b73 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/error.tsx @@ -1,29 +1,13 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteError } from "@/shared/components/route-error" export default function StudentTextbookDetailError({ + error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - const t = useTranslations("textbooks") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx index e14e122..2424fda 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/loading.tsx @@ -2,7 +2,12 @@ import { Skeleton } from "@/shared/components/ui/skeleton" export default function Loading() { return ( -
+
diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx index d530257..3b713df 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { JSX } from "react" import { notFound } from "next/navigation" import { getTranslations } from "next-intl/server" @@ -10,6 +11,8 @@ import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { getCurrentStudentUser } from "@/modules/users/data-access" import { getGradeNameById } from "@/modules/school/data-access" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -17,8 +20,10 @@ export default async function StudentTextbookDetailPage({ params, }: { params: Promise<{ id: string }> -}) { +}): Promise { const t = await getTranslations("textbooks") + // P0 显式权限校验:与教师端一致,校验 TEXTBOOK_READ 权限点 + await requirePermission(Permissions.TEXTBOOK_READ) const student = await getCurrentStudentUser() if (!student) return notFound() diff --git a/src/app/(dashboard)/student/learning/textbooks/error.tsx b/src/app/(dashboard)/student/learning/textbooks/error.tsx index 2d98f34..1afbc28 100644 --- a/src/app/(dashboard)/student/learning/textbooks/error.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/error.tsx @@ -1,29 +1,13 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteError } from "@/shared/components/route-error" export default function StudentTextbooksError({ + error, reset, }: { error: Error & { digest?: string } reset: () => void }) { - const t = useTranslations("textbooks") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/student/learning/textbooks/loading.tsx b/src/app/(dashboard)/student/learning/textbooks/loading.tsx index 5e7b0fc..3fdb477 100644 --- a/src/app/(dashboard)/student/learning/textbooks/loading.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/loading.tsx @@ -1,8 +1,14 @@ import { Skeleton } from "@/shared/components/ui/skeleton" +import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card" export default function Loading() { return ( -
+
@@ -10,7 +16,22 @@ export default function Loading() {
{Array.from({ length: 8 }).map((_, i) => ( - + +
+ +
+ + + + + + + + + + + +
))}
diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx index bd79b9a..16c40fc 100644 --- a/src/app/(dashboard)/student/learning/textbooks/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx @@ -1,3 +1,4 @@ +import type { JSX } from "react" import { BookOpen, UserX } from "lucide-react" import { getTranslations } from "next-intl/server" @@ -8,6 +9,8 @@ import { getCurrentStudentUser } from "@/modules/users/data-access" import { getGradeNameById } from "@/modules/school/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -15,8 +18,10 @@ export default async function StudentTextbooksPage({ searchParams, }: { searchParams: Promise -}) { +}): Promise { const t = await getTranslations("textbooks") + // P0 显式权限校验:与教师端一致,校验 TEXTBOOK_READ 权限点 + await requirePermission(Permissions.TEXTBOOK_READ) const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams]) if (!student) { diff --git a/src/app/(dashboard)/student/leave/error.tsx b/src/app/(dashboard)/student/leave/error.tsx new file mode 100644 index 0000000..3361031 --- /dev/null +++ b/src/app/(dashboard)/student/leave/error.tsx @@ -0,0 +1,7 @@ +"use client" + +import { RouteErrorBoundary } from "@/shared/components/route-error" + +export default function StudentLeaveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return +} diff --git a/src/app/(dashboard)/student/leave/loading.tsx b/src/app/(dashboard)/student/leave/loading.tsx new file mode 100644 index 0000000..a666b55 --- /dev/null +++ b/src/app/(dashboard)/student/leave/loading.tsx @@ -0,0 +1,25 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + + + + + + + +
+ ) +} diff --git a/src/app/(dashboard)/student/leave/page.tsx b/src/app/(dashboard)/student/leave/page.tsx new file mode 100644 index 0000000..9897c01 --- /dev/null +++ b/src/app/(dashboard)/student/leave/page.tsx @@ -0,0 +1,87 @@ +import Link from "next/link" +import { getTranslations } from "next-intl/server" +import { ArrowLeft, CalendarDays } from "lucide-react" + +import { getAuthContext } from "@/shared/lib/auth-guard" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { getStudentActiveClass } from "@/modules/classes/data-access" +import { getLeaveRequests } from "@/modules/leave-requests/data-access" +import { LeaveRequestForm } from "@/modules/leave-requests/components/leave-request-form" +import { LeaveRequestList } from "@/modules/leave-requests/components/leave-request-list" + +export const dynamic = "force-dynamic" + +/** + * 学生在线请假页面。 + * + * L-5 功能: + * - 顶部:请假申请表单(学生本人提交,classId 自动写入当前活跃班级) + * - 底部:学生本人提交的请假申请列表 + * + * 学生 scope 为 class_members,data-access 中 buildScopeFilter 返回 1=0, + * 但通过 currentUserId = requesterId 过滤出本人提交的记录。 + */ +export default async function StudentLeavePage() { + const t = await getTranslations("leave") + const ctx = await getAuthContext() + + // 并行:学生当前活跃班级 + 本人请假记录 + const [activeClass, leaveResult] = await Promise.all([ + getStudentActiveClass(ctx.userId), + getLeaveRequests({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + page: 1, + pageSize: 50, + }), + ]) + + return ( +
+
+

{t("title.student")}

+

{t("description.student")}

+
+ + + + {activeClass ? ( + + + + + {t("onlineLeave")} + + + + + + + ) : ( + + + {t("empty.studentDesc")} + + + )} + +
+

{t("onlineLeave")}

+ +
+
+ ) +} diff --git a/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx b/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx index f6df55b..bc8ae0a 100644 --- a/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx +++ b/src/app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx @@ -1,9 +1,12 @@ import type { JSX } from "react" import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { notFound } from "next/navigation" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlanById } from "@/modules/lesson-preparation/data-access" -import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { assertPlanInScope } from "@/modules/lesson-preparation/lib/scope-check" +import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access" import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view" import { Skeleton } from "@/shared/components/ui/skeleton" @@ -16,17 +19,17 @@ export default async function StudentLessonPlanViewPage({ }): Promise { const { planId } = await params const t = await getTranslations("lessonPreparation") - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) const plan = await getLessonPlanById(planId, ctx.userId) - if (!plan) { - return ( -
-
- {t("readonly.notFound")} -
-
- ) + if (!plan) notFound() + + // V4 P0-1 修复:学生仅可查看本年级已发布课案,防止跨年级信息泄露 + try { + assertPlanInScope(plan, ctx) + } catch { + notFound() } // 学生只能查看已发布的课案 @@ -44,21 +47,14 @@ export default async function StudentLessonPlanViewPage({ let textbookTitle: string | undefined let chapterTitle: string | undefined if (plan.textbookId) { - const textbook = await getTextbookById(plan.textbookId) + // V4 P2-1 修复:textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询 + const [textbook, chapters] = await Promise.all([ + getTextbookById(plan.textbookId), + getChaptersByTextbookId(plan.textbookId), + ]) textbookTitle = textbook?.title if (plan.chapterId) { - const chapters = await getChaptersByTextbookId(plan.textbookId) - const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => { - for (const ch of list) { - if (ch.id === plan.chapterId) return ch - if (ch.children && ch.children.length > 0) { - const found = findChapter(ch.children as typeof chapters) - if (found) return found - } - } - return undefined - } - const chapter = findChapter(chapters) + const chapter = findChapterById(chapters, plan.chapterId) chapterTitle = chapter?.title } } diff --git a/src/app/(dashboard)/student/lesson-plans/page.tsx b/src/app/(dashboard)/student/lesson-plans/page.tsx index 22fe529..4f9c75a 100644 --- a/src/app/(dashboard)/student/lesson-plans/page.tsx +++ b/src/app/(dashboard)/student/lesson-plans/page.tsx @@ -1,17 +1,21 @@ import type { JSX } from "react" import { Suspense } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getLessonPlans } from "@/modules/lesson-preparation/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list" +import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup" +import { STUDENT_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider" import { Skeleton } from "@/shared/components/ui/skeleton" export const dynamic = "force-dynamic" export default async function StudentLessonPlansPage(): Promise { const t = await getTranslations("lessonPreparation") - const ctx = await getAuthContext() + // V4 P0-2 修复:页面层补齐 requirePermission 权限校验 + const ctx = await requirePermission(Permissions.LESSON_PLAN_READ) const [items, subjects] = await Promise.all([ getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId), @@ -24,21 +28,24 @@ export default async function StudentLessonPlansPage(): Promise {

{t("student.title")}

{t("student.description")}

- - {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- } - > - - + {/* P0-13 修复:包裹 LessonPlanProviderSetup,注入 student 角色配置,使筛选功能生效 */} + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ } + > + + +
) } diff --git a/src/app/(dashboard)/student/practice/[sessionId]/page.tsx b/src/app/(dashboard)/student/practice/[sessionId]/page.tsx index 072ad77..7b0f172 100644 --- a/src/app/(dashboard)/student/practice/[sessionId]/page.tsx +++ b/src/app/(dashboard)/student/practice/[sessionId]/page.tsx @@ -6,6 +6,7 @@ import { Permissions } from "@/shared/types/permissions" import { getPracticeSessionById } from "@/modules/adaptive-practice/data-access" import { PracticeSessionView } from "@/modules/adaptive-practice/components/practice-session-view" +import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service" export const dynamic = "force-dynamic" @@ -24,8 +25,10 @@ export default async function PracticeSessionPage({ } return ( -
- -
+ +
+ +
+
) } diff --git a/src/app/(dashboard)/student/practice/page.tsx b/src/app/(dashboard)/student/practice/page.tsx index f562580..d585bec 100644 --- a/src/app/(dashboard)/student/practice/page.tsx +++ b/src/app/(dashboard)/student/practice/page.tsx @@ -5,9 +5,10 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access" -import { PracticeStarter } from "@/modules/adaptive-practice/components/practice-starter" +import { PracticeStarterWithNav } from "@/modules/adaptive-practice/components/practice-starter-with-nav" import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history" import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards" +import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service" import { getKnowledgePointOptions } from "@/modules/questions/data-access" export const dynamic = "force-dynamic" @@ -23,23 +24,31 @@ export default async function StudentPracticePage(): Promise { ]) return ( -
-
-

{t("page.title")}

-

{t("page.description")}

-
- - - -
-
- + +
+
+

{t("page.title")}

+

{t("page.description")}

-
-

{t("history.title")}

- + + + +
+
+ +
+
+

{t("history.title")}

+ +
-
+
) } diff --git a/src/app/(dashboard)/student/schedule/error.tsx b/src/app/(dashboard)/student/schedule/error.tsx new file mode 100644 index 0000000..8ca2e03 --- /dev/null +++ b/src/app/(dashboard)/student/schedule/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function StudentScheduleError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/student/schedule/page.tsx b/src/app/(dashboard)/student/schedule/page.tsx index 2c178eb..aea3471 100644 --- a/src/app/(dashboard)/student/schedule/page.tsx +++ b/src/app/(dashboard)/student/schedule/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next" import { UserX } from "lucide-react" import { getTranslations } from "next-intl/server" @@ -5,17 +6,28 @@ import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-ac import { getCurrentStudentUser } from "@/modules/users/data-access" import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters" import { StudentScheduleView } from "@/modules/student/components/student-schedule-view" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.studentSchedule")} - Next_Edu`, + description: t("metadata.studentSchedule"), + } +} + export default async function StudentSchedulePage({ searchParams, }: { searchParams: Promise }) { const t = await getTranslations("student") + await requirePermission(Permissions.CLASS_READ) const student = await getCurrentStudentUser() if (!student) { return ( diff --git a/src/app/(dashboard)/teacher/attendance/error.tsx b/src/app/(dashboard)/teacher/attendance/error.tsx index 44e27f4..edd24af 100644 --- a/src/app/(dashboard)/teacher/attendance/error.tsx +++ b/src/app/(dashboard)/teacher/attendance/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" - -import { EmptyState } from "@/shared/components/ui/empty-state" +import { RouteErrorBoundary } from "@/shared/components/route-error" export default function TeacherAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("attendance") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) + return } diff --git a/src/app/(dashboard)/teacher/attendance/page.tsx b/src/app/(dashboard)/teacher/attendance/page.tsx index 78ceac8..e7d5a3a 100644 --- a/src/app/(dashboard)/teacher/attendance/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/page.tsx @@ -4,8 +4,9 @@ import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" -import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination" +import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getTeacherClasses } from "@/modules/classes/data-access" import { getAttendanceRecords } from "@/modules/attendance/data-access" @@ -36,6 +37,7 @@ export default async function TeacherAttendancePage({ searchParams: Promise }): Promise { const sp = await searchParams + await requirePermission(Permissions.ATTENDANCE_READ) const ctx = await getAuthContext() const t = await getTranslations("attendance") @@ -55,12 +57,11 @@ export default async function TeacherAttendancePage({ ]) const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) - // 分页计算 + // 分页计算:使用后端返回的 total/totalPages,避免基于截断数据计算 const { page } = computePagination(sp, PAGE_SIZE) - const total = result.items.length - const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const total = result.total + const totalPages = result.totalPages const currentPage = Math.min(page, totalPages) - const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE) const hasFilters = Boolean(classId || status || date) const header = ( @@ -103,7 +104,7 @@ export default async function TeacherAttendancePage({ /> ) : (
- + {total > 0 ? ( void }) { + return +} diff --git a/src/app/(dashboard)/teacher/attendance/report/loading.tsx b/src/app/(dashboard)/teacher/attendance/report/loading.tsx new file mode 100644 index 0000000..2766d67 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/report/loading.tsx @@ -0,0 +1,35 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ + + + + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/report/page.tsx b/src/app/(dashboard)/teacher/attendance/report/page.tsx new file mode 100644 index 0000000..b960973 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/report/page.tsx @@ -0,0 +1,77 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats" +import { AttendanceReportPrint } from "@/modules/attendance/components/attendance-report-print" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { BarChart3 } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function AttendanceReportPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + await requirePermission(Permissions.ATTENDANCE_READ) + const t = await getTranslations("attendance") + const sp = await searchParams + + const classId = getParam(sp, "classId") + const startDate = getParam(sp, "startDate") + const endDate = getParam(sp, "endDate") + // L-8:报告类型(weekly 默认 / monthly) + const reportType = getParam(sp, "reportType") === "monthly" ? "monthly" : "weekly" + + const classes = await getTeacherClasses() + + if (classes.length === 0) { + return ( +
+
+

{t("report.title")}

+

{t("report.description")}

+
+ +
+ ) + } + + const targetClassId = classId ?? classes[0].id + + const summary = await getClassAttendanceStats( + targetClassId, + startDate, + endDate + ) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + const targetClass = classes.find((c) => c.id === targetClassId) + + return ( +
+
+

{t("report.title")}

+

{t("report.description")}

+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/sheet/error.tsx b/src/app/(dashboard)/teacher/attendance/sheet/error.tsx new file mode 100644 index 0000000..bc8c8f2 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/sheet/error.tsx @@ -0,0 +1,7 @@ +"use client" + +import { RouteErrorBoundary } from "@/shared/components/route-error" + +export default function AttendanceSheetError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return +} diff --git a/src/app/(dashboard)/teacher/attendance/sheet/page.tsx b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx index b0a6e10..52fa9b4 100644 --- a/src/app/(dashboard)/teacher/attendance/sheet/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx @@ -1,5 +1,7 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getTeacherClasses } from "@/modules/classes/data-access" import { getClassStudentsForAttendance } from "@/modules/attendance/data-access" import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet" @@ -12,6 +14,7 @@ export default async function AttendanceSheetPage({ }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.ATTENDANCE_MANAGE) const t = await getTranslations("attendance") const sp = await searchParams diff --git a/src/app/(dashboard)/teacher/attendance/stats/error.tsx b/src/app/(dashboard)/teacher/attendance/stats/error.tsx new file mode 100644 index 0000000..f2e5bc8 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/stats/error.tsx @@ -0,0 +1,7 @@ +"use client" + +import { RouteErrorBoundary } from "@/shared/components/route-error" + +export default function AttendanceStatsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return +} diff --git a/src/app/(dashboard)/teacher/attendance/stats/page.tsx b/src/app/(dashboard)/teacher/attendance/stats/page.tsx index 223b04f..081badd 100644 --- a/src/app/(dashboard)/teacher/attendance/stats/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/stats/page.tsx @@ -1,13 +1,20 @@ import type { JSX } from "react" +import Link from "next/link" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getTeacherClasses } from "@/modules/classes/data-access" -import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats" +import { getClassAttendanceStats, getClassAttendanceWarnings, getAttendanceTrend } from "@/modules/attendance/data-access-stats" import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card" import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" import { AttendanceStatsClassSelector } from "@/modules/attendance/components/attendance-stats-class-selector" +import { AttendanceWarningsCard } from "@/modules/attendance/components/attendance-warnings-card" +import { AttendanceTrendChart } from "@/modules/attendance/components/attendance-trend-chart" +import type { TrendGranularity } from "@/modules/attendance/trend-compute" +import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" -import { BarChart3 } from "lucide-react" +import { BarChart3, FileText } from "lucide-react" export const dynamic = "force-dynamic" @@ -16,12 +23,19 @@ export default async function AttendanceStatsPage({ }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.ATTENDANCE_READ) const t = await getTranslations("attendance") const sp = await searchParams const classId = getParam(sp, "classId") const startDate = getParam(sp, "startDate") const endDate = getParam(sp, "endDate") + // L-4:趋势粒度(默认 weekly) + const granularityParam = getParam(sp, "granularity") + const granularity: TrendGranularity = + granularityParam === "daily" || granularityParam === "monthly" + ? granularityParam + : "weekly" const classes = await getTeacherClasses() @@ -44,19 +58,28 @@ export default async function AttendanceStatsPage({ const targetClassId = classId ?? classes[0].id - const summary = await getClassAttendanceStats( - targetClassId, - startDate, - endDate - ) + // 并行获取统计、预警、趋势三个独立查询 + const [summary, warnings, trend] = await Promise.all([ + getClassAttendanceStats(targetClassId, startDate, endDate), + getClassAttendanceWarnings(targetClassId, startDate, endDate), + getAttendanceTrend(targetClassId, granularity, startDate, endDate), + ]) const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) return (
-
-

{t("title.teacherStats")}

-

{t("description.teacherStats")}

+
+
+

{t("title.teacherStats")}

+

{t("description.teacherStats")}

+
+
+ +

{t("stats.studentRecords")}

diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/error.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/error.tsx new file mode 100644 index 0000000..0b651be --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/[id]/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function ClassDetailError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx new file mode 100644 index 0000000..9c25511 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/[id]/loading.tsx @@ -0,0 +1,41 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+
+
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+ + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx index 40da0a5..07b4f2b 100644 --- a/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/classes/my/[id]/page.tsx @@ -1,10 +1,13 @@ +import type { Metadata } from "next" import type { JSX } from "react" import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access" import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget" +import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary" import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget" import { ClassHeader } from "@/modules/classes/components/class-detail/class-header" import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats" @@ -13,6 +16,14 @@ import { ClassStudentsWidget } from "@/modules/classes/components/class-detail/c export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.classDetail")} - Next_Edu`, + description: t("metadata.classDetail"), + } +} + export default async function ClassDetailPage({ params, }: { @@ -66,45 +77,47 @@ export default async function ClassDetailPage({ return (
- - -
- {/* Key Metrics */} - + -
- {/* Main Content Area (Left 2/3) */} -
- - -
+
+ {/* Key Metrics */} + - {/* Sidebar Area (Right 1/3) */} -
- - +
+ {/* Main Content Area (Left 2/3) */} +
+ + +
+ + {/* Sidebar Area (Right 1/3) */} +
+ + +
-
+
) } diff --git a/src/app/(dashboard)/teacher/classes/my/error.tsx b/src/app/(dashboard)/teacher/classes/my/error.tsx new file mode 100644 index 0000000..ad35e6e --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/my/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function MyClassesError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/teacher/classes/my/page.tsx b/src/app/(dashboard)/teacher/classes/my/page.tsx index fda32e6..e77b229 100644 --- a/src/app/(dashboard)/teacher/classes/my/page.tsx +++ b/src/app/(dashboard)/teacher/classes/my/page.tsx @@ -1,18 +1,31 @@ +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access" +import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary" import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.myClasses")} - Next_Edu`, + description: t("metadata.myClasses"), + } +} + export default async function MyClassesPage(): Promise { await requirePermission(Permissions.CLASS_READ) const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()]) return (
- + + +
) } diff --git a/src/app/(dashboard)/teacher/classes/schedule/error.tsx b/src/app/(dashboard)/teacher/classes/schedule/error.tsx new file mode 100644 index 0000000..5b20e35 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/schedule/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function ScheduleError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/teacher/classes/schedule/page.tsx b/src/app/(dashboard)/teacher/classes/schedule/page.tsx index 709b985..b470816 100644 --- a/src/app/(dashboard)/teacher/classes/schedule/page.tsx +++ b/src/app/(dashboard)/teacher/classes/schedule/page.tsx @@ -1,21 +1,33 @@ +import type { Metadata } from "next" import type { JSX } from "react" import { Suspense } from "react" import { Calendar } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access" import { ScheduleFilters } from "@/modules/classes/components/schedule-filters" import { ScheduleView } from "@/modules/classes/components/schedule-view" +import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.schedule")} - Next_Edu`, + description: t("metadata.schedule"), + } +} + async function ScheduleResults({ searchParams }: { searchParams: Promise }): Promise { const params = await searchParams const classId = getParam(params, "classId") + const t = await getTranslations("classes") const classes = await getTeacherClasses() const schedule = await getClassSchedule({ @@ -28,9 +40,9 @@ async function ScheduleResults({ searchParams }: { searchParams: Promise ) @@ -66,13 +78,15 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro return (
- }> - - + + }> + + - }> - - + }> + + +
) diff --git a/src/app/(dashboard)/teacher/classes/students/error.tsx b/src/app/(dashboard)/teacher/classes/students/error.tsx new file mode 100644 index 0000000..12e7264 --- /dev/null +++ b/src/app/(dashboard)/teacher/classes/students/error.tsx @@ -0,0 +1,13 @@ +"use client" + +import { ErrorState } from "@/shared/components/error-state" + +export default function StudentsError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/(dashboard)/teacher/classes/students/page.tsx b/src/app/(dashboard)/teacher/classes/students/page.tsx index 2b2b436..3ba5ece 100644 --- a/src/app/(dashboard)/teacher/classes/students/page.tsx +++ b/src/app/(dashboard)/teacher/classes/students/page.tsx @@ -1,20 +1,32 @@ +import type { Metadata } from "next" import type { JSX } from "react" import { Suspense } from "react" import { User } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access" import { StudentsFilters } from "@/modules/classes/components/students-filters" import { StudentsTable } from "@/modules/classes/components/students-table" +import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("classes") + return { + title: `${t("metadata.students")} - Next_Edu`, + description: t("metadata.students"), + } +} + async function StudentsResults({ searchParams, defaultClassId }: { searchParams: Promise, defaultClassId?: string }): Promise { const params = await searchParams + const t = await getTranslations("classes") const q = getParam(params, "q") || undefined const classId = getParam(params, "classId") @@ -25,7 +37,7 @@ async function StudentsResults({ searchParams, defaultClassId }: { searchParams: // However, the requirement is "Default to showing the first class". // If classId param is missing, we use defaultClassId. const targetClassId = classId ? (classId !== "all" ? classId : undefined) : defaultClassId - + const filteredStudents = await getClassStudents({ q, classId: targetClassId, @@ -47,9 +59,9 @@ async function StudentsResults({ searchParams, defaultClassId }: { searchParams: return ( ) @@ -80,20 +92,22 @@ function StudentsResultsFallback() { export default async function StudentsPage({ searchParams }: { searchParams: Promise }): Promise { await requirePermission(Permissions.CLASS_READ) const classes = await getTeacherClasses() - + // Logic to determine default class (first one available) const defaultClassId = classes.length > 0 ? classes[0].id : undefined return (
- }> - - + + }> + + - }> - - + }> + + +
) diff --git a/src/app/(dashboard)/teacher/course-plans/[id]/error.tsx b/src/app/(dashboard)/teacher/course-plans/[id]/error.tsx new file mode 100644 index 0000000..4ccd297 --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/[id]/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function TeacherCoursePlanDetailError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx b/src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx new file mode 100644 index 0000000..001f8f8 --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/[id]/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherCoursePlanDetailLoading() { + return ( +
+
+ +
+ + +
+
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx index 51d92c1..8c6992d 100644 --- a/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx @@ -13,9 +13,13 @@ export default async function TeacherCoursePlanDetailPage({ }: { params: Promise<{ id: string }> }): Promise { - await requirePermission(Permissions.COURSE_PLAN_READ) + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) const { id } = await params - const plan = await getCoursePlanById(id) + // P0-1/P0-3:教师视角仅能查看自己负责的计划,避免信息泄露 + const plan = await getCoursePlanById( + id, + { userId: ctx.userId, isAdmin: false, teacherId: ctx.userId } + ) if (!plan) notFound() @@ -24,6 +28,9 @@ export default async function TeacherCoursePlanDetailPage({
) diff --git a/src/app/(dashboard)/teacher/course-plans/error.tsx b/src/app/(dashboard)/teacher/course-plans/error.tsx new file mode 100644 index 0000000..d301c89 --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/error.tsx @@ -0,0 +1,20 @@ +"use client" + +import { useTranslations } from "next-intl" +import { ClipboardList } from "lucide-react" +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function TeacherCoursePlansError() { + const t = useTranslations("coursePlans") + return ( +
+ window.location.reload() }} + className="border-none shadow-none" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/loading.tsx b/src/app/(dashboard)/teacher/course-plans/loading.tsx new file mode 100644 index 0000000..8235cb7 --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function TeacherCoursePlansLoading() { + return ( +
+
+ + +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/page.tsx b/src/app/(dashboard)/teacher/course-plans/page.tsx index 1fbdbc0..c945997 100644 --- a/src/app/(dashboard)/teacher/course-plans/page.tsx +++ b/src/app/(dashboard)/teacher/course-plans/page.tsx @@ -1,24 +1,15 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getCoursePlans } from "@/modules/course-plans/data-access" import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list" -import type { CoursePlanStatus } from "@/modules/course-plans/types" +import { isCoursePlanStatus } from "@/modules/course-plans/types" export const dynamic = "force-dynamic" -const VALID_STATUSES: ReadonlySet = new Set([ - "planning", - "active", - "completed", - "paused", -]) - -function parseStatus(v?: string): CoursePlanStatus | undefined { - return v && VALID_STATUSES.has(v) ? (v as CoursePlanStatus) : undefined -} - export default async function TeacherCoursePlansPage({ searchParams, }: { @@ -27,21 +18,25 @@ export default async function TeacherCoursePlansPage({ const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) const teacherId = ctx.userId + const t = await getTranslations("coursePlans") const sp = await searchParams const statusParam = getParam(sp, "status") - const status = parseStatus(statusParam) + // P1-4:使用类型守卫替代 as 断言 + const status = isCoursePlanStatus(statusParam) ? statusParam : undefined + // P0-3:data-access 按教师范围过滤 const plans = teacherId - ? await getCoursePlans({ teacherId, status }) + ? await getCoursePlans( + { teacherId, status }, + { userId: teacherId, isAdmin: false, teacherId } + ) : [] return (
-

My Course Plans

-

- View your course teaching plans and weekly schedules. -

+

{t("teacher.title")}

+

{t("teacher.description")}

void }) { + const t = useTranslations("diagnostic") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx index 224e7e3..4fa8fc5 100644 --- a/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx @@ -1,11 +1,21 @@ import type { JSX } from "react" import { notFound } from "next/navigation" import { Stethoscope } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassMasterySummary } from "@/modules/diagnostic/data-access" import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view" -import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" +import { + DiagnosticServiceProvider, +} from "@/modules/diagnostic/services/diagnostic-service-context" +import { defaultDiagnosticService } from "@/modules/diagnostic/services/default-diagnostic-service" +import { createMonitoredDiagnosticService } from "@/modules/diagnostic/services/monitored-diagnostic-service" +import { + DiagnosticMonitorProvider, +} from "@/modules/diagnostic/services/diagnostic-monitor-context" +import { noopDiagnosticMonitor } from "@/modules/diagnostic/services/diagnostic-monitor" +import { WidgetBoundary } from "@/shared/components/widget-boundary" export const dynamic = "force-dynamic" @@ -16,6 +26,7 @@ export default async function ClassDiagnosticPage({ }): Promise { const { classId } = await params const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) + const t = await getTranslations("diagnostic") // DataScope 校验:教师只能查看所教班级,学生/家长不可访问 if (ctx.dataScope.type === "class_taught" && !ctx.dataScope.classIds.includes(classId)) { @@ -31,19 +42,29 @@ export default async function ClassDiagnosticPage({ notFound() } + // v2-P2-7: 包装默认服务以添加监控埋点(默认 no-op,生产环境可注入真实实现) + const monitoredService = createMonitoredDiagnosticService( + defaultDiagnosticService, + noopDiagnosticMonitor, + ) + return (

- Class-level knowledge point mastery overview and student attention list. + {t("title.teacherClassDesc")}

- - + + + + + +
) diff --git a/src/app/(dashboard)/teacher/diagnostic/error.tsx b/src/app/(dashboard)/teacher/diagnostic/error.tsx index 0849608..5d4c21a 100644 --- a/src/app/(dashboard)/teacher/diagnostic/error.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/error.tsx @@ -1,6 +1,7 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -10,14 +11,15 @@ export default function TeacherDiagnosticError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("diagnostic") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/teacher/diagnostic/page.tsx b/src/app/(dashboard)/teacher/diagnostic/page.tsx index 09d4734..eb52628 100644 --- a/src/app/(dashboard)/teacher/diagnostic/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/page.tsx @@ -1,9 +1,19 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" import { ReportList } from "@/modules/diagnostic/components/report-list" +import { + DiagnosticServiceProvider, +} from "@/modules/diagnostic/services/diagnostic-service-context" +import { defaultDiagnosticService } from "@/modules/diagnostic/services/default-diagnostic-service" +import { createMonitoredDiagnosticService } from "@/modules/diagnostic/services/monitored-diagnostic-service" +import { + DiagnosticMonitorProvider, +} from "@/modules/diagnostic/services/diagnostic-monitor-context" +import { noopDiagnosticMonitor } from "@/modules/diagnostic/services/diagnostic-monitor" import type { DiagnosticReportType, DiagnosticReportStatus } from "@/modules/diagnostic/types" export const dynamic = "force-dynamic" @@ -20,12 +30,12 @@ const VALID_REPORT_STATUSES: ReadonlySet = new Set([ "archived", ]) -function parseReportType(v?: string): DiagnosticReportType | undefined { - return v && VALID_REPORT_TYPES.has(v) ? (v as DiagnosticReportType) : undefined +function isReportType(v: string): v is DiagnosticReportType { + return VALID_REPORT_TYPES.has(v) } -function parseReportStatus(v?: string): DiagnosticReportStatus | undefined { - return v && VALID_REPORT_STATUSES.has(v) ? (v as DiagnosticReportStatus) : undefined +function isReportStatus(v: string): v is DiagnosticReportStatus { + return VALID_REPORT_STATUSES.has(v) } export default async function TeacherDiagnosticPage({ @@ -35,33 +45,44 @@ export default async function TeacherDiagnosticPage({ }): Promise { const sp = await searchParams const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) + const t = await getTranslations("diagnostic") - const reportType = getParam(sp, "reportType") - const status = getParam(sp, "status") + const reportTypeParam = getParam(sp, "reportType") + const statusParam = getParam(sp, "status") - const reports = await getDiagnosticReports( - { - reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined, - status: status && status !== "all" ? parseReportStatus(status) : undefined, - }, - ctx.dataScope, + const reportType = + reportTypeParam && reportTypeParam !== "all" && isReportType(reportTypeParam) + ? reportTypeParam + : undefined + const status = + statusParam && statusParam !== "all" && isReportStatus(statusParam) + ? statusParam + : undefined + + const reports = await getDiagnosticReports({ reportType, status }, ctx.dataScope) + + // v2-P2-2: 移除客户端 filter,DataScope 过滤已在 data-access 层完成 + // class_members scope 的学生已通过 filters.studentId 在 data-access 层过滤 + + // v2-P2-7: 包装默认服务以添加监控埋点(默认 no-op,生产环境可注入真实实现) + const monitoredService = createMonitoredDiagnosticService( + defaultDiagnosticService, + noopDiagnosticMonitor, ) - // 学生角色仅查看自己的报告;其他角色查看全部 - const visibleReports = - ctx.dataScope.type === "class_members" - ? reports.reports.filter((r) => r.studentId === ctx.userId) - : reports.reports - return (
-

Learning Diagnostic

+

{t("title.teacherReportList")}

- View and manage diagnostic reports based on knowledge point mastery. + {t("title.teacherReportListDesc")}

- + + + + +
) } diff --git a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/error.tsx b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/error.tsx index cc75dae..485350e 100644 --- a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/error.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/error.tsx @@ -1,6 +1,7 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -10,14 +11,15 @@ export default function DiagnosticStudentError({ error: Error & { digest?: string } reset: () => void }) { + const t = useTranslations("diagnostic") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx index 5029e73..b737b12 100644 --- a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx @@ -1,6 +1,7 @@ import type { JSX } from "react" import { notFound } from "next/navigation" import { Stethoscope } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { @@ -10,7 +11,7 @@ import { import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" import { getStudentActiveClassId } from "@/modules/classes/data-access" import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view" -import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import type { MasteryRadarPoint } from "@/modules/diagnostic/types" export const dynamic = "force-dynamic" @@ -22,6 +23,7 @@ export default async function StudentDiagnosticPage({ }): Promise { const { studentId } = await params const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) + const t = await getTranslations("diagnostic") // DataScope 二次校验:学生只能看自己,家长只能看子女 if (ctx.dataScope.type === "class_members" && ctx.userId !== studentId) { @@ -67,18 +69,18 @@ export default async function StudentDiagnosticPage({

- Knowledge point mastery analysis and diagnostic reports. + {t("title.teacherStudentDesc")}

- +
diff --git a/src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx b/src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx new file mode 100644 index 0000000..1aa45bd --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/[id]/edit/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx b/src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx new file mode 100644 index 0000000..88d4231 --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/[id]/edit/page.tsx @@ -0,0 +1,52 @@ +import { notFound } from "next/navigation" +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getElectiveCourseById } from "@/modules/elective/data-access" +import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" +import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" + +export const dynamic = "force-dynamic" + +export default async function TeacherEditElectiveCoursePage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const t = await getTranslations("elective") + const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) + const { id } = await params + + const [course, subjects, grades, teachers] = await Promise.all([ + getElectiveCourseById(id), + getSubjectOptions(), + getGrades(), + getStaffOptions(), + ]) + + if (!course) notFound() + + // 教师只能编辑自己教授的课程(防止跨教师越权编辑) + if (course.teacherId && course.teacherId !== ctx.userId) { + notFound() + } + + return ( +
+
+

{t("title.edit")}

+

{t("description.edit")}

+
+ ({ id: g.id, name: g.name }))} + teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))} + backHref="/teacher/elective" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/create/loading.tsx b/src/app/(dashboard)/teacher/elective/create/loading.tsx new file mode 100644 index 0000000..1aa45bd --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/create/loading.tsx @@ -0,0 +1,27 @@ +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+ + + + + + {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/create/page.tsx b/src/app/(dashboard)/teacher/elective/create/page.tsx new file mode 100644 index 0000000..3ccb473 --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/create/page.tsx @@ -0,0 +1,36 @@ +import type { JSX } from "react" +import { getTranslations } from "next-intl/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getGrades, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access" +import { ElectiveCourseForm } from "@/modules/elective/components/elective-course-form" + +export const dynamic = "force-dynamic" + +export default async function TeacherCreateElectiveCoursePage(): Promise { + const t = await getTranslations("elective") + await requirePermission(Permissions.ELECTIVE_MANAGE) + + const [subjects, grades, teachers] = await Promise.all([ + getSubjectOptions(), + getGrades(), + getStaffOptions(), + ]) + + return ( +
+
+

{t("title.create")}

+

{t("description.create")}

+
+ ({ id: g.id, name: g.name }))} + teachers={teachers.map((teacher) => ({ id: teacher.id, name: teacher.name }))} + backHref="/teacher/elective" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/elective/error.tsx b/src/app/(dashboard)/teacher/elective/error.tsx index 3bfcda0..bbd8635 100644 --- a/src/app/(dashboard)/teacher/elective/error.tsx +++ b/src/app/(dashboard)/teacher/elective/error.tsx @@ -11,10 +11,10 @@ export default function TeacherElectiveError({ reset }: { error: Error & { diges
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/teacher/elective/page.tsx b/src/app/(dashboard)/teacher/elective/page.tsx index dba2684..e99e507 100644 --- a/src/app/(dashboard)/teacher/elective/page.tsx +++ b/src/app/(dashboard)/teacher/elective/page.tsx @@ -50,8 +50,8 @@ export default async function TeacherElectivePage({ ) diff --git a/src/app/(dashboard)/teacher/error-book/page.tsx b/src/app/(dashboard)/teacher/error-book/page.tsx index 750b9b9..198701f 100644 --- a/src/app/(dashboard)/teacher/error-book/page.tsx +++ b/src/app/(dashboard)/teacher/error-book/page.tsx @@ -1,11 +1,13 @@ import type { JSX } from "react" import { Suspense } from "react" import { BarChart3 } from "lucide-react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access" @@ -17,7 +19,7 @@ import { getSubjectErrorOverviews, getClassErrorOverviews, getChapterWeakness, -} from "@/modules/error-book/data-access" +} from "@/modules/error-book/data-access-analytics" import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions" import { SubjectTabs } from "@/modules/error-book/components/subject-tabs" import { ClassFilter } from "@/modules/error-book/components/class-filter" @@ -34,6 +36,7 @@ async function TeacherErrorBookContent({ }: { searchParams: Promise }): Promise { + const t = await getTranslations("errorBook") const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ) const params = await searchParams @@ -45,13 +48,13 @@ async function TeacherErrorBookContent({ return (
-

错题分析

-

按学科、班级查看学生的错题统计与薄弱知识点。

+

{t("teacher.title")}

+

{t("teacher.descriptionShort")}

@@ -72,13 +75,13 @@ async function TeacherErrorBookContent({ return (
-

错题分析

-

按学科、班级查看学生的错题统计与薄弱知识点。

+

{t("teacher.title")}

+

{t("teacher.descriptionShort")}

@@ -128,9 +131,9 @@ async function TeacherErrorBookContent({ return (
-

错题分析

+

{t("teacher.title")}

- 按学科、班级查看学生的错题统计与薄弱知识点,辅助精准教学。 + {t("teacher.description")}

@@ -155,71 +158,86 @@ async function TeacherErrorBookContent({ ) : null} {/* 统计卡片 */} - + + + {/* 班级错题对比图(仅在"全部班级"视图下显示) */} {effectiveClassId === "all" && classOverviews.length > 1 ? ( - + + + ) : null} {/* 章节错题分布 + 知识点薄弱度(并排) */}
- {chapterWeakness.length > 0 ? ( - - ) : ( - - )} - {weakKps.length > 0 ? ( - - ) : ( - - )} + + {chapterWeakness.length > 0 ? ( + + ) : ( + + )} + + + {weakKps.length > 0 ? ( + + ) : ( + + )} +
{/* 学生错题详情(按班级分组) */}
-

学生错题详情

+

{t("teacher.studentDetail")}

- 共 {queryStudentIds.length} 名学生,{studentsWithErrorBook.length} 名有错题 + {t("teacher.studentsCount", { + total: queryStudentIds.length, + withErrors: studentsWithErrorBook.length, + })}
- {sortedSummaries.length > 0 ? ( - - ) : ( - - )} + + {sortedSummaries.length > 0 ? ( + + ) : ( + + )} +
{/* 高频错题 Top 10 */} {topWrongQuestions.length > 0 ? ( - + + + ) : null}
) diff --git a/src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx b/src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx new file mode 100644 index 0000000..6f9bdf1 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/analytics/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function ExamAnalyticsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx b/src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx new file mode 100644 index 0000000..908c922 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/analytics/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+
+ + + +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx index 42fe26e..b4a111a 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/analytics/page.tsx @@ -8,11 +8,14 @@ import { BarChart3, ArrowLeft } from "lucide-react" import { getExamById } from "@/modules/exams/data-access" import { getExamAnalytics } from "@/modules/exams/stats-service" import { ExamAnalyticsDashboard } from "@/modules/exams/components/exam-analytics-dashboard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" export default async function ExamAnalyticsPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params + await requirePermission(Permissions.EXAM_READ) const t = await getTranslations("examHomework") const [exam, analytics] = await Promise.all([ diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx index cb7a678..3c48922 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/build/page.tsx @@ -1,5 +1,6 @@ import type { JSX } from "react" import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { ExamAssembly } from "@/modules/exams/components/exam-assembly" import { getExamById } from "@/modules/exams/data-access" import { getQuestions } from "@/modules/questions/data-access" @@ -9,40 +10,14 @@ import type { ExamNode } from "@/modules/exams/components/assembly/selected-ques import { createId } from "@paralleldrive/cuid2" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { - AiClientProvider, - type AiClientService, -} from "@/modules/ai/context/ai-client-provider" -import { - aiChatAction, - suggestSimilarQuestionsAction, - suggestGradingAction, - generateLessonContentAction, - generateQuestionVariantAction, - analyzeWeaknessAction, -} from "@/modules/ai/actions" +import { AiClientProvider } from "@/modules/ai/context/ai-client-provider" +import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service" export const dynamic = "force-dynamic" -/** - * 构建 AI 客户端服务(Server Action 引用集合) - * - * 通过 React Context 注入,客户端组件不直接 import actions, - * 遵循依赖注入模式,便于测试时替换为 mock。 - */ -function createAiClientService(): AiClientService { - return { - chat: aiChatAction, - suggestSimilarQuestions: suggestSimilarQuestionsAction, - suggestGrading: suggestGradingAction, - generateLessonContent: generateLessonContentAction, - generateQuestionVariant: generateQuestionVariantAction, - analyzeWeakness: analyzeWeaknessAction, - } -} - export default async function BuildExamPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params + const t = await getTranslations("examHomework.exam.build") const ctx = await requirePermission(Permissions.EXAM_READ) const exam = await getExamById(id, ctx.dataScope) @@ -101,14 +76,14 @@ export default async function BuildExamPage({ params }: { params: Promise<{ id: })) } - const aiClientService = createAiClientService() + const aiClientService = createCoreAiClientService() return (
-

Build Exam

-

Assemble questions for your exam.

+

{t("title")}

+

{t("description")}

void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx b/src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx new file mode 100644 index 0000000..7b6eb94 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/edit-rich/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx new file mode 100644 index 0000000..d532e55 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx @@ -0,0 +1,80 @@ +import type { JSX } from "react" +import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" +import { getExamById } from "@/modules/exams/data-access" +import { getQuestions } from "@/modules/questions/data-access" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { normalizeStructure } from "@/modules/exams/utils/normalize-structure" +// 直接从具体文件导入纯转换函数和类型,绕过 editor/index.ts barrel, +// 避免 Server Component 连带加载 @tiptap/react 客户端扩展(会触发 +// "Class extends value undefined is not a constructor or null" 错误)。 +import { examNodesToEditorDoc } from "@/modules/exams/editor/exam-nodes-to-editor-doc" +import { structureToEditorDoc } from "@/modules/exams/editor/structure-to-editor" +import type { EditorJSONContent } from "@/modules/exams/editor/exam-rich-editor-types" +import { ExamRichForm } from "@/modules/exams/components/exam-rich-form" +import type { Question } from "@/modules/questions/types" + +export const dynamic = "force-dynamic" + +export default async function EditRichExamPage({ + params, +}: { + params: Promise<{ id: string }> +}): Promise { + const { id } = await params + const t = await getTranslations("examHomework") + + const ctx = await requirePermission(Permissions.EXAM_UPDATE) + const exam = await getExamById(id, ctx.dataScope) + if (!exam) return notFound() + + // 加载 exam 关联的题目(含 content) + const selectedQuestionIds = (exam.questions || []).map((q) => q.id) + const selectedResult = selectedQuestionIds.length > 0 + ? await getQuestions({ + ids: selectedQuestionIds, + pageSize: Math.max(10, selectedQuestionIds.length), + }) + : { data: [] as Awaited>["data"] } + + type RawQuestion = (typeof selectedResult.data)[number] + const toQuestion = (q: RawQuestion): Question => ({ + id: q.id, + content: q.content, + type: q.type, + difficulty: q.difficulty ?? 1, + createdAt: new Date(q.createdAt), + updatedAt: new Date(q.updatedAt), + author: q.author + ? { + id: q.author.id, + name: q.author.name || "Unknown", + image: q.author.image || null, + } + : null, + knowledgePoints: q.knowledgePoints ?? [], + }) + + const questions = selectedResult.data.map(toQuestion) + + // 把 ExamNode[] + questions 转为 EditorDoc,再转为 Tiptap JSON + const examNodes = normalizeStructure(exam.structure) + const editorDoc = examNodesToEditorDoc(examNodes, questions, exam.title) + const initialContent = structureToEditorDoc(editorDoc) as EditorJSONContent + + return ( +
+
+

{t("exam.richEditor.title")}

+

{t("exam.richEditor.description")}

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx b/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx index d1bdad0..14871a5 100644 --- a/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx +++ b/src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx @@ -1,5 +1,6 @@ import type { JSX } from "react" import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { ProctoringDashboard } from "@/modules/proctoring/components/proctoring-dashboard" @@ -18,13 +19,14 @@ export default async function ExamProctoringPage({ }: { params: Promise<{ id: string }> }): Promise { + const t = await getTranslations("examHomework.proctoring.page") try { await requirePermission(Permissions.EXAM_PROCTOR) } catch (error) { if (error instanceof PermissionDeniedError) { return (
- 您没有监考权限(exam:proctor)。 + {t("noPermission")}
) } @@ -51,8 +53,8 @@ export default async function ExamProctoringPage({ return (
-

Exam Proctoring

-

Monitor student activity during the exam.

+

{t("title")}

+

{t("description")}

diff --git a/src/app/(dashboard)/teacher/exams/all/error.tsx b/src/app/(dashboard)/teacher/exams/all/error.tsx new file mode 100644 index 0000000..9fb7e74 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/all/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function AllExamsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx index dac1871..caedb90 100644 --- a/src/app/(dashboard)/teacher/exams/all/page.tsx +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -9,13 +9,14 @@ import { Skeleton } from "@/shared/components/ui/skeleton" import { ExamDataTable } from "@/modules/exams/components/exam-data-table" import { ExamFilters } from "@/modules/exams/components/exam-filters" import { getExams } from "@/modules/exams/data-access" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { FileText, PlusCircle } from "lucide-react" async function ExamsResults({ searchParams }: { searchParams: Promise }): Promise { const params = await searchParams - const { dataScope } = await getAuthContext() + const ctx = await requirePermission(Permissions.EXAM_READ) const t = await getTranslations("examHomework") const q = getParam(params, "q") @@ -26,7 +27,7 @@ async function ExamsResults({ searchParams }: { searchParams: Promise void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/create/page.tsx b/src/app/(dashboard)/teacher/exams/create/page.tsx index 061cfb5..c763887 100644 --- a/src/app/(dashboard)/teacher/exams/create/page.tsx +++ b/src/app/(dashboard)/teacher/exams/create/page.tsx @@ -1,10 +1,13 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { ExamForm } from "@/modules/exams/components/exam-form" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" export default async function CreateExamPage(): Promise { + await requirePermission(Permissions.EXAM_CREATE) const t = await getTranslations("examHomework") return (
diff --git a/src/app/(dashboard)/teacher/exams/new/error.tsx b/src/app/(dashboard)/teacher/exams/new/error.tsx new file mode 100644 index 0000000..4e51bf6 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/new/error.tsx @@ -0,0 +1,24 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +export default function NewExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/new/loading.tsx b/src/app/(dashboard)/teacher/exams/new/loading.tsx new file mode 100644 index 0000000..7b6eb94 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/new/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/new/page.tsx b/src/app/(dashboard)/teacher/exams/new/page.tsx index 764cd47..e94dcec 100644 --- a/src/app/(dashboard)/teacher/exams/new/page.tsx +++ b/src/app/(dashboard)/teacher/exams/new/page.tsx @@ -1,10 +1,13 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { ExamRichForm } from "@/modules/exams/components/exam-rich-form" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" export default async function NewExamPage(): Promise { + await requirePermission(Permissions.EXAM_CREATE) const t = await getTranslations("examHomework") return (
diff --git a/src/app/(dashboard)/teacher/grades/analytics/loading.tsx b/src/app/(dashboard)/teacher/grades/analytics/loading.tsx index a07cef9..eb9d948 100644 --- a/src/app/(dashboard)/teacher/grades/analytics/loading.tsx +++ b/src/app/(dashboard)/teacher/grades/analytics/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/teacher/grades/analytics/page.tsx b/src/app/(dashboard)/teacher/grades/analytics/page.tsx index 6610de5..6e070a9 100644 --- a/src/app/(dashboard)/teacher/grades/analytics/page.tsx +++ b/src/app/(dashboard)/teacher/grades/analytics/page.tsx @@ -1,6 +1,7 @@ import type { JSX } from "react" import Link from "next/link" import { BarChart3, ArrowLeft } from "lucide-react" +import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -12,7 +13,7 @@ import { getGrades } from "@/modules/school/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { - getClassComparison, + getClassComparisonWithSignificance, getExamOptionsForGrades, getGradeDistribution, getGradeTrend, @@ -23,7 +24,9 @@ import { ClassComparisonChart } from "@/modules/grades/components/class-comparis import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart" import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart" import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters" -import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" +import { KnowledgePointMasteryChart } from "@/modules/grades/components/knowledge-point-mastery-chart" +import { getClassMasterySummary } from "@/modules/diagnostic/data-access" +import { WidgetBoundary } from "@/shared/components/widget-boundary" export const dynamic = "force-dynamic" @@ -34,6 +37,7 @@ export default async function GradeAnalyticsPage({ }): Promise { const sp = await searchParams const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") const classId = getParam(sp, "classId") const subjectId = getParam(sp, "subjectId") @@ -52,14 +56,12 @@ export default async function GradeAnalyticsPage({ return (
-

成绩分析

-

- 趋势分析、班级对比与分数分布。 -

+

{t("title.analytics")}

+

{t("page.analytics.description")}

@@ -77,59 +79,65 @@ export default async function GradeAnalyticsPage({ const targetExamId = examId && examId !== "all" ? examId : undefined // Run analytics queries in parallel - const [trend, distribution, subjectComparison, classComparison, examOptions] = - await Promise.all([ - getGradeTrend({ - classId: targetClassId, - subjectId: targetSubjectId, - semester: targetSemester, - examId: targetExamId, - scope: ctx.dataScope, - currentUserId: ctx.userId, - }), - getGradeDistribution({ - classId: targetClassId, - subjectId: targetSubjectId, - examId: targetExamId, - semester: targetSemester, - scope: ctx.dataScope, - currentUserId: ctx.userId, - }), - getSubjectComparison({ - classId: targetClassId, - examId: targetExamId, - semester: targetSemester, - scope: ctx.dataScope, - }), - targetGradeId - ? getClassComparison({ - gradeId: targetGradeId, - subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "", - examId: targetExamId, - semester: targetSemester, - scope: ctx.dataScope, - }) - : Promise.resolve([]), - getExamOptionsForGrades({ - classId: targetClassId, - subjectId: targetSubjectId, - scope: ctx.dataScope, - }), - ]) + // P3-3: 集成 diagnostic 模块的知识点掌握度视图(classId 已由 getTeacherClasses 校验在 scope 内) + const [ + trend, + distribution, + subjectComparison, + classComparisonResult, + examOptions, + classMasterySummary, + ] = await Promise.all([ + getGradeTrend({ + classId: targetClassId, + subjectId: targetSubjectId, + semester: targetSemester, + examId: targetExamId, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }), + getGradeDistribution({ + classId: targetClassId, + subjectId: targetSubjectId, + examId: targetExamId, + semester: targetSemester, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }), + getSubjectComparison({ + classId: targetClassId, + examId: targetExamId, + semester: targetSemester, + scope: ctx.dataScope, + }), + targetGradeId + ? getClassComparisonWithSignificance({ + gradeId: targetGradeId, + subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "", + examId: targetExamId, + semester: targetSemester, + scope: ctx.dataScope, + }) + : Promise.resolve({ items: [], significance: null }), + getExamOptionsForGrades({ + classId: targetClassId, + subjectId: targetSubjectId, + scope: ctx.dataScope, + }), + getClassMasterySummary(targetClassId), + ]) return (
-

成绩分析

-

- 趋势分析、班级对比与分数分布。 -

+

{t("title.analytics")}

+

{t("page.analytics.description")}

@@ -147,54 +155,63 @@ export default async function GradeAnalyticsPage({ />
- + {trend ? ( ) : ( )} - + {distribution.totalCount > 0 ? ( ) : ( )} - + {subjectComparison.length > 0 ? ( ) : ( )} - - {classComparison.length > 0 ? ( - + + {classComparisonResult.items.length > 0 ? ( + ) : ( )} + + +
) diff --git a/src/app/(dashboard)/teacher/grades/entry/loading.tsx b/src/app/(dashboard)/teacher/grades/entry/loading.tsx index db96284..0f89f74 100644 --- a/src/app/(dashboard)/teacher/grades/entry/loading.tsx +++ b/src/app/(dashboard)/teacher/grades/entry/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/teacher/grades/entry/page.tsx b/src/app/(dashboard)/teacher/grades/entry/page.tsx index 9890ce0..ba5b9e6 100644 --- a/src/app/(dashboard)/teacher/grades/entry/page.tsx +++ b/src/app/(dashboard)/teacher/grades/entry/page.tsx @@ -8,6 +8,7 @@ import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { EmptyState } from "@/shared/components/ui/empty-state" import { ClipboardList } from "lucide-react" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -18,6 +19,7 @@ export default async function BatchEntryPage({ }): Promise { const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) const sp = await searchParams + const t = await getTranslations("grades") const examId = getParam(sp, "examId") const classId = getParam(sp, "classId") @@ -62,16 +64,14 @@ export default async function BatchEntryPage({ return (
-

批量录入成绩

-

- 从试卷库选择试卷,按每题得分录入,像填 Excel 表格一样。 -

+

{t("page.entry.title")}

+

{t("page.entry.description")}

{exams.length === 0 ? ( diff --git a/src/app/(dashboard)/teacher/grades/error.tsx b/src/app/(dashboard)/teacher/grades/error.tsx index 7c3d50c..55bf962 100644 --- a/src/app/(dashboard)/teacher/grades/error.tsx +++ b/src/app/(dashboard)/teacher/grades/error.tsx @@ -1,6 +1,8 @@ "use client" +import type { JSX } from "react" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -9,15 +11,16 @@ export default function TeacherGradesError({ }: { error: Error & { digest?: string } reset: () => void -}) { +}): JSX.Element { + const t = useTranslations("grades") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/teacher/grades/loading.tsx b/src/app/(dashboard)/teacher/grades/loading.tsx index 889c68b..0ea907b 100644 --- a/src/app/(dashboard)/teacher/grades/loading.tsx +++ b/src/app/(dashboard)/teacher/grades/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/teacher/grades/page.tsx b/src/app/(dashboard)/teacher/grades/page.tsx index d4b9040..5de3ba3 100644 --- a/src/app/(dashboard)/teacher/grades/page.tsx +++ b/src/app/(dashboard)/teacher/grades/page.tsx @@ -1,9 +1,11 @@ import type { JSX } from "react" import Link from "next/link" import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" +import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination" +import { WidgetBoundary } from "@/shared/components/widget-boundary" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" @@ -13,21 +15,11 @@ import { getSubjectOptions } from "@/modules/school/data-access" import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters" import { GradeRecordList } from "@/modules/grades/components/grade-record-list" import { ExportButton } from "@/modules/grades/components/export-button" -import type { GradeRecordType, GradeRecordSemester } from "@/modules/grades/types" +import { ExcelImportDialog } from "@/modules/grades/components/excel-import-dialog" +import { isGradeType, isSemester } from "@/modules/grades/lib/type-guards" export const dynamic = "force-dynamic" -const VALID_GRADE_TYPES: ReadonlySet = new Set(["exam", "quiz", "homework", "other"]) -const VALID_SEMESTERS: ReadonlySet = new Set(["1", "2"]) - -function parseGradeType(v?: string): GradeRecordType | undefined { - return v && VALID_GRADE_TYPES.has(v) ? (v as GradeRecordType) : undefined -} - -function parseSemester(v?: string): GradeRecordSemester | undefined { - return v && VALID_SEMESTERS.has(v) ? (v as GradeRecordSemester) : undefined -} - const PAGE_SIZE = 20 export default async function TeacherGradesPage({ @@ -37,6 +29,7 @@ export default async function TeacherGradesPage({ }): Promise { const sp = await searchParams const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") const classId = getParam(sp, "classId") const subjectId = getParam(sp, "subjectId") @@ -55,8 +48,8 @@ export default async function TeacherGradesPage({ currentUserId: ctx.userId, classId: classId && classId !== "all" ? classId : undefined, subjectId: subjectId && subjectId !== "all" ? subjectId : undefined, - type: type && type !== "all" ? parseGradeType(type) : undefined, - semester: semester && semester !== "all" ? parseSemester(semester) : undefined, + type: type && type !== "all" ? isGradeType(type) ? type : undefined : undefined, + semester: semester && semester !== "all" ? isSemester(semester) ? semester : undefined : undefined, limit: PAGE_SIZE, offset, }), @@ -76,20 +69,20 @@ export default async function TeacherGradesPage({
-

成绩记录

-

管理学生成绩记录。

+

{t("title.list")}

+

{t("page.list.description")}

+ ({ id: c.id, name: c.name }))} + subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))} + />
@@ -110,17 +108,20 @@ export default async function TeacherGradesPage({ {total === 0 && !hasFilters ? ( ) : (
- + {/* P1-9: 用 WidgetBoundary 包裹独立数据区块,隔离故障域 */} + + + {total > 0 ? ( ) : null}
diff --git a/src/app/(dashboard)/teacher/grades/report-card/error.tsx b/src/app/(dashboard)/teacher/grades/report-card/error.tsx new file mode 100644 index 0000000..f86c105 --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/report-card/error.tsx @@ -0,0 +1,35 @@ +"use client" + +import type { JSX } from "react" +import { useEffect } from "react" +import { AlertTriangle } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { useTranslations } from "next-intl" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}): JSX.Element { + const t = useTranslations("grades") + useEffect(() => { + console.error("[ReportCard] Route error:", error) + }, [error]) + + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/report-card/loading.tsx b/src/app/(dashboard)/teacher/grades/report-card/loading.tsx new file mode 100644 index 0000000..0268bf4 --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/report-card/loading.tsx @@ -0,0 +1,12 @@ +import { Loader2 } from "lucide-react" +import { getTranslations } from "next-intl/server" + +export default async function Loading() { + const t = await getTranslations("grades") + return ( +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/report-card/page.tsx b/src/app/(dashboard)/teacher/grades/report-card/page.tsx new file mode 100644 index 0000000..f55924d --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/report-card/page.tsx @@ -0,0 +1,116 @@ +import type { JSX } from "react" +import { ArrowLeft } from "lucide-react" +import Link from "next/link" +import { getTranslations } from "next-intl/server" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getParam, type SearchParams } from "@/shared/lib/search-params" + +import { getReportCardData } from "@/modules/grades/lib/report-card" +import { ReportCardView } from "@/modules/grades/components/report-card-view" +import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action" +import { getAcademicYears } from "@/modules/school/data-access" + +export const dynamic = "force-dynamic" + +/** + * P3-1: 教师视角的学生成绩报告卡页面。 + * + * 路由:/teacher/grades/report-card?studentId=xxx + * 查询参数: + * - studentId: 必填,目标学生 ID + * - academicYearId?: 指定学年(不传则使用当前活跃学年) + * - semester?: "1" | "2"(不传则全部学期) + * + * 权限:GRADE_RECORD_READ(class_taught scope 在 data-access 层校验学生归属) + */ +export default async function TeacherReportCardPage({ + searchParams, +}: { + searchParams: Promise +}): Promise { + const sp = await searchParams + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) + const t = await getTranslations("grades") + + const studentId = getParam(sp, "studentId") + const academicYearIdParam = getParam(sp, "academicYearId") + const semesterParam = getParam(sp, "semester") + const academicYearId = + academicYearIdParam && academicYearIdParam !== "all" + ? academicYearIdParam + : undefined + const semester = + semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined + + if (!studentId) { + return ( +
+ + +
+ ) + } + + const [data, academicYears] = await Promise.all([ + getReportCardData(studentId, ctx.dataScope, { + academicYearId, + semester, + }), + getAcademicYears(), + ]) + + if (!data) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ + +
+ + + +

+ {t("reportCard.academicYearsCount", { + count: academicYears.length, + })} +

+
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/stats/loading.tsx b/src/app/(dashboard)/teacher/grades/stats/loading.tsx index 5976064..e19c8bf 100644 --- a/src/app/(dashboard)/teacher/grades/stats/loading.tsx +++ b/src/app/(dashboard)/teacher/grades/stats/loading.tsx @@ -1,7 +1,8 @@ +import type { JSX } from "react" import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" import { Skeleton } from "@/shared/components/ui/skeleton" -export default function Loading() { +export default function Loading(): JSX.Element { return (
diff --git a/src/app/(dashboard)/teacher/grades/stats/page.tsx b/src/app/(dashboard)/teacher/grades/stats/page.tsx index 33563c1..0dfb712 100644 --- a/src/app/(dashboard)/teacher/grades/stats/page.tsx +++ b/src/app/(dashboard)/teacher/grades/stats/page.tsx @@ -10,6 +10,7 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { BarChart3 } from "lucide-react" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" @@ -20,6 +21,7 @@ export default async function StatsPage({ }): Promise { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const sp = await searchParams + const t = await getTranslations("grades") const classId = getParam(sp, "classId") const subjectId = getParam(sp, "subjectId") @@ -33,12 +35,12 @@ export default async function StatsPage({ return (
-

Grade Statistics

-

View class grade statistics and rankings.

+

{t("title.stats")}

+

{t("page.stats.description")}

@@ -57,12 +59,12 @@ export default async function StatsPage({ return (
-

Grade Statistics

-

View class grade statistics and rankings.

+

{t("title.stats")}

+

{t("page.stats.description")}

@@ -98,14 +100,14 @@ export default async function StatsPage({
-

Grade Statistics

-

View class grade statistics and rankings.

+

{t("title.stats")}

+

{t("page.stats.description")}

diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx index 5c284e3..56ceab3 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx @@ -1,18 +1,27 @@ import type { JSX } from "react" +import { Suspense } from "react" +import type { ReactNode } from "react" import Link from "next/link" import { notFound } from "next/navigation" -import { getHomeworkAssignmentAnalytics } from "@/modules/homework/data-access" +import { getHomeworkAssignmentAnalytics } from "@/modules/homework/stats-service" import { HomeworkAssignmentExamContentCard } from "@/modules/homework/components/homework-assignment-exam-content-card" import { HomeworkAssignmentQuestionErrorOverviewCard } from "@/modules/homework/components/homework-assignment-question-error-overview-card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { SectionErrorBoundary } from "@/shared/components/section-error-boundary" import { formatDate } from "@/shared/lib/utils" import { ArrowLeft, Users, Calendar, BarChart3, CheckCircle2 } from "lucide-react" +import { requirePermission } from "@/shared/lib/auth-guard" +import { getTranslations } from "next-intl/server" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" export default async function HomeworkAssignmentDetailPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params + await requirePermission(Permissions.HOMEWORK_CREATE) + const t = await getTranslations("examHomework") const analytics = await getHomeworkAssignmentAnalytics(id) if (!analytics) return notFound() @@ -28,7 +37,7 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
@@ -37,14 +46,14 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params: {assignment.status}
-

{assignment.description || "No description provided."}

+

{assignment.description || t("homework.take.noDescription")}

@@ -54,38 +63,40 @@ export default async function HomeworkAssignmentDetailPage({ params }: { params:
- {/* Analytics Section */} + {/* Analytics Section - wrapped with SectionErrorBoundary + Suspense for graceful degradation */}
-

Performance Analytics

-
-
- +

{t("homework.detail.performanceAnalytics")}

+ + }> + + +
{/* Content Section */}
-

Assignment Content

+

{t("homework.detail.assignmentContent")}

) } + +/** + * Analytics 区块加载骨架屏。 + */ +function AnalyticsSkeleton(): ReactNode { + return ( +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx index 6b03c52..46ec44d 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx @@ -5,15 +5,18 @@ import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { getHomeworkAssignmentById, getHomeworkSubmissions } from "@/modules/homework/data-access" import { HomeworkBatchGradingView } from "@/modules/homework/components/homework-batch-grading-view" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params + const ctx = await requirePermission(Permissions.HOMEWORK_GRADE) const t = await getTranslations("examHomework") const [assignment, submissions] = await Promise.all([ - getHomeworkAssignmentById(id), - getHomeworkSubmissions({ assignmentId: id }), + getHomeworkAssignmentById(id, ctx.dataScope), + getHomeworkSubmissions({ assignmentId: id, scope: ctx.dataScope }), ]) if (!assignment) return notFound() diff --git a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx index 7431f55..eadc275 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx @@ -4,12 +4,14 @@ import { getExams } from "@/modules/exams/data-access" import { getTeacherClasses } from "@/modules/classes/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" import { getAuthContext } from "@/shared/lib/auth-guard" +import { getTranslations } from "next-intl/server" import { FileQuestion } from "lucide-react" export const dynamic = "force-dynamic" export default async function CreateHomeworkAssignmentPage(): Promise { const { dataScope } = await getAuthContext() + const t = await getTranslations("examHomework") const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()]) const options = exams.map((e) => ({ id: e.id, title: e.title })) @@ -17,17 +19,17 @@ export default async function CreateHomeworkAssignmentPage(): Promise
-

Create Assignment

-

快速发布文本作业或从考试派生。

+

{t("homework.form.createTitle")}

+

{t("homework.form.createDescription")}

{classes.length === 0 ? ( ) : ( diff --git a/src/app/(dashboard)/teacher/homework/assignments/error.tsx b/src/app/(dashboard)/teacher/homework/assignments/error.tsx new file mode 100644 index 0000000..6a23f9a --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/error.tsx @@ -0,0 +1,28 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * teacher/homework/assignments/error.tsx + * 作业列表错误边界(教师视图)。 + */ +export default function AssignmentsListError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/loading.tsx b/src/app/(dashboard)/teacher/homework/assignments/loading.tsx new file mode 100644 index 0000000..65a4ab0 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/loading.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +/** + * teacher/homework/assignments/loading.tsx + * 作业列表骨架屏(教师视图):标题 + 表格行骨架。 + */ +export default function Loading() { + return ( +
+
+
+ + +
+ +
+
+
+ + {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx index a40f450..594e047 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx @@ -20,6 +20,8 @@ import { getTeacherClasses } from "@/modules/classes/data-access" import { PenTool, PlusCircle, AlertCircle } from "lucide-react" import { getTeacherIdForMutations } from "@/modules/classes/data-access" import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -29,6 +31,7 @@ export default async function AssignmentsPage({ searchParams }: { searchParams: const t = await getTranslations("examHomework") const sp = await searchParams const rawClassId = getParam(sp, "classId") + const ctx = await requirePermission(Permissions.HOMEWORK_CREATE) const creatorId = await getTeacherIdForMutations() // Only fetch classes list when a class filter is active — needed to resolve @@ -36,7 +39,7 @@ export default async function AssignmentsPage({ searchParams }: { searchParams: // avoid an unnecessary DB round-trip. const filteredClassId = rawClassId && rawClassId !== "all" ? rawClassId : null const [assignments, classes] = await Promise.all([ - getHomeworkAssignments({ creatorId, classId: filteredClassId ?? undefined }), + getHomeworkAssignments({ creatorId, classId: filteredClassId ?? undefined, scope: ctx.dataScope }), filteredClassId ? getTeacherClasses() : Promise.resolve([]), ]) const hasAssignments = assignments.length > 0 diff --git a/src/app/(dashboard)/teacher/homework/error.tsx b/src/app/(dashboard)/teacher/homework/error.tsx new file mode 100644 index 0000000..f4cb9a7 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/error.tsx @@ -0,0 +1,28 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * teacher/homework/error.tsx + * 作业模块入口错误边界。 + */ +export default function HomeworkRootError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/loading.tsx b/src/app/(dashboard)/teacher/homework/loading.tsx new file mode 100644 index 0000000..4c81b26 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +/** + * teacher/homework/loading.tsx + * 作业模块入口骨架屏(实际会重定向到 /assignments,但保留骨架屏以避免空白闪烁)。 + */ +export default function Loading() { + return ( +
+
+ + +
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/error.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/error.tsx new file mode 100644 index 0000000..b7ff3e2 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/error.tsx @@ -0,0 +1,28 @@ +"use client" + +import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * teacher/homework/submissions/[submissionId]/error.tsx + * 批改页错误边界(教师视图)。 + */ +export default function SubmissionGradingError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/loading.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/loading.tsx new file mode 100644 index 0000000..7622eec --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +/** + * teacher/homework/submissions/[submissionId]/loading.tsx + * 批改页骨架屏:标题 + 题目卡片骨架 + 右侧汇总卡片骨架。 + */ +export default function Loading() { + return ( +
+
+
+ + +
+ +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx index 564cee2..5c57d53 100644 --- a/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx +++ b/src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx @@ -7,46 +7,22 @@ import { HomeworkGradingView } from "@/modules/homework/components/homework-grad import { Button } from "@/shared/components/ui/button" import { ScanLine } from "lucide-react" import { formatDate } from "@/shared/lib/utils" -import { - AiClientProvider, - type AiClientService, -} from "@/modules/ai/context/ai-client-provider" -import { - suggestGradingAction, - aiChatAction, - suggestSimilarQuestionsAction, - generateLessonContentAction, - generateQuestionVariantAction, - analyzeWeaknessAction, -} from "@/modules/ai/actions" +import { AiClientProvider } from "@/modules/ai/context/ai-client-provider" +import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" -/** - * 构建 AI 客户端服务(Server Action 引用集合) - * - * 通过 React Context 注入,客户端组件不直接 import actions, - * 遵循依赖注入模式,便于测试时替换为 mock。 - */ -function createAiClientService(): AiClientService { - return { - chat: aiChatAction, - suggestSimilarQuestions: suggestSimilarQuestionsAction, - suggestGrading: suggestGradingAction, - generateLessonContent: generateLessonContentAction, - generateQuestionVariant: generateQuestionVariantAction, - analyzeWeakness: analyzeWeaknessAction, - } -} - export default async function HomeworkSubmissionGradingPage({ params }: { params: Promise<{ submissionId: string }> }): Promise { const { submissionId } = await params + await requirePermission(Permissions.HOMEWORK_GRADE) const t = await getTranslations("examHomework") const submission = await getHomeworkSubmissionDetails(submissionId) if (!submission) return notFound() - const aiClientService = createAiClientService() + const aiClientService = createCoreAiClientService() return (
@@ -55,12 +31,12 @@ export default async function HomeworkSubmissionGradingPage({ params }: { params

{submission.assignmentTitle}

- Student: {submission.studentName} + {t("homework.grade.student")}: {submission.studentName} - Submitted: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"} + {t("homework.grade.submitted")}: {submission.submittedAt ? formatDate(submission.submittedAt) : "-"} - Status: {submission.status} + {t("homework.grade.status")}: {submission.status}