From 3b6272c99d9751a1c18c039fd0a14cdaf273f949 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:44:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20P1=20=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=8A=9F=E8=83=BD=20+=20=E4=BF=AE=E5=A4=8D=20proxy=20?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=20+=20=E5=88=87=E6=8D=A2=20MySQL=20=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E8=87=B3=2014013?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系 --- .gitea/workflows/ci.yml | 42 + .gitignore | 10 + .../004_architecture_impact_map.md | 2359 ++++++++++++++++- docs/architecture/005_architecture_data.json | 1257 ++++++++- docs/architecture/007_gap_audit_report.md | 278 +- package-lock.json | 1159 +++++++- package.json | 8 +- scripts/audit.ps1 | 19 + scripts/audit.sh | 17 + scripts/backup-db.sh | 46 + scripts/create-db.ts | 34 + scripts/restore-db.sh | 34 + scripts/test-backup.sh | 8 + src/app/(auth)/privacy/page.tsx | 128 + src/app/(auth)/register/page.tsx | 33 + src/app/(auth)/terms/page.tsx | 116 + .../admin/announcements/[id]/page.tsx | 36 + .../(dashboard)/admin/announcements/page.tsx | 39 + src/app/(dashboard)/admin/attendance/page.tsx | 71 + .../admin/audit-logs/data-changes/page.tsx | 69 + .../admin/audit-logs/login-logs/page.tsx | 59 + src/app/(dashboard)/admin/audit-logs/page.tsx | 65 + .../admin/course-plans/[id]/edit/page.tsx | 45 + .../admin/course-plans/[id]/page.tsx | 27 + .../admin/course-plans/create/page.tsx | 32 + .../(dashboard)/admin/course-plans/page.tsx | 45 + src/app/(dashboard)/admin/files/page.tsx | 19 + .../admin/scheduling/auto/page.tsx | 51 + .../admin/scheduling/changes/page.tsx | 91 + .../admin/scheduling/rules/page.tsx | 47 + .../(dashboard)/admin/users/import/page.tsx | 134 + src/app/(dashboard)/announcements/page.tsx | 20 + src/app/(dashboard)/messages/[id]/page.tsx | 30 + src/app/(dashboard)/messages/compose/page.tsx | 34 + src/app/(dashboard)/messages/page.tsx | 31 + .../(dashboard)/parent/attendance/page.tsx | 61 + .../parent/children/[studentId]/page.tsx | 71 + src/app/(dashboard)/parent/dashboard/page.tsx | 16 +- src/app/(dashboard)/parent/grades/page.tsx | 61 + src/app/(dashboard)/settings/page.tsx | 12 +- .../(dashboard)/settings/security/page.tsx | 50 + .../(dashboard)/student/attendance/page.tsx | 40 + src/app/(dashboard)/student/grades/page.tsx | 40 + .../(dashboard)/teacher/attendance/page.tsx | 83 + .../teacher/attendance/sheet/page.tsx | 49 + .../teacher/attendance/stats/page.tsx | 120 + .../teacher/course-plans/[id]/page.tsx | 26 + .../(dashboard)/teacher/course-plans/page.tsx | 49 + .../teacher/grades/analytics/page.tsx | 259 ++ .../(dashboard)/teacher/grades/entry/page.tsx | 52 + src/app/(dashboard)/teacher/grades/page.tsx | 101 + .../(dashboard)/teacher/grades/stats/page.tsx | 139 + .../teacher/schedule-changes/page.tsx | 69 + src/app/api/ai/chat/route.ts | 16 +- src/app/api/export/route.ts | 135 + src/app/api/files/[id]/route.ts | 87 + src/app/api/files/batch-delete/route.ts | 58 + src/app/api/import/route.ts | 62 + src/app/api/rate-limit-test/route.ts | 48 + src/app/api/search/route.ts | 275 ++ src/app/api/upload/route.ts | 128 + src/auth.ts | 186 +- src/modules/announcements/actions.ts | 242 ++ .../components/admin-announcements-view.tsx | 65 + .../components/announcement-card.tsx | 79 + .../components/announcement-detail.tsx | 206 ++ .../components/announcement-form.tsx | 201 ++ .../components/announcement-list.tsx | 108 + src/modules/announcements/data-access.ts | 120 + src/modules/announcements/schema.ts | 45 + src/modules/announcements/types.ts | 27 + src/modules/attendance/actions.ts | 271 ++ .../components/attendance-filters.tsx | 97 + .../components/attendance-record-list.tsx | 130 + .../components/attendance-rules-form.tsx | 148 ++ .../components/attendance-sheet.tsx | 215 ++ .../components/attendance-stats-card.tsx | 100 + .../components/student-attendance-view.tsx | 104 + src/modules/attendance/data-access-stats.ts | 145 + src/modules/attendance/data-access.ts | 271 ++ src/modules/attendance/schema.ts | 43 + src/modules/attendance/types.ts | 103 + src/modules/audit/actions.ts | 212 ++ .../components/audit-log-export-button.tsx | 89 + .../audit/components/audit-log-filters.tsx | 100 + .../audit/components/audit-log-table.tsx | 156 ++ .../audit/components/audit-log-view.tsx | 61 + .../components/data-change-log-table.tsx | 325 +++ .../audit/components/login-log-filters.tsx | 85 + .../audit/components/login-log-table.tsx | 150 ++ .../audit/components/login-log-view.tsx | 59 + src/modules/audit/data-access.ts | 260 ++ src/modules/audit/types.ts | 91 + src/modules/auth/components/register-form.tsx | 197 +- src/modules/course-plans/actions.ts | 265 ++ .../components/course-plan-detail.tsx | 258 ++ .../components/course-plan-form.tsx | 284 ++ .../components/course-plan-item-editor.tsx | 248 ++ .../components/course-plan-list.tsx | 160 ++ .../components/course-plan-progress.tsx | 38 + src/modules/course-plans/data-access.ts | 320 +++ src/modules/course-plans/schema.ts | 149 ++ src/modules/course-plans/types.ts | 60 + .../files/components/admin-files-view.tsx | 289 ++ src/modules/files/components/file-icon.tsx | 86 + src/modules/files/components/file-list.tsx | 126 + .../files/components/file-preview-dialog.tsx | 56 + src/modules/files/components/file-preview.tsx | 234 ++ src/modules/files/components/file-upload.tsx | 249 ++ src/modules/files/data-access.ts | 267 ++ src/modules/files/types.ts | 63 + src/modules/grades/actions-analytics.ts | 133 + src/modules/grades/actions.ts | 312 +++ .../grades/components/batch-grade-entry.tsx | 219 ++ .../components/class-comparison-chart.tsx | 132 + .../grades/components/class-grade-report.tsx | 92 + .../grades/components/export-button.tsx | 101 + .../components/grade-distribution-chart.tsx | 138 + .../grades/components/grade-query-filters.tsx | 104 + .../grades/components/grade-record-form.tsx | 184 ++ .../grades/components/grade-record-list.tsx | 136 + .../grades/components/grade-stats-card.tsx | 93 + .../grades/components/grade-trend-chart.tsx | 137 + .../components/student-grade-summary.tsx | 117 + .../components/subject-comparison-chart.tsx | 116 + src/modules/grades/data-access-analytics.ts | 293 ++ src/modules/grades/data-access-ranking.ts | 121 + src/modules/grades/data-access.ts | 419 +++ src/modules/grades/export.ts | 214 ++ src/modules/grades/schema.ts | 52 + src/modules/grades/types.ts | 176 ++ src/modules/layout/components/site-header.tsx | 19 +- src/modules/layout/config/navigation.ts | 150 +- src/modules/messaging/actions.ts | 245 ++ .../messaging/components/message-compose.tsx | 146 + .../messaging/components/message-detail.tsx | 153 ++ .../messaging/components/message-list.tsx | 117 + .../components/notification-dropdown.tsx | 159 ++ .../components/notification-list.tsx | 141 + src/modules/messaging/data-access.ts | 252 ++ .../messaging/notification-preferences.ts | 166 ++ src/modules/messaging/schema.ts | 18 + src/modules/messaging/types.ts | 108 + src/modules/parent/components/child-card.tsx | 89 + .../parent/components/child-detail-header.tsx | 49 + .../parent/components/child-detail-panel.tsx | 27 + .../parent/components/child-grade-summary.tsx | 163 ++ .../components/child-homework-summary.tsx | 131 + .../parent/components/child-schedule-card.tsx | 67 + .../parent/components/parent-dashboard.tsx | 68 + src/modules/parent/data-access.ts | 234 ++ src/modules/parent/types.ts | 57 + .../questions/components/question-actions.tsx | 2 +- src/modules/scheduling/actions.ts | 302 +++ src/modules/scheduling/auto-scheduler.ts | 310 +++ .../components/auto-schedule-panel.tsx | 120 + .../components/auto-schedule-result.tsx | 94 + .../components/schedule-change-form.tsx | 208 ++ .../components/schedule-change-list.tsx | 219 ++ .../components/schedule-conflicts-view.tsx | 137 + .../components/scheduling-rules-form.tsx | 235 ++ src/modules/scheduling/data-access.ts | 272 ++ src/modules/scheduling/schema.ts | 81 + src/modules/scheduling/types.ts | 124 + src/modules/school/actions.ts | 34 + src/modules/settings/actions-password.ts | 113 + src/modules/settings/actions.ts | 13 +- .../components/admin-settings-view.tsx | 21 +- .../notification-preferences-form.tsx | 260 ++ .../components/password-change-form.tsx | 180 ++ .../components/student-settings-view.tsx | 21 +- .../components/teacher-settings-view.tsx | 21 +- src/modules/users/actions.ts | 125 +- .../users/components/user-import-dialog.tsx | 316 +++ src/modules/users/import-export.ts | 291 ++ src/proxy.ts | 6 +- src/shared/components/global-search.tsx | 221 ++ src/shared/components/ui/switch.tsx | 29 + src/shared/db/relations.ts | 198 +- src/shared/db/schema.ts | 460 +++- src/shared/lib/audit-logger.ts | 50 + src/shared/lib/auth-guard.ts | 9 +- src/shared/lib/change-logger.ts | 47 + src/shared/lib/excel.ts | 173 ++ src/shared/lib/file-storage.ts | 70 + src/shared/lib/login-logger.ts | 46 + src/shared/lib/password-policy.ts | 119 + src/shared/lib/permissions.ts | 49 + src/shared/lib/rate-limit.ts | 119 + src/shared/lib/storage-provider.ts | 74 + src/shared/types/permissions.ts | 33 + tests/e2e/announcements.spec.ts | 28 + tests/e2e/auth.spec.ts | 28 + tests/e2e/grades.spec.ts | 33 + tests/e2e/navigation.spec.ts | 108 + 195 files changed, 27274 insertions(+), 416 deletions(-) create mode 100644 scripts/audit.ps1 create mode 100644 scripts/audit.sh create mode 100644 scripts/backup-db.sh create mode 100644 scripts/create-db.ts create mode 100644 scripts/restore-db.sh create mode 100644 scripts/test-backup.sh create mode 100644 src/app/(auth)/privacy/page.tsx create mode 100644 src/app/(auth)/terms/page.tsx create mode 100644 src/app/(dashboard)/admin/announcements/[id]/page.tsx create mode 100644 src/app/(dashboard)/admin/announcements/page.tsx create mode 100644 src/app/(dashboard)/admin/attendance/page.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx create mode 100644 src/app/(dashboard)/admin/audit-logs/page.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/[id]/page.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/create/page.tsx create mode 100644 src/app/(dashboard)/admin/course-plans/page.tsx create mode 100644 src/app/(dashboard)/admin/files/page.tsx create mode 100644 src/app/(dashboard)/admin/scheduling/auto/page.tsx create mode 100644 src/app/(dashboard)/admin/scheduling/changes/page.tsx create mode 100644 src/app/(dashboard)/admin/scheduling/rules/page.tsx create mode 100644 src/app/(dashboard)/admin/users/import/page.tsx create mode 100644 src/app/(dashboard)/announcements/page.tsx create mode 100644 src/app/(dashboard)/messages/[id]/page.tsx create mode 100644 src/app/(dashboard)/messages/compose/page.tsx create mode 100644 src/app/(dashboard)/messages/page.tsx create mode 100644 src/app/(dashboard)/parent/attendance/page.tsx create mode 100644 src/app/(dashboard)/parent/children/[studentId]/page.tsx create mode 100644 src/app/(dashboard)/parent/grades/page.tsx create mode 100644 src/app/(dashboard)/settings/security/page.tsx create mode 100644 src/app/(dashboard)/student/attendance/page.tsx create mode 100644 src/app/(dashboard)/student/grades/page.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/page.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/sheet/page.tsx create mode 100644 src/app/(dashboard)/teacher/attendance/stats/page.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/[id]/page.tsx create mode 100644 src/app/(dashboard)/teacher/course-plans/page.tsx create mode 100644 src/app/(dashboard)/teacher/grades/analytics/page.tsx create mode 100644 src/app/(dashboard)/teacher/grades/entry/page.tsx create mode 100644 src/app/(dashboard)/teacher/grades/page.tsx create mode 100644 src/app/(dashboard)/teacher/grades/stats/page.tsx create mode 100644 src/app/(dashboard)/teacher/schedule-changes/page.tsx create mode 100644 src/app/api/export/route.ts create mode 100644 src/app/api/files/[id]/route.ts create mode 100644 src/app/api/files/batch-delete/route.ts create mode 100644 src/app/api/import/route.ts create mode 100644 src/app/api/rate-limit-test/route.ts create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/api/upload/route.ts create mode 100644 src/modules/announcements/actions.ts create mode 100644 src/modules/announcements/components/admin-announcements-view.tsx create mode 100644 src/modules/announcements/components/announcement-card.tsx create mode 100644 src/modules/announcements/components/announcement-detail.tsx create mode 100644 src/modules/announcements/components/announcement-form.tsx create mode 100644 src/modules/announcements/components/announcement-list.tsx create mode 100644 src/modules/announcements/data-access.ts create mode 100644 src/modules/announcements/schema.ts create mode 100644 src/modules/announcements/types.ts create mode 100644 src/modules/attendance/actions.ts create mode 100644 src/modules/attendance/components/attendance-filters.tsx create mode 100644 src/modules/attendance/components/attendance-record-list.tsx create mode 100644 src/modules/attendance/components/attendance-rules-form.tsx create mode 100644 src/modules/attendance/components/attendance-sheet.tsx create mode 100644 src/modules/attendance/components/attendance-stats-card.tsx create mode 100644 src/modules/attendance/components/student-attendance-view.tsx create mode 100644 src/modules/attendance/data-access-stats.ts create mode 100644 src/modules/attendance/data-access.ts create mode 100644 src/modules/attendance/schema.ts create mode 100644 src/modules/attendance/types.ts create mode 100644 src/modules/audit/actions.ts create mode 100644 src/modules/audit/components/audit-log-export-button.tsx create mode 100644 src/modules/audit/components/audit-log-filters.tsx create mode 100644 src/modules/audit/components/audit-log-table.tsx create mode 100644 src/modules/audit/components/audit-log-view.tsx create mode 100644 src/modules/audit/components/data-change-log-table.tsx create mode 100644 src/modules/audit/components/login-log-filters.tsx create mode 100644 src/modules/audit/components/login-log-table.tsx create mode 100644 src/modules/audit/components/login-log-view.tsx create mode 100644 src/modules/audit/data-access.ts create mode 100644 src/modules/audit/types.ts create mode 100644 src/modules/course-plans/actions.ts create mode 100644 src/modules/course-plans/components/course-plan-detail.tsx create mode 100644 src/modules/course-plans/components/course-plan-form.tsx create mode 100644 src/modules/course-plans/components/course-plan-item-editor.tsx create mode 100644 src/modules/course-plans/components/course-plan-list.tsx create mode 100644 src/modules/course-plans/components/course-plan-progress.tsx create mode 100644 src/modules/course-plans/data-access.ts create mode 100644 src/modules/course-plans/schema.ts create mode 100644 src/modules/course-plans/types.ts create mode 100644 src/modules/files/components/admin-files-view.tsx create mode 100644 src/modules/files/components/file-icon.tsx create mode 100644 src/modules/files/components/file-list.tsx create mode 100644 src/modules/files/components/file-preview-dialog.tsx create mode 100644 src/modules/files/components/file-preview.tsx create mode 100644 src/modules/files/components/file-upload.tsx create mode 100644 src/modules/files/data-access.ts create mode 100644 src/modules/files/types.ts create mode 100644 src/modules/grades/actions-analytics.ts create mode 100644 src/modules/grades/actions.ts create mode 100644 src/modules/grades/components/batch-grade-entry.tsx create mode 100644 src/modules/grades/components/class-comparison-chart.tsx create mode 100644 src/modules/grades/components/class-grade-report.tsx create mode 100644 src/modules/grades/components/export-button.tsx create mode 100644 src/modules/grades/components/grade-distribution-chart.tsx create mode 100644 src/modules/grades/components/grade-query-filters.tsx create mode 100644 src/modules/grades/components/grade-record-form.tsx create mode 100644 src/modules/grades/components/grade-record-list.tsx create mode 100644 src/modules/grades/components/grade-stats-card.tsx create mode 100644 src/modules/grades/components/grade-trend-chart.tsx create mode 100644 src/modules/grades/components/student-grade-summary.tsx create mode 100644 src/modules/grades/components/subject-comparison-chart.tsx create mode 100644 src/modules/grades/data-access-analytics.ts create mode 100644 src/modules/grades/data-access-ranking.ts create mode 100644 src/modules/grades/data-access.ts create mode 100644 src/modules/grades/export.ts create mode 100644 src/modules/grades/schema.ts create mode 100644 src/modules/grades/types.ts create mode 100644 src/modules/messaging/actions.ts create mode 100644 src/modules/messaging/components/message-compose.tsx create mode 100644 src/modules/messaging/components/message-detail.tsx create mode 100644 src/modules/messaging/components/message-list.tsx create mode 100644 src/modules/messaging/components/notification-dropdown.tsx create mode 100644 src/modules/messaging/components/notification-list.tsx create mode 100644 src/modules/messaging/data-access.ts create mode 100644 src/modules/messaging/notification-preferences.ts create mode 100644 src/modules/messaging/schema.ts create mode 100644 src/modules/messaging/types.ts create mode 100644 src/modules/parent/components/child-card.tsx create mode 100644 src/modules/parent/components/child-detail-header.tsx create mode 100644 src/modules/parent/components/child-detail-panel.tsx create mode 100644 src/modules/parent/components/child-grade-summary.tsx create mode 100644 src/modules/parent/components/child-homework-summary.tsx create mode 100644 src/modules/parent/components/child-schedule-card.tsx create mode 100644 src/modules/parent/components/parent-dashboard.tsx create mode 100644 src/modules/parent/data-access.ts create mode 100644 src/modules/parent/types.ts create mode 100644 src/modules/scheduling/actions.ts create mode 100644 src/modules/scheduling/auto-scheduler.ts create mode 100644 src/modules/scheduling/components/auto-schedule-panel.tsx create mode 100644 src/modules/scheduling/components/auto-schedule-result.tsx create mode 100644 src/modules/scheduling/components/schedule-change-form.tsx create mode 100644 src/modules/scheduling/components/schedule-change-list.tsx create mode 100644 src/modules/scheduling/components/schedule-conflicts-view.tsx create mode 100644 src/modules/scheduling/components/scheduling-rules-form.tsx create mode 100644 src/modules/scheduling/data-access.ts create mode 100644 src/modules/scheduling/schema.ts create mode 100644 src/modules/scheduling/types.ts create mode 100644 src/modules/settings/actions-password.ts create mode 100644 src/modules/settings/components/notification-preferences-form.tsx create mode 100644 src/modules/settings/components/password-change-form.tsx create mode 100644 src/modules/users/components/user-import-dialog.tsx create mode 100644 src/modules/users/import-export.ts create mode 100644 src/shared/components/global-search.tsx create mode 100644 src/shared/components/ui/switch.tsx create mode 100644 src/shared/lib/audit-logger.ts create mode 100644 src/shared/lib/change-logger.ts create mode 100644 src/shared/lib/excel.ts create mode 100644 src/shared/lib/file-storage.ts create mode 100644 src/shared/lib/login-logger.ts create mode 100644 src/shared/lib/password-policy.ts create mode 100644 src/shared/lib/rate-limit.ts create mode 100644 src/shared/lib/storage-provider.ts create mode 100644 tests/e2e/announcements.spec.ts create mode 100644 tests/e2e/auth.spec.ts create mode 100644 tests/e2e/grades.spec.ts create mode 100644 tests/e2e/navigation.spec.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ea502e..fcce8c4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,6 +7,8 @@ on: pull_request: branches: - main + schedule: + - cron: "0 2 * * *" # 每天凌晨 2 点触发定时备份 jobs: @@ -128,3 +130,43 @@ jobs: nextjs-app echo "Deploy complete!" + + security-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - name: Run npm audit + run: npm audit --audit-level=moderate + continue-on-error: true + - name: Check for critical vulnerabilities + run: npm audit --audit-level=critical + - name: Upload audit report + if: always() + run: npm audit --json > audit-report.json + - uses: actions/upload-artifact@v3 + if: always() + with: + name: security-audit-report + path: audit-report.json + + scheduled-backup: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run database backup + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + BACKUP_DIR: ./backups + run: | + chmod +x scripts/backup-db.sh + ./scripts/backup-db.sh + - uses: actions/upload-artifact@v3 + with: + name: db-backup + path: backups/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 5ef6a52..5e544ae 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,13 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database backups +/backups/ + +# security audit reports +/audit-report.json + +# playwright +/playwright-report/ +/test-results/ diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 2ccb1b6..6838324 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -66,7 +66,7 @@ - 参数说明:`permission`: Permission,来自 `shared/types/permissions` 的权限常量 - 功能:断言当前用户拥有指定权限,否则抛出 PermissionDeniedError - 依赖:`getAuthContext` -- 被以下模块使用:**所有业务模块**的 Server Actions(exams, homework, questions, textbooks, classes, school, settings) +- 被以下模块使用:**所有业务模块**的 Server Actions(exams, homework, questions, textbooks, classes, school, settings, audit, announcements, files, grades, attendance) #### `checkPermission` - 签名:`checkPermission(permission: Permission): Promise<{ allowed: boolean; ctx: AuthContext }>` @@ -87,6 +87,184 @@ - 依赖:`ROLE_PERMISSIONS` 常量 - 被以下模块使用:auth.ts (JWT callback) +#### `logAudit` +- 签名:`logAudit(params: LogAuditParams): Promise` +- 参数说明:`params`: LogAuditParams,含 userId, userName, action, module, targetId?, targetType?, detail?, status? +- 功能:记录操作日志(静默失败,不影响主流程) +- 依赖:`auth` (NextAuth), `shared/db` (auditLogs 表), `next/headers` +- 被以下模块使用:school/actions.ts,其他 Server Actions + +#### `logLoginEvent` +- 签名:`logLoginEvent(params: LogLoginEventParams): Promise` +- 参数说明:`params`: LogLoginEventParams,含 userId?, userEmail, action (signin/signout/signup), status, errorMessage? +- 功能:记录登录日志(不依赖 auth 上下文,可在 NextAuth events 中调用,静默失败) +- 依赖:`shared/db` (loginLogs 表), `next/headers` +- 被以下模块使用:auth.ts (events.signIn, events.signOut) + +#### `isAllowedMimeType` +- 签名:`isAllowedMimeType(mimeType: string): boolean` +- 功能:判断 MIME 类型是否在允许上传的白名单中 +- 依赖:`ALLOWED_MIME_TYPES` 常量 +- 被以下模块使用:`app/api/upload/route.ts`, files/components/file-upload.tsx + +#### `generateStoragePath` +- 签名:`generateStoragePath(originalName: string): string` +- 功能:根据原始文件名生成存储路径 `uploads/YYYY-MM/cuid.ext`(相对于 public/) +- 依赖:`@paralleldrive/cuid2` +- 被以下模块使用:`app/api/upload/route.ts` + +#### `getFileExtension` +- 签名:`getFileExtension(filename: string): string` +- 功能:从文件名中提取小写扩展名(不含点) +- 被以下模块使用:`shared/lib/file-storage` 内部 + +#### `formatFileSize` +- 签名:`formatFileSize(bytes: number): string` +- 功能:将字节数格式化为人类可读字符串(如 `1.5 MB`、`800 KB`) +- 被以下模块使用:files/components/file-upload.tsx, file-list.tsx, file-preview.tsx + +#### `exportToExcel` +- 签名:`exportToExcel(params: { sheets: ExcelSheet[] }): Promise` +- 参数说明:`sheets`: ExcelSheet[],每个含 `name`/`columns`/`rows` +- 功能:将多 sheet 数据导出为 Excel Buffer(表头加粗+冻结首行+自动筛选) +- 依赖:`exceljs` +- 被以下模块使用:users/import-export.exportUsersToExcel, grades/export.exportGradeRecordsToExcel, grades/export.exportClassGradeReportToExcel + +#### `parseExcel` +- 签名:`parseExcel(buffer: Buffer): Promise` +- 返回:ParsedSheet[],每个含 `sheetName`/`rows` +- 功能:从 Buffer 解析 Excel 文件,首行作为表头,返回每 sheet 的行记录数组 +- 依赖:`exceljs` +- 被以下模块使用:users/actions.importUsersAction, `app/api/import/route.ts` + +#### `generateTemplate` +- 签名:`(params: { sheets: TemplateSheet[] }): Promise` +- 参数说明:`sheets`: TemplateSheet[],每个含 `name`/`columns`(含 `note`)/`sampleRows?` +- 功能:生成导入模板 Buffer(表头加粗+第二行填写说明+示例行) +- 依赖:`exceljs` +- 被以下模块使用:users/import-export.generateUserImportTemplate + +#### `validatePassword` +- 签名:`(password: string) => { valid: boolean; errors: string[] }` +- 功能:校验密码是否符合策略(最小长度、大小写、数字等) +- 依赖:`PASSWORD_RULES` 常量 +- 被以下模块使用:settings/actions-password.changePasswordAction, auth.ts + +#### `getPasswordStrength` +- 签名:`(password: string) => "weak" | "medium" | "strong"` +- 功能:基于长度和字符多样性计算密码强度等级(纯函数,可在客户端使用) +- 依赖:无 +- 被以下模块使用:settings/components/password-change-form.tsx + +#### `isAccountLocked` +- 签名:`(failedAttempts: number, lastFailedAt: Date | null) => boolean` +- 功能:判断账户是否应被锁定(失败次数达阈值且锁定时间未过期) +- 依赖:`PASSWORD_RULES.maxLoginAttempts`, `PASSWORD_RULES.lockoutDurationMinutes` +- 被以下模块使用:auth.ts (authorize callback) + +#### `getRemainingLockoutMs` +- 签名:`(failedAttempts: number, lastFailedAt: Date | null) => number` +- 功能:计算剩余锁定时间(毫秒,0 表示已解锁) +- 依赖:`isAccountLocked` +- 被以下模块使用:待扩展 + +#### `rateLimit` +- 签名:`(params: { key: string; limit: number; windowMs: number }) => { success: boolean; remaining: number; resetTime: number; retryAfterMs: number }` +- 功能:内存滑动窗口限流检查(单实例,多实例需替换为 @upstash/ratelimit) +- 依赖:无(Map 存储) +- 被以下模块使用:auth.ts (LOGIN), settings/actions-password (PASSWORD_CHANGE), app/api/ai/chat (AI_CHAT), app/api/upload (UPLOAD), app/api/rate-limit-test + +#### `resetRateLimit` +- 签名:`(key: string) => void` +- 功能:重置指定 key 的限流计数(如登录成功后清除失败计数) +- 被以下模块使用:auth.ts (成功登录后) + +#### `rateLimitKey` +- 签名:`(prefix: string, identifier: string) => string` +- 功能:构建限流 key(如 `login:ip:email`) +- 被以下模块使用:auth.ts, settings/actions-password, app/api/ai/chat, app/api/upload + +#### `rateLimitHeaders` +- 签名:`(result: RateLimitResult) => Record` +- 功能:将限流结果转为 HTTP 响应头(X-RateLimit-Limit/Remaining/Reset, Retry-After) +- 被以下模块使用:app/api/ai/chat, app/api/upload, app/api/rate-limit-test + +#### `logDataChange` +- 签名:`logDataChange(params: LogDataChangeParams): Promise` +- 参数说明:`params`: LogDataChangeParams,含 tableName, recordId, action ("create" | "update" | "delete"), oldValue?, newValue? +- 功能:记录数据变更日志(写入 dataChangeLogs 表,自动从 NextAuth session 获取 changedBy/changedByName,从 headers 获取 ipAddress;静默失败,不阻塞主流程) +- 依赖:`auth` (NextAuth), `shared/db` (dataChangeLogs 表), `next/headers`, `@paralleldrive/cuid2` +- 被以下模块使用:待扩展(数据变更场景) + +#### `StorageProvider` (接口) +- 文件:`lib/storage-provider.ts` +- 定义:文件存储抽象接口,方法 `save(file, storagePath) => Promise`、`read(storagePath) => Promise`、`delete(storagePath) => Promise`、`exists(storagePath) => Promise`、`getUrl(storagePath) => string` +- 功能:抽象文件持久化层,便于未来切换到 OSS/S3 而不修改调用方 +- 被以下模块使用:`app/api/files/batch-delete/route.ts` + +#### `LocalStorageProvider` (类) +- 文件:`lib/storage-provider.ts` +- 实现:`StorageProvider` 接口,文件持久化到 `public/uploads/...`,URL 为 `/uploads/...` +- 依赖:`fs/promises` (mkdir/readFile/writeFile/unlink/access), `path` +- 被以下模块使用:通过 `storageProvider` 实例使用 + +#### `storageProvider` (实例) +- 文件:`lib/storage-provider.ts` +- 类型:`StorageProvider`(默认为 `LocalStorageProvider` 实例) +- 功能:默认存储 Provider 单例,替换此实例可迁移到 OSS/S3 +- 被以下模块使用:`app/api/files/batch-delete/route.ts` + +### 导出常量与实例 + +#### `Permissions` (常量对象) +- 文件:`types/permissions.ts` +- 定义:47 个权限常量(`exam:create`, `homework:grade`, `audit_log:read`, `announcement:manage`, `file:upload`, `file:read`, `file:delete`, `grade_record:manage`, `grade_record:read`, `course_plan:manage`, `course_plan:read`, `attendance:manage`, `attendance:read`, `message:send`, `message:read`, `message:delete`, `schedule:auto`, `schedule:adjust` 等) +- 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件 + +#### `ROLE_PERMISSIONS` (常量对象) +- 文件:`lib/permissions.ts` +- 定义:角色到权限列表的映射(admin/teacher/student/parent/grade_head/teaching_head) +- 课程计划权限:admin 含 `COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ` +- 考勤权限:admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`;student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ` +- 消息权限:admin/teacher/grade_head/teaching_head 含 `MESSAGE_SEND`+`MESSAGE_READ`+`MESSAGE_DELETE`;student/parent 含 `MESSAGE_SEND`+`MESSAGE_READ` +- 排课权限:admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`;teacher/student/parent/grade_head/teaching_head 无排课权限 +- 被使用:`resolvePermissions`, auth.ts (JWT callback) + +#### `db` (Drizzle 实例) +- 文件:`db/index.ts` +- 定义:Drizzle ORM 客户端实例(MySQL) +- 被使用:**所有业务模块**的 data-access.ts 和 actions.ts + +#### `questionTypeEnum` +- 文件:`db/schema.ts` (或 schema 枚举导出) +- 定义:题目类型枚举(选择/填空/判断/复合等) +- 被使用:questions, exams, homework + +#### `classEnrollmentStatusEnum` +- 文件:`db/schema.ts` (或 schema 枚举导出) +- 定义:班级注册状态枚举(active/inactive 等) +- 被使用:classes, homework + +#### `PASSWORD_RULES` (常量对象) +- 文件:`lib/password-policy.ts` +- 定义:密码策略配置(minLength: 8, requireUppercase/Lowercase/Number: true, maxLoginAttempts: 5, lockoutDurationMinutes: 30) +- 被使用:auth.ts, settings/actions-password.ts, password-change-form.tsx (via PASSWORD_REQUIREMENT_HINTS) + +#### `RATE_LIMIT_RULES` (常量对象) +- 文件:`lib/rate-limit.ts` +- 定义:预定义限流规则(LOGIN: 5/15min, API: 100/min, UPLOAD: 10/min, AI_CHAT: 20/min, PASSWORD_CHANGE: 5/min) +- 被使用:auth.ts (LOGIN), settings/actions-password (PASSWORD_CHANGE), app/api/ai/chat (AI_CHAT), app/api/upload (UPLOAD) + +### 文件记录 + +#### `db/relations.ts` +- 内容:24+ Drizzle relations(表间关系定义,含 coursePlansRelations、coursePlanItemsRelations、attendanceRecordsRelations、attendanceRulesRelations) +- 被使用:Drizzle ORM 关联查询(所有 data-access.ts) + +#### `next-auth.d.ts` +- 内容:NextAuth 类型扩展(Session.user 增加 id/roles/permissions 等字段) +- 被使用:auth.ts, 所有使用 `useSession`/`auth()` 的代码 + ### 导出组件 #### `AuthSessionProvider` @@ -105,9 +283,24 @@ - 被使用:`app/layout.tsx` #### `EmptyState` +- 文件:`components/ui/empty-state.tsx` - Props: `{ icon?, title, description, action? }` - 被使用:exams, homework, questions, textbooks 等模块的列表空状态 +#### `GlobalSearch` +- 文件:`components/global-search.tsx` +- Props: `{ className?, placeholder? }` +- 功能:全局搜索组件(防抖 300ms 调用 `GET /api/search`,Cmd/Ctrl+K 快捷键聚焦,Escape 关闭,↑/↓ 键盘导航,Enter 跳转;下拉展示 question/textbook/exam/announcement 四类结果,点击外部自动关闭) +- 内部使用:`useDebounce`, `Input`, `Link`, `useRouter` +- 被使用:`layout/components/site-header.tsx` + +#### `Switch` +- 文件:`components/ui/switch.tsx` +- 基于:`@radix-ui/react-switch` +- Props: Radix Switch Root props(含 `checked`, `onCheckedChange`, `disabled`, `id`, `aria-label` 等) +- 功能:开关切换 UI 组件(shadcn 风格,checked/unchecked 两态) +- 被使用:`settings/components/notification-preferences-form.tsx` + ### 导出 Hooks #### `useActionWithToast` @@ -138,10 +331,6 @@ - 定义:`{ success: boolean; message?: string; errors?: Record; data?: T }` - 被使用:**所有模块**的 Server Action 返回类型 -#### `Permissions` (常量对象) -- 定义:22 个权限常量(`exam:create`, `homework:grade` 等) -- 被使用:auth-guard.ts, 所有模块的 actions.ts, 前端组件 - #### `Permission` (类型) - 定义:`Permissions` 值的联合类型 - 被使用:auth-guard.ts, use-permission.ts @@ -161,51 +350,70 @@ | 表名 | 核心字段 | 被哪些模块使用 | |------|---------|--------------| -| `users` | id, name, email, password, gradeId, departmentId, onboardedAt | auth, users, dashboard, classes | -| `accounts` | userId, provider, access_token | auth | -| `sessions` | userId, sessionToken | auth | -| `roles` | id, name | auth, auth-guard | +| `users` | id, name, email, emailVerified, image, password, phone, address, gender, age, birthDate, guardianName, guardianPhone, guardianRelation, consentAcceptedAt, gradeId, departmentId, onboardedAt, createdAt, updatedAt | auth, users, dashboard, classes | +| `accounts` | userId, type, provider, providerAccountId, refresh_token, access_token, expires_at, token_type, scope, id_token, session_state | auth | +| `sessions` | sessionToken, userId, expires | auth | +| `verificationTokens` | identifier, token, expires | auth | +| `roles` | id, name, description, createdAt, updatedAt | auth, auth-guard | | `usersToRoles` | userId, roleId | auth, auth-guard | | `rolePermissions` | roleId, permission | auth (seed) | -| `knowledgePoints` | id, name, chapterId, parentId | textbooks, questions | -| `questions` | id, content, type, authorId, parentId | questions, exams, homework | +| `knowledgePoints` | id, name, description, anchorText, parentId, chapterId, level, order, createdAt, updatedAt | textbooks, questions | +| `questions` | id, content, type, difficulty, authorId, parentId, createdAt, updatedAt | questions, exams, homework | | `questionsToKnowledgePoints` | questionId, knowledgePointId | questions | -| `subjects` | id, name, order | exams, textbooks | -| `textbooks` | id, title, subject, grade | textbooks | -| `chapters` | id, textbookId, title, parentId, content | textbooks | -| `departments` | id, name, description | school | -| `classrooms` | id, location, capacity | school | -| `academicYears` | id, name, startDate, endDate | school | -| `schools` | id, name, code | school, classes | -| `grades` | id, schoolId, name, gradeHeadId, teachingHeadId | school, classes, exams, auth-guard | -| `classes` | id, schoolId, gradeId, teacherId, name, invitationCode | classes, homework, auth-guard | -| `classSubjectTeachers` | classId, teacherId, subject | classes, auth-guard | -| `classEnrollments` | classId, studentId, status | classes, homework | -| `classSchedule` | id, classId, weekday, startTime, endTime, course | classes | -| `exams` | id, creatorId, title, subjectId, gradeId, status, structure | exams, homework | -| `examQuestions` | examId, questionId | exams | -| `examSubmissions` | examId, studentId, score | exams | -| `submissionAnswers` | submissionId, questionId, answer, score | exams | -| `homeworkAssignments` | id, creatorId, sourceExamId, title, status, dueAt | homework | -| `homeworkAssignmentQuestions` | assignmentId, questionId | homework | -| `homeworkAssignmentTargets` | assignmentId, studentId | homework | -| `homeworkSubmissions` | assignmentId, studentId, status, score | homework | -| `homeworkAnswers` | submissionId, questionId, answer, score, feedback | homework | -| `aiProviders` | id, provider, baseUrl, model, apiKeyEncrypted | settings, ai | +| `subjects` | id, name, order, code, createdAt, updatedAt | exams, textbooks | +| `textbooks` | id, title, subject, grade, publisher, createdAt, updatedAt | textbooks | +| `chapters` | id, textbookId, title, order, parentId, content, createdAt, updatedAt | textbooks | +| `departments` | id, name, description, createdAt, updatedAt | school | +| `classrooms` | id, name, building, floor, capacity, createdAt, updatedAt | school | +| `academicYears` | id, name, startDate, endDate, isActive, createdAt, updatedAt | school | +| `schools` | id, name, code, createdAt, updatedAt | school, classes | +| `grades` | id, schoolId, name, order, gradeHeadId, teachingHeadId, createdAt, updatedAt | school, classes, exams, auth-guard | +| `classes` | id, schoolId, gradeId, teacherId, name, homeroom, room, invitationCode, schoolName, grade, createdAt, updatedAt | classes, homework, auth-guard | +| `classSubjectTeachers` | classId, teacherId, subjectId, createdAt, updatedAt | classes, auth-guard | +| `classEnrollments` | classId, studentId, status, createdAt | classes, homework | +| `classSchedule` | id, classId, weekday, startTime, endTime, course, location, createdAt, updatedAt | classes | +| `exams` | id, creatorId, title, description, subjectId, gradeId, status, structure, startTime, endTime, createdAt, updatedAt | exams, homework | +| `examQuestions` | examId, questionId, score, order | exams | +| `examSubmissions` | id, examId, studentId, score, status, submittedAt, createdAt, updatedAt | exams | +| `submissionAnswers` | id, submissionId, questionId, answerContent, score, feedback, createdAt, updatedAt | exams | +| `homeworkAssignments` | id, creatorId, sourceExamId, title, description, status, structure, availableAt, dueAt, allowLate, lateDueAt, maxAttempts, createdAt, updatedAt | homework | +| `homeworkAssignmentQuestions` | assignmentId, questionId, score, order | homework | +| `homeworkAssignmentTargets` | assignmentId, studentId, createdAt | homework | +| `homeworkSubmissions` | id, assignmentId, studentId, status, attemptNo, score, submittedAt, startedAt, isLate, createdAt, updatedAt | homework | +| `homeworkAnswers` | id, submissionId, questionId, answerContent, score, feedback, createdAt, updatedAt | homework | +| `aiProviders` | id, provider, baseUrl, model, apiKeyEncrypted, apiKeyLast4, isDefault, createdBy, updatedBy, createdAt, updatedAt | settings, ai | +| `announcements` | id, title, content, type, status, targetGradeId, targetClassId, authorId, publishedAt, createdAt, updatedAt | announcements | +| `auditLogs` | id, userId, userName, action, module, targetId, targetType, detail, ipAddress, userAgent, status, createdAt | audit, shared/lib/audit-logger | +| `loginLogs` | id, userId, userEmail, action, status, ipAddress, userAgent, errorMessage, createdAt | audit, shared/lib/login-logger, auth | +| `dataChangeLogs` | id, tableName, recordId, action (create/update/delete), oldValue, newValue, changedBy, changedByName, ipAddress, createdAt | audit, shared/lib/change-logger | +| `fileAttachments` | id, filename, originalName, mimeType, size, storagePath, url, uploaderId, targetType, targetId, createdAt | files | +| `gradeRecords` | id, studentId, classId, subjectId, examId, academicYearId, title, score, fullScore, type, semester, recordedBy, remark, createdAt, updatedAt | grades | +| `coursePlans` | id, classId, subjectId, teacherId, academicYearId, semester, totalHours, completedHours, weeklyHours, startDate, endDate, syllabus, objectives, status, createdBy, createdAt, updatedAt | course-plans | +| `coursePlanItems` | id, planId, week, topic, content, hours, textbookChapter, notes, isCompleted, completedAt, createdAt, updatedAt | course-plans | +| `parentStudentRelations` | id, parentId, studentId, relation, createdAt | parent, auth-guard | +| `messages` | id, senderId, receiverId, subject, content, isRead, readAt, parentMessageId, createdAt | messaging | +| `messageNotifications` | id, userId, type, title, content, link, isRead, createdAt | messaging | +| `notificationPreferences` | id, userId (unique FK→users), emailEnabled, smsEnabled, pushEnabled, homeworkNotifications, gradeNotifications, announcementNotifications, messageNotifications, attendanceNotifications, createdAt, updatedAt | messaging, settings | +| `attendanceRecords` | id, studentId, classId, scheduleId, date, status, remark, recordedBy, createdAt, updatedAt | attendance | +| `attendanceRules` | id, classId, lateThresholdMinutes, earlyLeaveThresholdMinutes, enableAutoMark, createdAt, updatedAt | attendance | +| `schedulingRules` | id, classId, maxDailyHours, maxContinuousHours, lunchBreakStart, lunchBreakEnd, morningStart, afternoonEnd, avoidBackToBack, balancedSubjects, createdAt, updatedAt | scheduling | +| `scheduleChanges` | id, originalScheduleId, classId, originalTeacherId, substituteTeacherId, originalDate, newDate, newStartTime, newEndTime, reason, status, requestedBy, approvedBy, createdAt, updatedAt | scheduling | +| `passwordSecurity` | id, userId, failedLoginAttempts, lockedUntil, passwordChangedAt, mustChangePassword, lastPasswordChange, createdAt, updatedAt | auth, settings | --- ## 模块:auth ### 模块职责 -处理用户认证(登录/注册/JWT/Session),提供 NextAuth 实例和中间件。 +处理用户认证(登录/注册/JWT/Session),提供 NextAuth 实例和中间件。通过 events 回调记录登录日志。 +集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制。 ### 导出函数 #### `auth` - 签名:`auth(): Promise` (NextAuth 导出) - 功能:获取当前用户 Session -- 被使用:auth-guard.ts, 所有 Server Component 页面 +- 被使用:auth-guard.ts, 所有 Server Component 页面, audit-logger.ts #### `handlers` - 签名:`{ GET, POST }` (NextAuth Route Handler) @@ -214,6 +422,29 @@ #### `signIn` / `signOut` - 被使用:login-form.tsx, site-header.tsx +### authorize 回调(Credentials Provider) + +> 登录流程集成密码安全策略和速率限制: + +1. **速率限制**:按 `IP:email` 维度限流(RATE_LIMIT_RULES.LOGIN: 5次/15分钟),超限返回 null +2. **账户锁定检查**:通过 `isAccountLocked(failedLoginAttempts, lastFailedAt)` 判断,锁定则返回 null +3. **密码验证失败**:调用 `recordFailedLogin` 递增失败次数,达阈值自动锁定 +4. **登录成功**:调用 `resetFailedLogin` 清零失败次数,`resetRateLimit` 清除限流计数 + +### Events 回调 + +#### `events.signIn` +- 签名:`async signIn({ user }) => void` +- 功能:用户登录成功后记录登录日志 +- 依赖:`shared/lib/login-logger.logLoginEvent` +- 调用参数:`{ userId: user.id, userEmail: user.email, action: "signin", status: "success" }` + +#### `events.signOut` +- 签名:`async signOut(message) => void` +- 功能:用户登出后记录登录日志(处理 NextAuth v5 不同 message 形状) +- 依赖:`shared/lib/login-logger.logLoginEvent` +- 调用参数:`{ userId?, userEmail, action: "signout", status: "success" }` + #### `middleware` (proxy.ts) - 签名:`middleware(request: NextRequest) => Promise` - 功能:基于权限点的路由守卫,未登录重定向 /login,无权限重定向角色首页 @@ -272,6 +503,12 @@ - 依赖:`requirePermission(EXAM_DUPLICATE)`, `shared/db` - 被使用:exam-actions.tsx +#### `getExamPreviewAction` +- 签名:`(examId: string) => Promise>` +- 功能:获取考试预览数据 +- 依赖:`requirePermission(EXAM_READ)`, `shared/db` +- 被使用:exam-viewer.tsx + #### `getSubjectsAction` - 签名:`() => Promise>` - 依赖:`requirePermission(EXAM_READ)`, `shared/db` @@ -298,6 +535,23 @@ #### `persistExamDraft` / `persistAiGeneratedExamDraft` - 被使用:createExamAction, createAiExamAction +#### `omitScheduledAtFromDescription` +- 功能:从考试描述中移除 scheduledAt 字段(用于编辑场景) +- 被使用:exams/data-access.ts 内部 + +#### `resolveSubjectGradeNames` +- 功能:将 subjectId/gradeId 解析为可读名称 +- 被使用:exams/data-access.ts 内部, buildExamDescription + +#### `buildExamDescription` +- 功能:构建考试描述文本(含科目、年级、时间等) +- 依赖:`resolveSubjectGradeNames`, `omitScheduledAtFromDescription` +- 被使用:createExamAction, createAiExamAction + +#### `GetExamsParams` (类型) +- 定义:查询参数类型(含 subjectId?, gradeId?, status?, keyword? 等过滤条件) +- 被使用:`getExams`, `getQuestionsAction` + ### 导出函数 (ai-pipeline.ts) #### `generateAiPreviewData` @@ -311,6 +565,42 @@ #### `regenerateAiQuestionByInstruction` - 被使用:regenerateAiQuestionAction +#### `generateAiExamDraft` +- 功能:生成 AI 考试草稿(含结构与题目) +- 依赖:`shared/lib/ai.createAiChatCompletion`, `AiQuestionSchema`, `AiGeneratedStructureSchema` +- 被使用:createAiExamAction + +### AI Schema 与类型 (ai-pipeline.ts) + +#### `AiQuestionSchema` +- 类型:Zod schema +- 定义:AI 生成题目的校验 schema(type, content, difficulty, score, options? 等) +- 被使用:`generateAiExamDraft`, `AiGeneratedQuestion` + +#### `AiInsertQuestionSchema` +- 类型:Zod schema +- 定义:插入题目到 DB 的校验 schema(含 authorId, parentId 等 DB 字段) +- 被使用:`persistAiGeneratedExamDraft` + +#### `AiGeneratedQuestion` +- 类型:TypeScript 类型(基于 `AiQuestionSchema` 推断) +- 被使用:`AiPreviewData`, exams/components + +#### `AiGeneratedStructureNode` +- 类型:TypeScript 类型 +- 定义:AI 生成的试卷结构节点(section 标题、题目列表) +- 被使用:`AiGeneratedStructureSchema` + +#### `AiGeneratedStructureNodeSchema` +- 类型:Zod schema +- 定义:`AiGeneratedStructureNode` 的校验 schema +- 被使用:`AiGeneratedStructureSchema` + +#### `AiGeneratedStructureSchema` +- 类型:Zod schema +- 定义:AI 生成的完整试卷结构校验 schema(含 `AiGeneratedStructureNode[]`) +- 被使用:`generateAiExamDraft` + ### 类型/接口 #### `Exam` @@ -319,6 +609,57 @@ #### `AiPreviewData` / `AiRewriteQuestionData` - 被使用:exams/actions.ts, exams/components +#### `ExamStatus` +- 定义:考试状态枚举类型(draft/published 等) +- 被使用:exams/data-access.ts, exams/components + +#### `ExamDifficulty` +- 定义:考试难度枚举类型 +- 被使用:exams/components, ai-pipeline.ts + +#### `SubmissionStatus` +- 定义:提交状态枚举类型(in_progress/submitted/graded 等) +- 被使用:examSubmissions 相关逻辑 + +#### `ExamSubmission` +- 定义:考试提交记录类型(含 studentId, score, status 等) +- 被使用:exams/data-access.ts, exams/components + +### 导出 Hooks + +#### `useExamPreview` +- 签名:`useExamPreview(): { isPending, execute, previewData, error }` +- 功能:包装 `previewAiExamAction`,管理 AI 预览状态 +- 被使用:exam-ai-generator.tsx + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `exam-form.tsx` | 考试创建/编辑表单(手动 + AI 模式) | +| `exam-ai-generator.tsx` | AI 生成考试配置面板 | +| `exam-viewer.tsx` | 考试预览展示 | +| `exam-actions.tsx` | 考试操作菜单(复制/删除) | +| `exam-data-table.tsx` | 考试列表数据表格 | +| `exam-preview-question-editor.tsx` | AI 预览题目编辑器 | +| `exam-status-badge.tsx` | 考试状态徽章 | +| `exam-card.tsx` | 考试卡片 | +| `exam-list.tsx` | 考试列表 | +| `exam-detail.tsx` | 考试详情 | +| `exam-header.tsx` | 考试头部信息 | +| `exam-info.tsx` | 考试基本信息展示 | +| `exam-question-list.tsx` | 考试题目列表 | +| `exam-question-item.tsx` | 考试题目项 | +| `exam-question-picker.tsx` | 题目选择器 | +| `exam-score-summary.tsx` | 分值汇总 | +| `exam-schedule-picker.tsx` | 考试时间选择器 | +| `exam-subject-grade-select.tsx` | 科目年级选择器 | +| `exam-empty.tsx` | 考试空状态 | +| `assembly/exam-assembly-panel.tsx` | 组卷面板 | +| `assembly/question-pool.tsx` | 题库选择池 | +| `assembly/assembly-cart.tsx` | 已选题目购物车 | +| `assembly/assembly-summary.tsx` | 组卷汇总 | + --- ## 模块:homework @@ -377,6 +718,42 @@ - 签名:`(assignmentId: string) => Promise` - 被使用:homework 错误分析组件 +#### `getHomeworkAssignmentById` +- 签名:`(assignmentId: string, scope?: DataScope) => Promise` +- 功能:按 ID 获取作业详情(含数据权限过滤) +- 被使用:homework 详情/编辑页面, homework-take-view.tsx + +#### `getHomeworkSubmissionDetails` +- 签名:`(submissionId: string, scope?: DataScope) => Promise` +- 功能:获取作业提交详情(含答案列表、学生信息) +- 被使用:homework-grading-view.tsx + +#### `getDemoStudentUser` +- 签名:`() => Promise<{ id: string; name: string } | null>` +- 功能:获取演示学生用户(用于 demo/预览场景) +- 被使用:homework demo 相关页面 + +#### `getStudentHomeworkTakeData` +- 签名:`(studentId: string, assignmentId: string) => Promise` +- 功能:获取学生作答作业所需完整数据(作业、题目、已答内容、提交状态) +- 被使用:homework-take-view.tsx + +### Schema (schema.ts) + +#### `CreateHomeworkAssignmentSchema` +- 类型:Zod schema +- 定义:创建作业的校验 schema(sourceExamId, title, classIds, dueAt, allowLate 等) +- 被使用:`createHomeworkAssignmentAction` + +#### `CreateHomeworkAssignmentInput` +- 类型:TypeScript 类型(基于 `CreateHomeworkAssignmentSchema` 推断) +- 被使用:`createHomeworkAssignmentAction`, homework-assignment-form.tsx + +#### `GradeHomeworkSchema` +- 类型:Zod schema +- 定义:批改作业的校验 schema(submissionId, answers[{ questionId, score, feedback }]) +- 被使用:`gradeHomeworkSubmissionAction` + ### 类型/接口 #### `StudentDashboardGradeProps` @@ -385,6 +762,77 @@ #### `HomeworkAssignmentListItem` - 被使用:homework 列表页, homework-assignment-form.tsx +#### `HomeworkAssignment` +- 定义:作业完整类型(含 sourceExam, targets, questions 等关联) +- 被使用:homework 详情/编辑页面 + +#### `HomeworkAssignmentReviewListItem` +- 被使用:teacher 批改列表 + +#### `HomeworkSubmissionListItem` +- 被使用:teacher 提交列表 + +#### `HomeworkSubmission` +- 定义:作业提交记录类型 +- 被使用:homework-grading-view.tsx + +#### `HomeworkSubmissionDetails` +- 定义:提交详情类型(含学生、答案列表) +- 被使用:homework-grading-view.tsx + +#### `HomeworkAnswer` +- 定义:作业答案类型 +- 被使用:homework-take-view.tsx, homework-grading-view.tsx + +#### `HomeworkAssignmentAnalytics` +- 定义:作业分析数据类型(含错误率、平均分等) +- 被使用:homework 错误分析组件 + +#### `StudentHomeworkAssignmentListItem` +- 被使用:student/dashboard + +#### `StudentHomeworkTakeData` +- 定义:学生作答数据类型(含 assignment, questions, currentAnswers, submission) +- 被使用:homework-take-view.tsx + +#### `TeacherGradeTrendItem` +- 被使用:dashboard (教师仪表盘) + +#### `HomeworkAssignmentStatus` +- 定义:作业状态枚举类型(draft/published/closed 等) +- 被使用:homework/data-access.ts, homework/components + +#### `HomeworkSubmissionStatus` +- 定义:提交状态枚举类型(in_progress/submitted/graded 等) +- 被使用:homework/data-access.ts, homework/components + +#### `HomeworkAssignmentTarget` +- 定义:作业目标学生类型 +- 被使用:homework/data-access.ts + +#### `HomeworkQuestion` +- 定义:作业题目类型(含 score, order) +- 被使用:homework-take-view.tsx + +#### `HomeworkGradingInput` +- 定义:批改输入类型 +- 被使用:homework-grading-view.tsx + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `homework-assignment-form.tsx` | 作业创建表单(源自考试) | +| `homework-take-view.tsx` | 学生作答视图 | +| `homework-grading-view.tsx` | 教师批改视图 | +| `homework-assignment-list.tsx` | 作业列表 | +| `homework-assignment-card.tsx` | 作业卡片 | +| `homework-assignment-detail.tsx` | 作业详情 | +| `homework-submission-list.tsx` | 提交列表 | +| `homework-submission-card.tsx` | 提交卡片 | +| `homework-analytics.tsx` | 作业分析(错误率/平均分) | +| `homework-status-badge.tsx` | 作业状态徽章 | + --- ## 模块:questions @@ -419,6 +867,39 @@ - 依赖:`requirePermission(QUESTION_READ)`, `shared/db` - 被使用:create-question-dialog.tsx +### 导出函数 (data-access.ts) + +#### `getQuestions` +- 签名:`(params: GetQuestionsParams & { scope?: DataScope }) => Promise` +- 功能:查询题目列表(含知识点关联、数据权限过滤) +- 依赖:`shared/db`, `DataScope` +- 被使用:`getQuestionsAction`, teacher/questions/page.tsx + +#### `GetQuestionsParams` (类型) +- 定义:查询参数类型(含 keyword?, type?, difficulty?, knowledgePointId?, parentId? 等过滤条件) +- 被使用:`getQuestions`, `getQuestionsAction` + +### Schema (schema.ts) + +#### `QuestionTypeEnum` +- 类型:Zod enum +- 定义:题目类型枚举(choice/fill/judge/composite 等) +- 被使用:`BaseQuestionSchema`, `CreateQuestionSchema` + +#### `BaseQuestionSchema` +- 类型:Zod schema +- 定义:题目基础校验 schema(type, content, difficulty, knowledgePointIds? 等) +- 被使用:`CreateQuestionSchema` + +#### `CreateQuestionInput` +- 类型:TypeScript 类型(基于 `CreateQuestionSchema` 推断) +- 被使用:`createNestedQuestion`, create-question-dialog.tsx + +#### `CreateQuestionSchema` +- 类型:Zod schema +- 定义:创建题目的完整校验 schema(含 `BaseQuestionSchema` + 选项/答案/子题目) +- 被使用:`createNestedQuestion` + ### 类型/接口 #### `Question` @@ -427,6 +908,21 @@ #### `KnowledgePointOption` - 被使用:create-question-dialog.tsx +#### `QuestionType` +- 定义:题目类型联合类型(基于 `QuestionTypeEnum`) +- 被使用:questions/components, exams/components, homework/components + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `create-question-dialog.tsx` | 创建题目对话框(含嵌套题目) | +| `question-actions.tsx` | 题目操作菜单(编辑/删除) | +| `question-data-table.tsx` | 题目列表数据表格 | +| `question-card.tsx` | 题目卡片 | +| `question-detail.tsx` | 题目详情展示 | +| `question-type-badge.tsx` | 题目类型徽章 | + --- ## 模块:textbooks @@ -459,14 +955,31 @@ | `getKnowledgePointsByChapterId` | `(chapterId: string) => Promise` | textbook-reader.tsx | | `getKnowledgePointsByTextbookId` | `(textbookId: string) => Promise` | textbook-reader.tsx | +#### 写操作函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `persistTextbook` | `(input: { textbookId, title, subject, grade, publisher, creatorId }) => Promise` | createTextbookAction | +| `updateTextbookMeta` | `(textbookId, input: { title?, subject?, grade?, publisher? }) => Promise` | updateTextbookAction | +| `deleteTextbookRecord` | `(textbookId) => Promise` | deleteTextbookAction | +| `persistChapter` | `(input: { chapterId, textbookId, parentId?, title, order }) => Promise` | createChapterAction | +| `updateChapterContent` | `(chapterId, content, textbookId) => Promise` | updateChapterContentAction | +| `deleteChapterRecord` | `(chapterId, textbookId) => Promise` | deleteChapterAction | +| `reorderChapters` | `(chapterId, newIndex, parentId, textbookId) => Promise` | reorderChaptersAction | +| `persistKnowledgePoint` | `(input: { kpId, chapterId, textbookId, name, description?, anchorText? }) => Promise` | createKnowledgePointAction | +| `updateKnowledgePointRecord` | `(kpId, textbookId, input: { name?, description?, anchorText? }) => Promise` | updateKnowledgePointAction | +| `deleteKnowledgePointRecord` | `(kpId, textbookId) => Promise` | deleteKnowledgePointAction | + ### 导出 Hooks #### `useTextSelection` -- 签名:`useTextSelection(contentRef, onCreateKnowledgePoint) => { selectedText, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }` +- 签名:`useTextSelection() => { selectedText, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }` +- 功能:管理教材内容文本选择与知识点创建对话框状态(无参数,内部使用 ref 与状态) - 被使用:textbook-content-panel.tsx #### `useKnowledgePointActions` -- 签名:`useKnowledgePointActions(textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId) => { editingKp, editKpDialogOpen, ..., requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }` +- 签名:`useKnowledgePointActions(textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId, onCreateKP, onEditKP) => { editingKp, editKpDialogOpen, ..., requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }` +- 功能:知识点 CRUD 操作集合(6 参数:textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId, onCreateKP, onEditKP) - 被使用:textbook-reader.tsx ### 类型/接口 @@ -477,6 +990,48 @@ #### `KnowledgePoint` - 被使用:textbooks/components, questions/types (KnowledgePointOption) +#### `Textbook` +- 定义:教材类型(含 id, title, subject, grade, publisher 等) +- 被使用:textbooks/components, teacher/textbooks/page.tsx + +#### `TextbookListItem` +- 定义:教材列表项类型 +- 被使用:teacher/textbooks/page.tsx + +#### `ChapterTreeNode` +- 定义:章节树节点类型(含 children[]) +- 被使用:textbook-reader.tsx, chapter-tree.tsx + +#### `KnowledgePointInput` +- 定义:知识点创建/更新输入类型 +- 被使用:useKnowledgePointActions, createKnowledgePointAction + +#### `ChapterInput` +- 定义:章节创建输入类型 +- 被使用:createChapterAction + +#### `ReorderChaptersInput` +- 定义:章节排序输入类型 +- 被使用:reorderChaptersAction + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `textbook-reader.tsx` | 教材阅读器(章节树 + 内容面板 + 知识点) | +| `textbook-list.tsx` | 教材列表 | +| `textbook-card.tsx` | 教材卡片 | +| `textbook-form.tsx` | 教材创建/编辑表单 | +| `textbook-content-panel.tsx` | 教材内容面板(Markdown 渲染 + 文本选择) | +| `chapter-tree.tsx` | 章节树(可拖拽排序) | +| `chapter-node.tsx` | 章节树节点 | +| `chapter-form.tsx` | 章节创建/编辑表单 | +| `chapter-content-editor.tsx` | 章节 Markdown 内容编辑器 | +| `knowledge-point-list.tsx` | 知识点列表 | +| `knowledge-point-item.tsx` | 知识点项 | +| `knowledge-point-form.tsx` | 知识点创建/编辑表单 | +| `knowledge-graph.tsx` | 知识图谱可视化 | + --- ## 模块:classes @@ -508,17 +1063,158 @@ ### 导出函数 (data-access.ts) -| 函数 | 被使用 | -|------|--------| -| `getTeacherClasses(teacherId?)` | teacher/classes/my/page.tsx, dashboard | -| `getAdminClasses()` | admin 班级管理 | -| `getGradeManagedClasses(userId)` | grade_head 班级管理 | -| `getStudentClasses(studentId)` | student/dashboard | -| `getStudentSchedule(studentId)` | student 课表 | -| `getClassStudents(classId, scope?)` | teacher/classes/students/page.tsx | -| `getClassSchedule(classId)` | teacher/classes/schedule/page.tsx | -| `getClassHomeworkInsights(classId)` | classes 作业洞察 | -| `getGradeHomeworkInsights(gradeId)` | 年级作业洞察 | +#### 读操作函数 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getTeacherClasses` | `(params?: { teacherId? }) => Promise` | teacher/classes/my, dashboard | +| `getAdminClasses` | `() => Promise` | admin 班级管理 | +| `getGradeManagedClasses` | `(userId) => Promise` | grade_head 班级管理 | +| `getStudentClasses` | `(studentId) => Promise` | student/dashboard | +| `getStudentSchedule` | `(studentId) => Promise` | student 课表 | +| `getClassStudents` | `(classId, scope?) => Promise` | teacher/classes/students | +| `getClassSchedule` | `(classId) => Promise` | teacher/classes/schedule | +| `getClassHomeworkInsights` | `(classId) => Promise` | classes 作业洞察 | +| `getGradeHomeworkInsights` | `(gradeId) => Promise` | 年级作业洞察 | +| `getClassById` | `(classId, scope?) => Promise` | 班级详情页 | +| `getClassDetails` | `(classId, scope?) => Promise` | 班级详情(含学生数、教师数) | +| `getClassSubjectTeachers` | `(classId) => Promise` | 班级学科教师分配 | +| `getStudentEnrollmentStatus` | `(classId, studentId) => Promise` | 注册状态查询 | +| `validateInvitationCode` | `(code) => Promise<{ valid: boolean; classId?: string }>` | 邀请码校验 | +| `getClassInsights` | `(classId) => Promise` | 班级洞察数据 | + +#### 写操作函数 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `persistTeacherClass` | `(input: { classId, teacherId, name, gradeId, ... }) => Promise` | createTeacherClassAction | +| `updateTeacherClassRecord` | `(classId, input) => Promise` | updateTeacherClassAction | +| `deleteTeacherClassRecord` | `(classId) => Promise` | deleteTeacherClassAction | +| `persistGradeClass` | `(input: { classId, gradeId, ... }) => Promise` | createGradeClassAction | +| `updateGradeClassRecord` | `(classId, input) => Promise` | updateGradeClassAction | +| `deleteGradeClassRecord` | `(classId) => Promise` | deleteGradeClassAction | +| `enrollStudentByEmail` | `(classId, email) => Promise<{ studentId }>` | enrollStudentByEmailAction | +| `joinClassByInvitationCode` | `(code, studentId) => Promise<{ classId }>` | joinClassByInvitationCodeAction | +| `ensureInvitationCode` | `(classId) => Promise<{ code }>` | ensureClassInvitationCodeAction | +| `regenerateInvitationCode` | `(classId) => Promise<{ code }>` | regenerateClassInvitationCodeAction | +| `setStudentEnrollmentStatus` | `(classId, studentId, status) => Promise` | setStudentEnrollmentStatusAction | +| `persistClassScheduleItem` | `(input: { scheduleId, classId, ... }) => Promise` | createClassScheduleItemAction | +| `updateClassScheduleItem` | `(scheduleId, input) => Promise` | updateClassScheduleItemAction | +| `deleteClassScheduleItem` | `(scheduleId) => Promise` | deleteClassScheduleItemAction | +| `persistAdminClass` | `(input: { classId, ... }) => Promise` | createAdminClassAction | +| `updateAdminClassRecord` | `(classId, input) => Promise` | updateAdminClassAction | +| `deleteAdminClassRecord` | `(classId) => Promise` | deleteAdminClassAction | +| `assignSubjectTeacher` | `(classId, teacherId, subjectId) => Promise` | 学科教师分配 | +| `removeClassSubjectTeacher` | `(classId, teacherId, subjectId) => Promise` | 移除学科教师 | +| `bulkEnrollStudents` | `(classId, studentIds[]) => Promise` | 批量注册学生 | +| `transferStudent` | `(fromClassId, toClassId, studentId) => Promise` | 学生转班 | +| `getClassInvitationInfo` | `(classId) => Promise<{ code, expiresAt? }>` | 邀请信息查询 | +| `countClassStudents` | `(classId) => Promise` | 学生计数 | + +### 类型/接口 + +#### `TeacherClass` +- 定义:教师班级类型(含 grade, studentCount 等) +- 被使用:teacher/classes/my, dashboard + +#### `AdminClassListItem` +- 定义:管理员班级列表项类型 +- 被使用:admin 班级管理 + +#### `StudentEnrolledClass` +- 定义:学生已加入班级类型 +- 被使用:student/dashboard + +#### `StudentScheduleItem` +- 定义:学生课表项类型 +- 被使用:student 课表 + +#### `ClassStudent` +- 定义:班级学生类型(含 enrollmentStatus) +- 被使用:teacher/classes/students + +#### `ClassScheduleItem` +- 定义:班级课表项类型 +- 被使用:teacher/classes/schedule + +#### `ClassHomeworkInsights` +- 定义:班级作业洞察类型 +- 被使用:classes 作业洞察 + +#### `GradeHomeworkInsights` +- 定义:年级作业洞察类型 +- 被使用:年级作业洞察 + +#### `Class` +- 定义:班级基础类型 +- 被使用:classes/components + +#### `ClassDetails` +- 定义:班级详情类型(含学生数、教师数、课表等) +- 被使用:班级详情页 + +#### `ClassSubjectTeacher` +- 定义:班级学科教师类型 +- 被使用:班级学科教师分配 + +#### `EnrollmentStatus` +- 定义:注册状态枚举类型(active/inactive/pending) +- 被使用:classes/data-access.ts + +#### `ClassInsights` +- 定义:班级洞察数据类型 +- 被使用:班级洞察页面 + +#### `CreateClassInput` +- 定义:创建班级输入类型 +- 被使用:createTeacherClassAction, createAdminClassAction + +#### `UpdateClassInput` +- 定义:更新班级输入类型 +- 被使用:updateTeacherClassAction, updateAdminClassAction + +#### `CreateScheduleItemInput` +- 定义:创建课表项输入类型 +- 被使用:createClassScheduleItemAction + +#### `EnrollmentInput` +- 定义:注册输入类型 +- 被使用:enrollStudentByEmailAction + +#### `InvitationCodeResult` +- 定义:邀请码结果类型 +- 被使用:ensureClassInvitationCodeAction + +#### `ClassListItem` +- 定义:班级列表项类型 +- 被使用:班级列表页 + +#### `ClassFormValues` +- 定义:班级表单值类型 +- 被使用:class-form.tsx + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `class-list.tsx` | 班级列表 | +| `class-card.tsx` | 班级卡片 | +| `class-form.tsx` | 班级创建/编辑表单 | +| `class-detail.tsx` | 班级详情 | +| `class-actions.tsx` | 班级操作菜单 | +| `class-students.tsx` | 班级学生列表 | +| `class-student-card.tsx` | 班级学生卡片 | +| `class-schedule.tsx` | 班级课表 | +| `class-schedule-form.tsx` | 课表项创建/编辑表单 | +| `class-schedule-item.tsx` | 课表项展示 | +| `class-invitation.tsx` | 邀请码组件 | +| `class-invitation-dialog.tsx` | 邀请码对话框 | +| `class-enroll-dialog.tsx` | 学生注册对话框 | +| `class-subject-teachers.tsx` | 学科教师分配 | +| `class-subject-teacher-form.tsx` | 学科教师表单 | +| `class-insights.tsx` | 班级洞察 | +| `class-homework-insights.tsx` | 班级作业洞察 | +| `class-status-badge.tsx` | 班级状态徽章 | --- @@ -529,11 +1225,14 @@ ### 导出函数 (actions.ts) +> 所有 12 个 actions 均使用 `requirePermission()` 进行权限校验。 +> 学校 CRUD actions(createSchoolAction/updateSchoolAction/deleteSchoolAction)在写操作成功后调用 `logAudit()` 记录操作日志。 + | 函数 | 权限 | 核心功能 | |------|------|---------| -| `createSchoolAction` | SCHOOL_MANAGE | 创建学校 | -| `updateSchoolAction` | SCHOOL_MANAGE | 更新学校 | -| `deleteSchoolAction` | SCHOOL_MANAGE | 删除学校 | +| `createSchoolAction` | SCHOOL_MANAGE | 创建学校(成功后记录 audit log: school.create) | +| `updateSchoolAction` | SCHOOL_MANAGE | 更新学校(成功后记录 audit log: school.update) | +| `deleteSchoolAction` | SCHOOL_MANAGE | 删除学校(成功后记录 audit log: school.delete) | | `createGradeAction` | GRADE_MANAGE | 创建年级 | | `updateGradeAction` | GRADE_MANAGE | 更新年级 | | `deleteGradeAction` | GRADE_MANAGE | 删除年级 | @@ -555,6 +1254,59 @@ | `getStaffOptions()` | school 组件 (年级主任选择) | | `getGradesForStaff(staffId)` | grade_head 视图 | +### Schema (schema.ts) + +#### `CreateSchoolSchema` +- 类型:Zod schema +- 定义:创建学校的校验 schema(name, code) +- 被使用:`createSchoolAction` + +#### `CreateGradeSchema` +- 类型:Zod schema +- 定义:创建年级的校验 schema(schoolId, name, order, gradeHeadId?, teachingHeadId?) +- 被使用:`createGradeAction` + +#### `CreateDepartmentSchema` +- 类型:Zod schema +- 定义:创建部门的校验 schema(name, description?) +- 被使用:`createDepartmentAction` + +#### `CreateAcademicYearSchema` +- 类型:Zod schema +- 定义:创建学年的校验 schema(name, startDate, endDate, isActive?) +- 被使用:`createAcademicYearAction` + +### 类型/接口 + +#### `SchoolListItem` +- 定义:学校列表项类型(含 id, name, code) +- 被使用:admin 学校管理 + +#### `GradeListItem` +- 定义:年级列表项类型(含 schoolId, name, gradeHeadId?) +- 被使用:admin 年级管理, exams + +#### `DepartmentListItem` +- 定义:部门列表项类型 +- 被使用:admin 部门管理 + +#### `AcademicYearListItem` +- 定义:学年列表项类型 +- 被使用:admin 学年管理 + +#### `StaffOption` +- 定义:员工选项类型(含 id, name) +- 被使用:school 组件 (年级主任选择) + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `school-form.tsx` | 学校创建/编辑表单 | +| `grade-form.tsx` | 年级创建/编辑表单 | +| `department-form.tsx` | 部门创建/编辑表单 | +| `academic-year-form.tsx` | 学年创建/编辑表单 | + --- ## 模块:dashboard @@ -572,13 +1324,50 @@ ### 类型/接口 #### `StudentDashboardProps` -- 被使用:student-dashboard-view.tsx +- 被使用:student-dashboard.tsx - 依赖:`homework/types.StudentDashboardGradeProps` +#### `StudentDashboard` +- 定义:学生仪表盘组件(原 `StudentDashboardView`,已重命名) +- 被使用:student/dashboard/page.tsx + #### `TeacherDashboardData` - 被使用:teacher-dashboard-view.tsx - 依赖:`homework/data-access.getTeacherGradeTrends`, `classes/data-access.getTeacherClasses` +#### `TeacherDashboardProps` +- 定义:教师仪表盘组件 Props 类型 +- 被使用:teacher-dashboard-view.tsx + +#### `AdminDashboardData` +- 定义:管理员仪表盘数据类型(含 activeSessionsCount, userCount, userRoleCounts, classCount 等) +- 被使用:admin/dashboard/page.tsx + +#### `DashboardWidget` +- 定义:仪表盘通用小组件类型(含 title, value, trend? 等) +- 被使用:dashboard/components + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `admin-dashboard-view.tsx` | 管理员仪表盘视图 | +| `teacher-dashboard-view.tsx` | 教师仪表盘视图 | +| `student-dashboard.tsx` | 学生仪表盘视图(原 StudentDashboardView) | +| `parent-dashboard-view.tsx` | 家长仪表盘视图 | +| `dashboard-card.tsx` | 仪表盘卡片 | +| `dashboard-widget.tsx` | 仪表盘小组件 | +| `dashboard-stat.tsx` | 统计数据展示 | +| `dashboard-chart.tsx` | 图表组件 | +| `dashboard-schedule.tsx` | 课表展示 | +| `dashboard-assignment-list.tsx` | 作业列表 | +| `dashboard-assignment-item.tsx` | 作业项 | +| `dashboard-grade-trend.tsx` | 成绩趋势 | +| `dashboard-class-list.tsx` | 班级列表 | +| `dashboard-submission-list.tsx` | 提交列表 | +| `dashboard-empty.tsx` | 仪表盘空状态 | +| `dashboard-header.tsx` | 仪表盘头部 | + --- ## 模块:layout @@ -593,14 +1382,51 @@ - 功能:根据权限渲染侧边栏导航 #### `SiteHeader` -- 内部使用:`useSession`, `signOut` -- 功能:顶部导航栏 +- 内部使用:`useSession`, `signOut`, `NotificationDropdown`(来自 messaging 模块), `GlobalSearch`(来自 shared/components), `Breadcrumb` +- 功能:顶部导航栏(含全局搜索 GlobalSearch、通知下拉菜单展示未读通知数、面包屑导航、用户下拉菜单) + +#### `SidebarProvider` +- Props: `{ children: React.ReactNode, defaultOpen?: boolean }` +- 功能:侧边栏状态上下文 Provider(管理展开/折叠状态) +- 被使用:app/layout.tsx + +### 导出 Hooks + +#### `useSidebar` +- 签名:`useSidebar(): { isOpen, toggle, setOpen, isMobile, openMobile, setOpenMobile }` +- 功能:访问侧边栏状态(需在 `SidebarProvider` 内使用) +- 被使用:app-sidebar.tsx, site-header.tsx + +### 类型/接口 + +#### `Role` +- 定义:`type Role = "admin" | "teacher" | "student" | "parent" | "grade_head" | "teaching_head"` +- 被使用:NAV_CONFIG, usePermission + +#### `NavItem` +- 定义:`{ title: string; href: string; icon?: string; permission?: Permission; children?: NavItem[] }` +- 被使用:NAV_CONFIG, app-sidebar.tsx ### 导出配置 #### `NAV_CONFIG` -- 类型:`Record` -- 每个NavItem含 `permission?: string` 字段 +- 类型:`Record` +- 每个NavItem含 `permission?: Permission` 字段 +- admin 角色菜单包含 "Audit Logs" 项(icon: ScrollText, href: /admin/audit-logs, permission: Permissions.AUDIT_LOG_READ),含子项 Operation Logs 与 Login Logs +- admin 角色菜单的 "School Management" 子菜单包含 "Course Plans" 项(href: /admin/course-plans, permission: Permissions.COURSE_PLAN_MANAGE) +- admin 角色菜单的 "School Management" 子菜单包含 "Import Users" 项(href: /admin/users/import, permission: Permissions.USER_MANAGE) +- admin 角色菜单包含 "Scheduling" 项(icon: CalendarClock, href: /admin/scheduling/rules, permission: Permissions.SCHEDULE_ADJUST),含子项 Rules (/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule (/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests (/admin/scheduling/changes, permission: SCHEDULE_ADJUST) +- teacher 角色菜单包含 "Grades" 项(icon: GraduationCap, permission: Permissions.GRADE_RECORD_READ),含子项 All Grades (/teacher/grades)、Batch Entry (/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics (/teacher/grades/stats)、Analytics (/teacher/grades/analytics, permission: GRADE_RECORD_READ) +- teacher 角色菜单包含 "Course Plans" 项(icon: CalendarRange, href: /teacher/course-plans, permission: Permissions.COURSE_PLAN_READ) +- teacher 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /teacher/attendance, permission: Permissions.ATTENDANCE_MANAGE),含子项 Records (/teacher/attendance)、Take Attendance (/teacher/attendance/sheet, permission: ATTENDANCE_MANAGE)、Statistics (/teacher/attendance/stats, permission: ATTENDANCE_READ) +- teacher 角色菜单包含 "Schedule Changes" 项(icon: CalendarClock, href: /teacher/schedule-changes, permission: Permissions.SCHEDULE_ADJUST) +- student 角色菜单包含 "My Grades" 项(icon: GraduationCap, href: /student/grades, permission: Permissions.GRADE_RECORD_READ) +- student 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /student/attendance, permission: Permissions.ATTENDANCE_READ) +- parent 角色菜单包含 "Dashboard" 项(icon: LayoutDashboard, href: /parent/dashboard,无 permission 字段,仅需登录) +- parent 角色菜单包含 "Grades" 项(icon: GraduationCap, href: /parent/grades, permission: Permissions.GRADE_RECORD_READ) +- parent 角色菜单包含 "Attendance" 项(icon: CalendarCheck, href: /parent/attendance, permission: Permissions.ATTENDANCE_READ) +- parent 角色菜单包含 "Announcements" 项(icon: Megaphone, href: /announcements, permission: Permissions.ANNOUNCEMENT_READ) +- 所有角色(admin/teacher/student/parent)菜单均包含 "Messages" 项(icon: Mail, href: /messages, permission: Permissions.MESSAGE_READ) - 被使用:app-sidebar.tsx --- @@ -608,33 +1434,1175 @@ ## 模块:settings ### 模块职责 -系统设置:AI Provider 配置、用户偏好。 +系统设置:AI Provider 配置、用户偏好、密码安全(修改密码、强度校验)。 ### 导出函数 (actions.ts) +> 所有 3 个 actions 均使用 `requirePermission(AI_CONFIGURE)` 进行权限校验。 + | 函数 | 权限 | 核心功能 | |------|------|---------| | `getAiProviderSummaries()` | AI_CONFIGURE | 获取 AI Provider 列表 | | `upsertAiProviderAction(data)` | AI_CONFIGURE | 创建/更新 AI Provider | | `testAiProviderAction(data)` | AI_CONFIGURE | 测试 AI Provider 连通性 | +### 导出函数 (actions-password.ts) + +#### `changePasswordAction` +- 签名:`(prevState: ActionState, formData: FormData) => Promise>` +- 权限:`requireAuth()`(任何登录用户可修改自己的密码) +- 功能:修改当前用户密码(校验当前密码、新密码策略、速率限制 PASSWORD_CHANGE: 5次/分钟) +- 依赖:`requireAuth`, `validatePassword`, `rateLimit`, bcryptjs (`hash`/`compare`), `shared/db` (users, passwordSecurity) +- 被使用:settings/components/password-change-form.tsx + +### 类型/接口 + +#### `AiProviderSummary` +- 定义:AI Provider 摘要类型(含 id, provider, baseUrl, model, apiKeyLast4, isDefault 等,不含完整 apiKey) +- 被使用:getAiProviderSummaries, settings/components + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `ai-provider-list.tsx` | AI Provider 列表 | +| `ai-provider-form.tsx` | AI Provider 创建/编辑表单 | +| `ai-provider-card.tsx` | AI Provider 卡片 | +| `ai-provider-test-dialog.tsx` | AI Provider 测试对话框 | +| `ai-provider-actions.tsx` | AI Provider 操作菜单 | +| `settings-layout.tsx` | 设置页面布局 | +| `password-change-form.tsx` | 密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示) | +| `notification-preferences-form.tsx` | 通知偏好表单(Delivery Channels: push/email/sms + Notification Categories: messages/announcements/homework/grades/attendance;Switch 切换 + 隐藏 checkbox 提交;useActionState 调用 updateNotificationPreferencesAction;toast 反馈) | +| `admin-settings-view.tsx` | 管理员设置视图(General/Appearance/Security/Notifications tab,Security 含 PasswordChangeForm,Notifications 含 NotificationPreferencesForm;接收 notificationPreferences prop) | +| `teacher-settings-view.tsx` | 教师设置视图(同上,含 Notifications tab) | +| `student-settings-view.tsx` | 学生设置视图(同上,含 Notifications tab) | + +--- + +## 模块:users + +### 模块职责 +用户个人资料管理:当前用户查看与更新自己的资料;用户批量导入/导出(Excel)。 + +### 模块路径 +`src/modules/users` + +### 导出函数 (actions.ts) + +#### `updateUserProfile` +- 签名:`(prevState: ActionState | null, formData: FormData) => Promise>` +- 功能:更新当前用户个人资料(name, phone, address, gender, age 等) +- 依赖:`requireAuth()`, `shared/db` +- 被使用:profile-form.tsx + +> 注:本模块仅使用 `requireAuth()` 校验登录状态,不涉及权限点(用户只能修改自己的资料)。 + +#### `downloadUserTemplateAction` +- 签名:`() => Promise>` +- 权限:`requirePermission(USER_MANAGE)` +- 功能:生成用户导入模板(返回 base64 编码的 Excel) +- 依赖:`requirePermission`, `import-export.generateUserImportTemplate` +- 被使用:components/user-import-dialog.tsx + +#### `importUsersAction` +- 签名:`(prevState: ActionState | null, formData: FormData) => Promise>` +- 权限:`requirePermission(USER_MANAGE)` +- 功能:导入用户:接收文件,解析+验证+批量创建(默认密码 123456) +- 依赖:`requirePermission`, `shared/lib/excel.parseExcel`, `import-export.parseUserImportData`, `import-export.batchImportUsers` +- 被使用:components/user-import-dialog.tsx + +#### `exportUsersAction` +- 签名:`(role?: string) => Promise>` +- 权限:`requirePermission(USER_MANAGE)` +- 功能:导出用户列表(返回 base64 编码的 Excel) +- 依赖:`requirePermission`, `import-export.exportUsersToExcel` +- 被使用:待扩展 + +### 导出函数 (data-access.ts) + +#### `getUserProfile` +- 签名:`(userId: string) => Promise` +- 功能:获取用户个人资料(含 name, email, phone, address, gender, age, grade, department 等) +- 依赖:`shared/db` +- 被使用:profile/page.tsx, profile-form.tsx + +### 导出函数 (import-export.ts) + +#### `generateUserImportTemplate` +- 签名:`() => Promise` +- 功能:生成用户导入模板(列:姓名/邮箱/角色/手机/班级邀请码,含示例行) +- 依赖:`shared/lib/excel.generateTemplate` +- 被使用:`downloadUserTemplateAction` + +#### `parseUserImportData` +- 签名:`(rows: Record[]) => UserImportValidation` +- 功能:解析并验证导入行(校验姓名/邮箱格式/角色枚举/邀请码仅 student) +- 依赖:无 +- 被使用:`importUsersAction` + +#### `batchImportUsers` +- 签名:`(records: UserImportRecord[]) => Promise` +- 功能:批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级) +- 依赖:`shared/db`, `bcryptjs`, `@paralleldrive/cuid2` +- 被使用:`importUsersAction` + +#### `exportUsersToExcel` +- 签名:`(params: { scope: DataScope; role?: string }) => Promise` +- 功能:导出用户列表到 Excel(含姓名/邮箱/手机/性别/年龄/角色/创建时间) +- 依赖:`shared/db`, `shared/lib/excel.exportToExcel` +- 被使用:`exportUsersAction`, `app/api/export/route.ts` + +### 类型/接口 + +#### `UserProfile` +- 定义:用户资料类型(含 id, name, email, phone, address, gender, age, gradeId?, departmentId?, onboardedAt? 等) +- 被使用:profile/page.tsx, profile-form.tsx + +#### `UpdateUserProfileInput` +- 定义:更新用户资料输入类型(name?, phone?, address?, gender?, age? 等可选字段) +- 被使用:`updateUserProfile`, profile-form.tsx + +#### `UserImportRecord` +- 定义:`{ name, email, role, phone?, invitationCode? }` +- 被使用:`parseUserImportData`, `batchImportUsers` + +#### `UserImportValidation` +- 定义:`{ valid: UserImportRecord[], invalid: Array<{ row, record, errors }> }` +- 被使用:`parseUserImportData` + +#### `UserImportResult` +- 定义:`{ successCount, failedCount, errors: Array<{ row, email, error }> }` +- 被使用:`batchImportUsers`, `importUsersAction` + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `user-import-dialog.tsx` | 用户批量导入对话框(4 状态:idle/preview/importing/done;下载模板→上传预览→确认导入→结果展示) | + +--- + +## 模块:audit + +### 模块职责 +操作日志与登录日志查询:提供审计日志的列表展示、筛选与分页能力,支撑管理员审计与合规需求。 + +### 模块路径 +`src/modules/audit` + +### 导出函数 (data-access.ts) + +#### `getAuditLogs` +- 签名:`getAuditLogs(params?: AuditLogQueryParams): Promise>` +- 参数说明:`params`: AuditLogQueryParams,含 userId?, module?, action?, status?, page?, pageSize?, startDate?, endDate? +- 功能:分页查询操作日志(支持按模块/操作/状态/日期范围过滤) +- 依赖:`shared/db` (auditLogs 表) +- 被使用:app/(dashboard)/admin/audit-logs/page.tsx + +#### `getLoginLogs` +- 签名:`getLoginLogs(params?: LoginLogQueryParams): Promise>` +- 参数说明:`params`: LoginLogQueryParams,含 userId?, action?, status?, page?, pageSize?, startDate?, endDate? +- 功能:分页查询登录日志(支持按操作/状态/日期范围过滤) +- 依赖:`shared/db` (loginLogs 表) +- 被使用:app/(dashboard)/admin/audit-logs/login-logs/page.tsx + +#### `getAuditModuleOptions` +- 签名:`getAuditModuleOptions(): Promise` +- 功能:获取操作日志中所有不同的 module 值(用于筛选下拉框) +- 依赖:`shared/db` (auditLogs 表) +- 被使用:app/(dashboard)/admin/audit-logs/page.tsx + +#### `getDataChangeLogs` +- 签名:`getDataChangeLogs(params?: DataChangeLogQueryParams): Promise>` +- 参数说明:`params`: DataChangeLogQueryParams,含 tableName?, recordId?, action?, userId?, page?, pageSize?, startDate?, endDate? +- 功能:分页查询数据变更日志(支持按表名/记录ID/操作/用户/日期范围过滤) +- 依赖:`shared/db` (dataChangeLogs 表) +- 被使用:`getDataChangeLogsAction` + +#### `getDataChangeStats` +- 签名:`getDataChangeStats(): Promise` +- 功能:按 tableName 分组统计数据变更日志数量(用于统计卡片) +- 依赖:`shared/db` (dataChangeLogs 表) +- 被使用:`getDataChangeLogsAction` + +#### `getDataChangeTableOptions` +- 签名:`getDataChangeTableOptions(): Promise` +- 功能:获取数据变更日志中所有不同的 tableName 值(用于筛选下拉框) +- 依赖:`shared/db` (dataChangeLogs 表) +- 被使用:`getDataChangeLogsAction` + +#### `getDataChangeLogsForExport` +- 签名:`getDataChangeLogsForExport(params?: DataChangeLogQueryParams): Promise` +- 功能:导出用:按条件查询全部数据变更日志(取前 100 条,无分页上限封顶) +- 依赖:`getDataChangeLogs` +- 被使用:`exportDataChangeLogsAction` + +#### `getAuditLogsForExport` +- 签名:`getAuditLogsForExport(params?: AuditLogQueryParams): Promise` +- 功能:导出用:按条件查询全部操作日志(取前 100 条) +- 依赖:`getAuditLogs` +- 被使用:`exportAuditLogsAction` + +#### `getLoginLogsForExport` +- 签名:`getLoginLogsForExport(params?: LoginLogQueryParams): Promise` +- 功能:导出用:按条件查询全部登录日志(取前 100 条) +- 依赖:`getLoginLogs` +- 被使用:`exportLoginLogsAction` + +### 导出函数 (actions.ts) + +> 所有 actions 均使用 `requirePermission(AUDIT_LOG_READ)` 进行权限校验(数据变更日志复用 AUDIT_LOG_READ 权限)。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `getDataChangeLogsAction` | AUDIT_LOG_READ | 获取数据变更日志(分页结果 + tableOptions + stats 三者并行加载) | +| `exportAuditLogsAction` | AUDIT_LOG_READ | 导出操作日志为 Excel(返回 `{ buffer, filename }`) | +| `exportLoginLogsAction` | AUDIT_LOG_READ | 导出登录日志为 Excel(返回 `{ buffer, filename }`) | +| `exportDataChangeLogsAction` | AUDIT_LOG_READ | 导出数据变更日志为 Excel(返回 `{ buffer, filename }`) | + +### 类型/接口 (types.ts) + +#### `AuditLog` +- 定义:操作日志类型(含 id, userId, userName, action, module, targetId, targetType, detail, ipAddress, userAgent, status, createdAt) +- 被使用:audit/components, audit/data-access + +#### `LoginLog` +- 定义:登录日志类型(含 id, userId, userEmail, action, status, ipAddress, userAgent, errorMessage, createdAt) +- 被使用:audit/components, audit/data-access + +#### `AuditLogQueryParams` +- 定义:操作日志查询参数类型(含 userId?, module?, action?, status?, page?, pageSize?, startDate?, endDate?) +- 被使用:`getAuditLogs`, audit-logs/page.tsx + +#### `LoginLogQueryParams` +- 定义:登录日志查询参数类型(含 userId?, action?, status?, page?, pageSize?, startDate?, endDate?) +- 被使用:`getLoginLogs`, login-logs/page.tsx + +#### `PaginatedResult` +- 定义:分页结果接口(含 items: T[], total, page, pageSize, totalPages) +- 被使用:`getAuditLogs`, `getLoginLogs`, `getDataChangeLogs`, audit/components + +#### `DataChangeAction` +- 定义:`"create" | "update" | "delete"` +- 被使用:data-access, change-logger, types.DataChangeLog + +#### `DataChangeLog` +- 定义:数据变更日志类型(含 id, tableName, recordId, action, oldValue, newValue, changedBy, changedByName, ipAddress, createdAt) +- 被使用:audit/data-access, audit/actions + +#### `DataChangeStat` +- 定义:`{ tableName: string; count: number }`(按表名分组的变更统计) +- 被使用:`getDataChangeStats`, `getDataChangeLogsAction` + +#### `DataChangeLogQueryParams` +- 定义:数据变更日志查询参数类型(含 tableName?, recordId?, action?, userId?, page?, pageSize?, startDate?, endDate?) +- 被使用:`getDataChangeLogs`, `getDataChangeLogsForExport`, `getDataChangeLogsAction`, `exportDataChangeLogsAction` + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `audit-log-table.tsx` | 操作日志表格(含分页控件) | +| `audit-log-filters.tsx` | 操作日志筛选器(模块/操作/状态/日期,基于 nuqs) | +| `audit-log-view.tsx` | 操作日志视图(筛选+表格+URL 分页) | +| `login-log-table.tsx` | 登录日志表格(含分页控件) | +| `login-log-filters.tsx` | 登录日志筛选器(操作/状态/日期,基于 nuqs) | +| `login-log-view.tsx` | 登录日志视图(筛选+表格+URL 分页) | + +--- + +## 模块:announcements + +### 模块职责 +通知公告系统:创建、编辑、发布、归档、删除公告,所有登录用户可查看已发布公告。 + +### 模块路径 +`src/modules/announcements` + +### 导出函数 (actions.ts) + +> 所有 manage actions 均使用 `requirePermission(ANNOUNCEMENT_MANAGE)` 进行权限校验。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `createAnnouncementAction` | ANNOUNCEMENT_MANAGE | 创建公告(草稿/已发布) | +| `updateAnnouncementAction` | ANNOUNCEMENT_MANAGE | 更新公告 | +| `deleteAnnouncementAction` | ANNOUNCEMENT_MANAGE | 删除公告 | +| `publishAnnouncementAction` | ANNOUNCEMENT_MANAGE | 发布公告(设置 published 状态) | +| `archiveAnnouncementAction` | ANNOUNCEMENT_MANAGE | 归档公告 | +| `getAnnouncementsAction` | requireAuth | 获取公告列表(所有登录用户可读) | + +### 导出函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getAnnouncements` | `(params?: { status?, type?, page?, pageSize? }) => Promise` | admin/announcements, announcements 页面 | +| `getAnnouncementById` | `(id: string) => Promise` | 编辑页面 | + +### Schema (schema.ts) + +#### `CreateAnnouncementSchema` +- 类型:Zod schema +- 定义:创建公告的校验 schema(title, content, type, status, targetGradeId?, targetClassId?, publishedAt?) +- 被使用:`createAnnouncementAction` + +#### `UpdateAnnouncementSchema` +- 类型:Zod schema +- 定义:更新公告的校验 schema(同上) +- 被使用:`updateAnnouncementAction` + +### 类型/接口 + +#### `Announcement` +- 定义:公告完整类型(含 id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, createdAt, updatedAt) +- 被使用:announcements/components, 页面 + +#### `AnnouncementListItem` +- 定义:公告列表项类型(同 Announcement) +- 被使用:列表页 + +#### `AnnouncementStatus` +- 定义:`"draft" | "published" | "archived"` +- 被使用:data-access, components + +#### `AnnouncementType` +- 定义:`"school" | "grade" | "class"` +- 被使用:data-access, components + +#### `GetAnnouncementsParams` +- 定义:查询参数类型(status?, type?, page?, pageSize?) +- 被使用:`getAnnouncements`, `getAnnouncementsAction` + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `announcement-list.tsx` | 公告列表(支持状态筛选) | +| `announcement-card.tsx` | 单条公告卡片 | +| `announcement-form.tsx` | 创建/编辑表单 | +| `announcement-detail.tsx` | 详情查看(含发布/归档/删除操作) | +| `admin-announcements-view.tsx` | 管理端公告视图(列表+创建对话框) | + +--- + +## 模块:files + +### 模块职责 +文件上传与管理:通过 API 路由处理文件上传(保存到 `public/uploads/YYYY-MM/`),记录文件元数据到 DB,支持按关联资源(exam/textbook/question/announcement)多态查询、下载与删除。 + +### 模块路径 +`src/modules/files` + +### 导出函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `createFileAttachment` | `(data: CreateFileAttachmentInput) => Promise` | `app/api/upload/route.ts` | +| `getFileAttachment` | `(id: string) => Promise` | `app/api/files/[id]/route.ts` | +| `getFileAttachmentsByTarget` | `(targetType: string, targetId: string) => Promise` | 按关联资源查询文件列表 | +| `getFileAttachmentsByUploader` | `(uploaderId: string) => Promise` | 按上传者查询文件列表 | +| `getAllFileAttachments` | `(limit?: number) => Promise` | `app/(dashboard)/admin/files/page.tsx` | +| `deleteFileAttachment` | `(id: string) => Promise` | `app/api/files/[id]/route.ts` | +| `deleteFileAttachments` | `(ids: string[]) => Promise` | `app/api/files/batch-delete/route.ts`(批量删除 DB 记录,失败回退逐条删除) | +| `getFileAttachmentsWithFilters` | `(params: FileAttachmentQueryParams) => Promise` | 管理员文件筛选(mimeType 精确/前缀匹配 + originalName/filename 模糊搜索 + limit/offset 分页) | +| `getFileStats` | `() => Promise` | 文件统计(总数、总大小、按 mimeType 分组) | +| `getFileAttachmentsByIds` | `(ids: string[]) => Promise` | `app/api/files/batch-delete/route.ts`(批量删除前获取磁盘路径) | + +### 类型/接口 (types.ts) + +#### `FileAttachment` +- 定义:文件附件完整类型(含 id, filename, originalName, mimeType, size, storagePath, url, uploaderId, targetType, targetId, createdAt) +- 被使用:files/components, data-access, API 路由 + +#### `FileUploadResult` +- 定义:上传成功返回类型 `{ id, url, filename, originalName, size, mimeType }` +- 被使用:`app/api/upload/route.ts` 响应, file-upload.tsx 回调 + +#### `FileTargetType` +- 定义:`"exam" | "textbook" | "question" | "announcement"` +- 被使用:types.FileAttachment.targetType, file-upload.tsx + +#### `CreateFileAttachmentInput` +- 定义:创建文件附件记录的输入类型 +- 被使用:`createFileAttachment` + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `file-upload.tsx` | 文件上传组件(拖拽+点击上传,进度条,文件类型校验,调用 `/api/upload`) | +| `file-list.tsx` | 文件列表展示(图标、文件名、大小、下载链接、删除按钮) | +| `file-preview.tsx` | 文件预览(图片直接预览,PDF iframe,其他下载) | +| `file-icon.tsx` | 根据 MIME 类型显示不同图标与颜色 | +| `admin-files-view.tsx` | 管理端文件视图(上传+列表+删除) | + +### API 路由 + +#### `POST /api/upload` +- 文件:`app/api/upload/route.ts` +- 功能:接收 `multipart/form-data`,保存文件到 `public/uploads/YYYY-MM/cuid.ext`,写入 DB,返回 `FileUploadResult` +- 权限:`requireAuth()`(需登录) +- 限制:10MB、白名单 MIME 类型 + +#### `GET /api/files/[id]` +- 文件:`app/api/files/[id]/route.ts` +- 功能:获取文件元数据 +- 权限:`requireAuth()` + +#### `DELETE /api/files/[id]` +- 文件:`app/api/files/[id]/route.ts` +- 功能:删除文件(DB 记录 + 磁盘文件) +- 权限:`requirePermission(FILE_DELETE)` + +#### `POST /api/files/batch-delete` +- 文件:`app/api/files/batch-delete/route.ts` +- 功能:批量删除文件(先查文件记录,通过 `storageProvider.delete` 删除磁盘文件静默失败,再调用 `deleteFileAttachments` 删除 DB 记录) +- 权限:`requirePermission(FILE_DELETE)` +- 请求体:JSON `{ ids: string[] }` +- 响应:`{ success, message, deletedCount, failedIds }` + +#### `POST /api/export` +- 文件:`app/api/export/route.ts` +- 功能:Excel 导出(grades/users/attendance),按 `type` 分发到 `exportGradeRecordsToExcel`/`exportUsersToExcel`,返回 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` 二进制流 +- 权限:`requireAuth()`(需登录) +- 请求体:JSON `{ type: "grades" | "users" | "attendance", params?: Record }` + +#### `POST /api/import` +- 文件:`app/api/import/route.ts` +- 功能:Excel 解析预览(不写 DB),接收 Excel 文件,调用 `parseExcel` 返回 sheets 预览数据(实际导入由 `users/actions.importUsersAction` 完成) +- 权限:`requirePermission(USER_MANAGE)` +- 请求体:`multipart/form-data`,字段 `file` +- 限制:仅 `.xlsx`/`.xls`,10MB 上限 + +--- + +## 模块:grades + +### 模块职责 +成绩分析模块:成绩录入(单条/批量)、查询、统计报表(均分、中位数、标准差、及格率、优秀率、班级排名)、Excel 导出(成绩明细+统计汇总/班级多科目横向对比)、趋势对比分析(成绩趋势、班级对比、科目对比、分数分布、排名趋势)。 + +### 模块路径 +`src/modules/grades` + +### 导出函数 (actions.ts) + +> 所有 manage actions 均使用 `requirePermission(GRADE_RECORD_MANAGE)` 进行权限校验,read actions 使用 `requirePermission(GRADE_RECORD_READ)`。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `createGradeRecordAction` | GRADE_RECORD_MANAGE | 创建单条成绩记录 | +| `batchCreateGradeRecordsAction` | GRADE_RECORD_MANAGE | 批量录入成绩(班级+科目+考试,表格形式) | +| `updateGradeRecordAction` | GRADE_RECORD_MANAGE | 更新成绩记录 | +| `deleteGradeRecordAction` | GRADE_RECORD_MANAGE | 删除成绩记录 | +| `getGradeRecordsAction` | GRADE_RECORD_READ | 查询成绩列表(按 scope 过滤) | +| `getClassGradeStatsAction` | GRADE_RECORD_READ | 获取班级成绩统计 | +| `getStudentGradeSummaryAction` | GRADE_RECORD_READ | 获取学生成绩汇总(学生/家长只能查自己/子女) | +| `getClassRankingAction` | GRADE_RECORD_READ | 获取班级排名 | +| `getGradeRecordByIdAction` | GRADE_RECORD_READ | 获取单条成绩记录 | +| `exportGradesAction` | GRADE_RECORD_READ | 导出成绩到 Excel(detail=成绩明细+统计汇总,class=班级多科目横向对比总表),返回 base64 buffer | + +### 导出函数 (actions-analytics.ts) + +> 所有 analytics actions 均使用 `requirePermission(GRADE_RECORD_READ)` 进行权限校验。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `getGradeTrendAction` | GRADE_RECORD_READ | 获取成绩趋势(按学生/科目/学期,返回归一化分数趋势点) | +| `getClassComparisonAction` | GRADE_RECORD_READ | 获取班级对比(同年级各班的均分/及格率/优秀率) | +| `getSubjectComparisonAction` | GRADE_RECORD_READ | 获取科目对比(同班级各科目雷达图数据) | +| `getGradeDistributionAction` | GRADE_RECORD_READ | 获取分数分布(90-100/80-89/70-79/60-69/<60 各区间人数) | +| `getRankingTrendAction` | GRADE_RECORD_READ | 获取排名趋势(学生历次考试排名变化,含 DataScope 二次校验) | + +### 导出函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getGradeRecords` | `(params: GradeQueryParams & { scope: DataScope; currentUserId?: string }) => Promise` | teacher/grades/page.tsx, getGradeRecordsAction | +| `getGradeRecordById` | `(id: string) => Promise` | getGradeRecordByIdAction | +| `createGradeRecord` | `(data: CreateGradeRecordInput, recordedBy: string) => Promise` | createGradeRecordAction | +| `batchCreateGradeRecords` | `(data: BatchCreateGradeRecordInput, recordedBy: string) => Promise` | batchCreateGradeRecordsAction | +| `updateGradeRecord` | `(id: string, data: UpdateGradeRecordInput) => Promise` | updateGradeRecordAction | +| `deleteGradeRecord` | `(id: string) => Promise` | deleteGradeRecordAction | +| `getClassGradeStats` | `(classId, subjectId?, examId?) => Promise` | getClassGradeStatsAction | +| `getStudentGradeSummary` | `(studentId: string) => Promise` | getStudentGradeSummaryAction, student/grades, parent/grades | +| `getClassRanking` | `(classId, subjectId?, examId?) => Promise` | getClassRankingAction, teacher/grades/stats | +| `getClassStudentsForEntry` | `(classId: string) => Promise>` | teacher/grades/entry | +| `getClassGradeStatsWithMeta` | `(classId, subjectId?, examId?) => Promise` | teacher/grades/stats | + +### 导出函数 (data-access-analytics.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getGradeTrend` | `(params: { studentId; subjectId?; semester?; scope: DataScope }) => Promise` | getGradeTrendAction, teacher/grades/analytics | +| `getClassComparison` | `(params: { gradeId; subjectId; examId?; scope: DataScope }) => Promise` | getClassComparisonAction, teacher/grades/analytics | +| `getSubjectComparison` | `(params: { classId; examId?; semester?; scope: DataScope }) => Promise` | getSubjectComparisonAction, teacher/grades/analytics | +| `getGradeDistribution` | `(params: { classId; subjectId?; examId?; scope: DataScope }) => Promise` | getGradeDistributionAction, teacher/grades/analytics | + +### 导出函数 (data-access-ranking.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getRankingTrend` | `(studentId: string, subjectId?, semester?) => Promise` | getRankingTrendAction | + +### 导出函数 (export.ts) + +#### `exportGradeRecordsToExcel` +- 签名:`(params: { classId: string; subjectId?: string; examId?: string; scope: DataScope }) => Promise` +- 功能:导出成绩单(Sheet1 成绩明细,Sheet2 统计汇总:均分/中位数/最高分/最低分/标准差/及格率/优秀率/参考人数) +- 依赖:`shared/lib/excel.exportToExcel`, `data-access.getGradeRecords`, `data-access.getClassGradeStats` +- 被使用:`exportGradesAction`, `app/api/export/route.ts` + +#### `exportClassGradeReportToExcel` +- 签名:`(params: { classId: string; scope: DataScope }) => Promise` +- 功能:导出班级成绩总表(多科目横向对比,含总分/平均分/排名列) +- 依赖:`shared/db`, `shared/lib/excel.exportToExcel`, `data-access.getGradeRecords` +- 被使用:`exportGradesAction` + +#### `formatDateForFile` +- 签名:`(d?: Date) => string` +- 功能:格式化日期为 `YYYY-MM-DD` 用于文件名 +- 依赖:无 +- 被使用:`exportGradesAction` + +### Schema (schema.ts) + +#### `CreateGradeRecordSchema` +- 类型:Zod schema +- 定义:创建成绩记录的校验 schema(studentId, classId, subjectId, examId?, title, score, fullScore?, type?, semester?, remark?) +- 被使用:`createGradeRecordAction` + +#### `BatchCreateGradeRecordSchema` +- 类型:Zod schema +- 定义:批量录入成绩的校验 schema(classId, subjectId, examId?, title, fullScore?, type?, semester?, records[{ studentId, score, remark? }]) +- 被使用:`batchCreateGradeRecordsAction` + +#### `UpdateGradeRecordSchema` +- 类型:Zod schema +- 定义:更新成绩记录的校验 schema(title?, score?, fullScore?, type?, semester?, remark?, examId?) +- 被使用:`updateGradeRecordAction` + +### 类型/接口 (types.ts) + +#### `GradeRecord` +- 定义:成绩记录完整类型(含 id, studentId, classId, subjectId, examId, title, score, fullScore, type, semester, recordedBy, remark, createdAt, updatedAt) +- 被使用:grades/data-access, grades/components + +#### `GradeRecordListItem` +- 定义:成绩列表项类型(含 studentName, className, subjectName, recorderName 等关联名称) +- 被使用:grades/components, teacher/grades + +#### `GradeStats` +- 定义:成绩统计类型(average, median, max, min, stdDev, passRate, excellentRate, count) +- 被使用:grades/components, teacher/grades/stats + +#### `ClassGradeStats` +- 定义:班级成绩统计类型(classId, className, stats: GradeStats, studentCount) +- 被使用:grades/components, teacher/grades/stats + +#### `StudentGradeSummary` +- 定义:学生成绩汇总类型(studentId, studentName, records[], averageScore, rank) +- 被使用:grades/components, student/grades, parent/grades + +#### `ClassRankingItem` +- 定义:班级排名项类型(studentId, studentName, averageScore, rank, recordCount) +- 被使用:grades/components, teacher/grades/stats + +#### `GradeRecordType` +- 定义:`"exam" | "quiz" | "homework" | "other"` +- 被使用:grades/data-access, grades/components + +#### `GradeQueryParams` +- 定义:查询参数类型(classId?, subjectId?, studentId?, type?, semester?, examId?) +- 被使用:`getGradeRecords`, `getGradeRecordsAction` + +#### `GradeTrendPoint` / `GradeTrendResult` +- 定义:成绩趋势类型(Point: date, title, score, fullScore, normalizedScore, type;Result: label, points[], averageScore) +- 被使用:getGradeTrend, getGradeTrendAction, grade-trend-chart + +#### `ClassComparisonItem` +- 定义:班级对比项类型(classId, className, averageScore, passRate, excellentRate, studentCount) +- 被使用:getClassComparison, getClassComparisonAction, class-comparison-chart + +#### `SubjectComparisonItem` +- 定义:科目对比项类型(subjectId, subjectName, averageScore, passRate, excellentRate) +- 被使用:getSubjectComparison, getSubjectComparisonAction, subject-comparison-chart + +#### `GradeDistributionBucket` / `GradeDistributionResult` +- 定义:分数分布类型(Bucket: label, min, max, count, percentage;Result: buckets[], totalCount) +- 被使用:getGradeDistribution, getGradeDistributionAction, grade-distribution-chart + +#### `RankingTrendPoint` / `RankingTrendResult` +- 定义:排名趋势类型(Point: title, date, rank, totalStudents, score;Result: studentName, points[]) +- 被使用:getRankingTrend, getRankingTrendAction + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `grade-record-form.tsx` | 单条成绩录入表单 | +| `batch-grade-entry.tsx` | 批量录入界面(选择班级+科目+考试,表格形式录入每个学生分数) | +| `grade-record-list.tsx` | 成绩列表(含删除功能) | +| `grade-stats-card.tsx` | 统计卡片(均分、中位数、及格率、优秀率等) | +| `grade-query-filters.tsx` | 查询筛选器(班级、科目、考试类型、学期) | +| `student-grade-summary.tsx` | 学生成绩汇总视图 | +| `class-grade-report.tsx` | 班级成绩报表(含统计+排名) | +| `export-button.tsx` | 成绩导出按钮(DropdownMenu 选择 detail/class 报表类型,调用 exportGradesAction 并触发浏览器下载) | +| `grade-trend-chart.tsx` | 成绩趋势折线图(recharts LineChart,归一化分数 0-100) | +| `class-comparison-chart.tsx` | 班级对比柱状图(recharts BarChart,均分/及格率/优秀率) | +| `subject-comparison-chart.tsx` | 科目对比雷达图(recharts RadarChart) | +| `grade-distribution-chart.tsx` | 分数分布柱状图(recharts BarChart,彩色区间 90-100/80-89/70-79/60-69/<60) | + +--- + +## 模块:course-plans + +### 模块职责 +课程计划管理:创建、编辑、删除课程计划(含周计划条目),管理员可管理全部,教师/学生/年级主任/教务主任可查看。 + +### 导出函数 (actions.ts) + +#### `createCoursePlanAction` +- 签名:`(prevState: ActionState | null, formData: FormData) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:创建课程计划 +- 依赖:`requirePermission`, `shared/db`, `data-access.createCoursePlan` +- 被以下模块使用:course-plan-form.tsx + +#### `updateCoursePlanAction` +- 签名:`(id: string, prevState: ActionState | null, formData: FormData) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:更新课程计划 +- 依赖:`requirePermission`, `shared/db`, `data-access.updateCoursePlan` +- 被以下模块使用:course-plan-form.tsx + +#### `deleteCoursePlanAction` +- 签名:`(id: string) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:删除课程计划 +- 依赖:`requirePermission`, `shared/db`, `data-access.deleteCoursePlan` +- 被以下模块使用:course-plan-detail.tsx + +#### `getCoursePlansAction` +- 签名:`(params?: GetCoursePlansParams) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_READ)` +- 功能:获取课程计划列表 +- 依赖:`requirePermission`, `data-access.getCoursePlans` + +#### `getCoursePlanAction` +- 签名:`(id: string) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_READ)` +- 功能:获取课程计划详情(含周计划条目) +- 依赖:`requirePermission`, `data-access.getCoursePlanById` + +#### `createCoursePlanItemAction` +- 签名:`(prevState: ActionState | null, formData: FormData) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:创建周计划条目 +- 依赖:`requirePermission`, `shared/db`, `data-access.createCoursePlanItem` +- 被以下模块使用:course-plan-item-editor.tsx + +#### `updateCoursePlanItemAction` +- 签名:`(id: string, prevState: ActionState | null, formData: FormData) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:更新周计划条目 +- 依赖:`requirePermission`, `shared/db`, `data-access.updateCoursePlanItem` +- 被以下模块使用:course-plan-item-editor.tsx + +#### `deleteCoursePlanItemAction` +- 签名:`(id: string) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:删除周计划条目 +- 依赖:`requirePermission`, `shared/db`, `data-access.deleteCoursePlanItem` +- 被以下模块使用:course-plan-item-editor.tsx + +#### `toggleCoursePlanItemCompletedAction` +- 签名:`(id: string, completed: boolean) => Promise>` +- 权限:`requirePermission(COURSE_PLAN_MANAGE)` +- 功能:切换周计划条目完成状态 +- 依赖:`requirePermission`, `shared/db`, `data-access.updateCoursePlanItem` +- 被以下模块使用:course-plan-detail.tsx + +### 导出函数 (data-access.ts) + +#### `getCoursePlans` +- 签名:`(params?: GetCoursePlansParams) => Promise` +- 功能:查询课程计划列表(含班级/科目/教师名称,支持按 classId/teacherId/subjectId/status 过滤) +- 依赖:`shared.db`, `shared.db.schema.coursePlans/classes/subjects/users` +- 被以下模块使用:admin/course-plans/page.tsx, teacher/course-plans/page.tsx + +#### `getCoursePlanById` +- 签名:`(id: string) => Promise` +- 功能:按 ID 获取课程计划详情(含周计划条目列表) +- 依赖:`shared.db`, `shared.db.schema.coursePlans/coursePlanItems/classes/subjects/users` +- 被以下模块使用:admin/course-plans/[id]/page.tsx, teacher/course-plans/[id]/page.tsx + +#### `createCoursePlan` +- 签名:`(data: CreateCoursePlanInput, createdBy: string) => Promise` +- 功能:创建课程计划记录 +- 依赖:`shared.db`, `shared.db.schema.coursePlans`, `@paralleldrive/cuid2` +- 被以下模块使用:createCoursePlanAction + +#### `updateCoursePlan` +- 签名:`(id: string, data: Partial) => Promise` +- 功能:更新课程计划(部分字段) +- 依赖:`shared.db`, `shared.db.schema.coursePlans` +- 被以下模块使用:updateCoursePlanAction + +#### `deleteCoursePlan` +- 签名:`(id: string) => Promise` +- 功能:删除课程计划(级联删除周计划条目) +- 依赖:`shared.db`, `shared.db.schema.coursePlans` +- 被以下模块使用:deleteCoursePlanAction + +#### `createCoursePlanItem` / `updateCoursePlanItem` / `deleteCoursePlanItem` +- 签名:`(data: CreateCoursePlanItemInput) => Promise` / `(id: string, data: Partial) => Promise` / `(id: string) => Promise` +- 功能:周计划条目 CRUD +- 依赖:`shared.db`, `shared.db.schema.coursePlanItems`, `@paralleldrive/cuid2` +- 被以下模块使用:createCoursePlanItemAction, updateCoursePlanItemAction, deleteCoursePlanItemAction, toggleCoursePlanItemCompletedAction + +#### `reorderCoursePlanItems` +- 签名:`(planId: string, items: ReorderCoursePlanItemInput[]) => Promise` +- 功能:重排周计划条目顺序 +- 依赖:`shared.db`, `shared.db.schema.coursePlanItems` + +#### `getSubjectOptions` +- 签名:`() => Promise<{id:string;name:string}[]>` +- 功能:获取科目选项列表 +- 依赖:`shared.db`, `shared.db.schema.subjects` +- 被以下模块使用:admin/course-plans/create/page.tsx, admin/course-plans/[id]/edit/page.tsx + +### Zod Schema (schema.ts) + +| Schema | 用途 | +|--------|------| +| `CreateCoursePlanSchema` | 创建课程计划校验(classId, subjectId, teacherId, academicYearId?, semester?, totalHours?, weeklyHours?, startDate?, endDate?, syllabus?, objectives?, status?) | +| `UpdateCoursePlanSchema` | 更新课程计划校验(所有字段可选,含 completedHours?) | +| `CreateCoursePlanItemSchema` | 创建周计划条目校验(planId, week, topic, content?, hours?, textbookChapter?, notes?) | +| `UpdateCoursePlanItemSchema` | 更新周计划条目校验(所有字段可选,含 isCompleted?, completedAt?) | + +### 类型/接口 (types.ts) + +| 类型 | 定义 | +|------|------| +| `CoursePlan` | 课程计划基础接口 | +| `CoursePlanItem` | 周计划条目接口 | +| `CoursePlanListItem` | = CoursePlan & { className, subjectName, teacherName } | +| `CoursePlanWithItems` | = CoursePlanListItem & { items: CoursePlanItem[] } | +| `CoursePlanStatus` | `"planning" \| "active" \| "completed" \| "paused"` | +| `CoursePlanSemester` | `"1" \| "2"` | +| `GetCoursePlansParams` | { classId?, teacherId?, subjectId?, status? } | +| `ReorderCoursePlanItemInput` | { id, week } | + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `course-plan-progress.tsx` | 进度条组件(completedHours/totalHours 百分比) | +| `course-plan-list.tsx` | 课程计划列表(支持状态筛选,URL 同步) | +| `course-plan-form.tsx` | 创建/编辑表单(班级、科目、教师、学年、学期、状态、课时、日期、大纲、目标) | +| `course-plan-item-editor.tsx` | 周计划条目编辑器(Dialog,支持创建/编辑/删除/切换完成) | +| `course-plan-detail.tsx` | 详情视图(计划信息、进度、大纲、目标、周计划表格、删除确认) | + +--- + +## 模块:parent + +### 模块职责 +家长端仪表盘:聚合家长关联子女的学习数据(课表、作业、成绩、班级),支持多子女切换查看。家长通过 `parentStudentRelations` 表关联子女,DataScope 解析为 `children` 类型。 + +### 模块路径 +`src/modules/parent` + +### 导出函数 (data-access.ts) + +> 所有函数使用 `cache()` 包装以实现请求级缓存。本模块仅用于读操作聚合,无 Server Action,权限校验在页面层通过 `requireAuth()` 完成。 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getChildren` | `(parentId: string) => Promise` | `getParentDashboardData` 内部, parent/children/[studentId] 页面 | +| `getChildBasicInfo` | `(studentId: string, relation?: string \| null) => Promise` | `getChildDashboardData` 内部 | +| `getChildDashboardData` | `(studentId: string, relation?: string \| null) => Promise` | parent/children/[studentId]/page.tsx, `getParentDashboardData` 内部 | +| `getParentDashboardData` | `(parentId: string) => Promise` | parent/dashboard/page.tsx | + +#### `getChildren` +- 依赖:`shared/db` (parentStudentRelations 表) +- 功能:查询指定家长的所有子女关联记录,按 createdAt 升序 + +#### `getChildBasicInfo` +- 依赖:`shared/db` (users, grades, classEnrollments, classes 表) +- 功能:聚合子女基础信息(姓名、邮箱、头像、年级名称、班级名称、与家长关系) + +#### `getChildDashboardData` +- 依赖:`getChildBasicInfo`, `classes/data-access.getStudentClasses`, `classes/data-access.getStudentSchedule`, `homework/data-access.getStudentHomeworkAssignments`, `homework/data-access.getStudentDashboardGrades`, `grades/data-access.getStudentGradeSummary` +- 功能:并行聚合单个子女的完整仪表盘数据(基础信息、已加入班级、今日课表、作业概览、成绩趋势、成绩汇总) + +#### `getParentDashboardData` +- 依赖:`shared/db` (users 表), `getChildren`, `getChildDashboardData` +- 功能:聚合家长名称与所有子女的仪表盘数据 + +### 类型/接口 (types.ts) + +#### `ParentChildRelation` +- 定义:家长-子女关联记录类型(id, parentId, studentId, relation, createdAt) +- 被使用:`getChildren`, `getParentDashboardData` + +#### `ChildBasicInfo` +- 定义:子女基础信息类型(id, name, email, image, gradeName, className, classId, relation) +- 被使用:`getChildBasicInfo`, `ChildDashboardData.basicInfo` + +#### `ChildScheduleItem` +- 定义:子女今日课表项类型(id, classId, className, course, startTime, endTime, location) +- 被使用:`ChildDashboardData.todaySchedule`, child-schedule-card.tsx + +#### `ChildHomeworkSummary` +- 定义:子女作业概览类型(pendingCount, submittedCount, gradedCount, overdueCount, recentAssignments) +- 被使用:`ChildDashboardData.homeworkSummary`, child-homework-summary.tsx + +#### `ChildDashboardData` +- 定义:单个子女仪表盘数据类型(basicInfo, enrolledClasses, todaySchedule, homeworkSummary, gradeTrend, gradeSummary) +- 依赖:`homework/types.StudentDashboardGradeProps`, `homework/types.StudentHomeworkAssignmentListItem`, `classes/types.StudentEnrolledClass`, `grades/types.StudentGradeSummary` +- 被使用:`getChildDashboardData`, `ParentDashboardData.children`, 所有 child-* 组件 + +#### `ParentDashboardData` +- 定义:家长仪表盘数据类型(parentName, children: ChildDashboardData[]) +- 被使用:`getParentDashboardData`, parent-dashboard.tsx + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `parent-dashboard.tsx` | 主容器组件(问候语、子女卡片网格、空状态) | +| `child-card.tsx` | 子女卡片(头像、姓名、班级、待完成/逾期/平均分统计,点击跳转详情) | +| `child-detail-header.tsx` | 子女详情页头部(返回按钮、头像、姓名、班级、年级、关系) | +| `child-detail-panel.tsx` | 子女详情面板容器(组合 homework/grade/schedule 三个子组件) | +| `child-homework-summary.tsx` | 子女作业概览(pending/submitted/graded/overdue 统计 + 最近作业列表) | +| `child-grade-summary.tsx` | 子女成绩概览(Recharts 折线图趋势 + 最新分数 + 班级排名 + 最近成绩列表,"use client") | +| `child-schedule-card.tsx` | 子女今日课表卡片(课程、时间、地点、班级) | + +--- + +## 模块:messaging + +### 模块职责 +站内消息系统:用户间私信收发(支持回复链)、站内通知(多态类型:message/announcement/homework/grade),SiteHeader 通知下拉菜单展示未读数。 + +### 模块路径 +`src/modules/messaging` + +### 导出函数 (actions.ts) + +> send actions 使用 `requirePermission(MESSAGE_SEND)`,read actions 使用 `requirePermission(MESSAGE_READ)`,delete actions 使用 `requirePermission(MESSAGE_DELETE)`,通知读取使用 `requireAuth()`。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `sendMessageAction` | MESSAGE_SEND | 发送消息(同时为收件人创建通知;支持 parentMessageId 回复) | +| `markMessageAsReadAction` | MESSAGE_READ | 标记消息已读(设置 readAt) | +| `deleteMessageAction` | MESSAGE_DELETE | 删除消息(仅发送者或接收者可删) | +| `getMessagesAction` | MESSAGE_READ | 获取消息列表(收件箱/已发送,分页) | +| `getMessageDetailAction` | MESSAGE_READ | 获取消息详情(含回复线程) | +| `getRecipientsAction` | MESSAGE_SEND | 获取可发送对象列表(按 DataScope 过滤) | +| `getNotificationsAction` | requireAuth | 获取当前用户通知列表(分页) | +| `markNotificationAsReadAction` | requireAuth | 标记单条通知已读 | +| `markAllNotificationsAsReadAction` | requireAuth | 标记所有通知已读 | +| `getNotificationPreferencesAction` | requireAuth | 获取当前用户通知偏好(无记录时自动创建默认记录) | +| `updateNotificationPreferencesAction` | requireAuth | 更新(upsert)当前用户通知偏好(从 FormData 解析 checkbox "on" 为布尔值) | + +### 导出函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getMessages` | `(userId: string, params?: { type?, page?, pageSize? }) => Promise>` | messages 页面 | +| `getMessageById` | `(id: string, userId: string) => Promise` | 消息详情页 | +| `getMessageThread` | `(rootId: string, userId: string) => Promise` | 消息详情页(回复链) | +| `createMessage` | `(input: CreateMessageInput) => Promise` | sendMessageAction | +| `markMessageAsRead` | `(id: string, userId: string) => Promise` | markMessageAsReadAction, 详情页自动已读 | +| `deleteMessage` | `(id: string, userId: string) => Promise` | deleteMessageAction | +| `getUnreadMessageCount` | `(userId: string) => Promise` | 待扩展 | +| `getNotifications` | `(userId: string, params?: { page?, pageSize? }) => Promise>` | 通知列表/下拉菜单 | +| `createNotification` | `(input: CreateNotificationInput) => Promise` | sendMessageAction(内部调用) | +| `markNotificationAsRead` | `(id: string, userId: string) => Promise` | markNotificationAsReadAction | +| `markAllNotificationsAsRead` | `(userId: string) => Promise` | markAllNotificationsAsReadAction | +| `getUnreadNotificationCount` | `(userId: string) => Promise` | 待扩展 | +| `getRecipients` | `(ctx: AuthContext) => Promise` | compose 页面(按 DataScope 过滤) | + +### 导出函数 (notification-preferences.ts) + +> 文件标记 `"server-only"`,使用 React `cache` 包装读取函数。 + +#### `getNotificationPreferences` +- 签名:`getNotificationPreferences(userId: string): Promise`(cache 包装) +- 功能:获取用户通知偏好;若用户无记录则自动创建一条默认记录(pushEnabled/homeworkNotifications/gradeNotifications/announcementNotifications/messageNotifications/attendanceNotifications 默认 true,emailEnabled/smsEnabled 默认 false),并发冲突时回退到查询 +- 依赖:`shared/db` (notificationPreferences 表), `@paralleldrive/cuid2`, `react` (cache) +- 被使用:`getNotificationPreferencesAction`, `app/(dashboard)/settings/page.tsx` + +#### `upsertNotificationPreferences` +- 签名:`upsertNotificationPreferences(userId: string, input: UpdateNotificationPreferencesInput): Promise` +- 功能:upsert 语义更新通知偏好(存在则部分字段更新,不存在则插入;未提供的字段保留原值或使用默认值) +- 依赖:`shared/db` (notificationPreferences 表), `@paralleldrive/cuid2` +- 被使用:`updateNotificationPreferencesAction` + +### Schema (schema.ts) + +#### `SendMessageSchema` +- 类型:Zod schema +- 定义:发送消息校验 schema(receiverId, subject?, content, parentMessageId?) +- 被使用:`sendMessageAction` + +### 类型/接口 + +#### `Message` +- 定义:消息完整类型(含 id, senderId, receiverId, subject, content, isRead, readAt, parentMessageId, createdAt, senderName, receiverName) +- 被使用:messaging/components, 页面 + +#### `MessageListItem` +- 定义:消息列表项类型(同 Message 精简版) +- 被使用:列表页 + +#### `MessageThread` +- 定义:消息线程类型(root + replies) +- 被使用:详情页 + +#### `Notification` +- 定义:通知完整类型(含 id, userId, type, title, content, link, isRead, createdAt) +- 被使用:notification-dropdown, notification-list + +#### `NotificationListItem` +- 定义:通知列表项类型(同 Notification) +- 被使用:列表页 + +#### `NotificationType` +- 定义:`"message" | "announcement" | "homework" | "grade"` +- 被使用:data-access, components + +#### `MessageType` +- 定义:`"inbox" | "sent"` +- 被使用:getMessages 参数 + +#### `CreateMessageInput` / `CreateNotificationInput` +- 定义:创建消息/通知的输入类型 +- 被使用:data-access.createMessage, createNotification + +#### `RecipientOption` +- 定义:`{ id: string; name: string }` +- 被使用:compose 页面下拉选项 + +#### `NotificationPreferences` +- 定义:通知偏好完整类型(含 id, userId, emailEnabled, smsEnabled, pushEnabled, homeworkNotifications, gradeNotifications, announcementNotifications, messageNotifications, attendanceNotifications, createdAt, updatedAt) +- 被使用:`getNotificationPreferences`, `upsertNotificationPreferences`, `getNotificationPreferencesAction`, `updateNotificationPreferencesAction`, settings/components/notification-preferences-form.tsx + +#### `UpdateNotificationPreferencesInput` +- 定义:更新通知偏好的输入类型(所有字段可选:emailEnabled?, smsEnabled?, pushEnabled?, homeworkNotifications?, gradeNotifications?, announcementNotifications?, messageNotifications?, attendanceNotifications?;未提供则保留原值) +- 被使用:`upsertNotificationPreferences`, `updateNotificationPreferencesAction` + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `message-list.tsx` | 消息列表(收件箱/已发送 Tab 切换,已读/未读标记,usePermission 控制"写消息"按钮) | +| `message-detail.tsx` | 消息详情(含回复线程、回复/删除操作,AlertDialog 删除确认,usePermission 控制按钮可见性) | +| `message-compose.tsx` | 写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式) | +| `notification-dropdown.tsx` | SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,滚动列表,标记已读,查看全部链接) | +| `notification-list.tsx` | 通知完整列表(全部标记已读、单条标记已读、查看链接) | + +--- + +## 模块:attendance + +### 模块职责 +学生考勤管理:教师按班级/日期点名(单条/批量)、查询考勤记录、统计出勤率/迟到率,学生/家长查看本人/子女考勤汇总,管理员查看全校考勤记录。支持班级考勤规则配置(迟到阈值、早退阈值、自动标记)。 + +### 模块路径 +`src/modules/attendance` + +### 导出函数 (actions.ts) + +> 所有 manage actions 均使用 `requirePermission(ATTENDANCE_MANAGE)` 进行权限校验,read actions 使用 `requirePermission(ATTENDANCE_READ)`。学生/家长在 `getStudentAttendanceAction` 中进行 DataScope 二次校验(class_members 仅查自己,children 仅查子女)。 + +| 函数 | 权限 | 核心功能 | +|------|------|---------| +| `recordAttendanceAction` | ATTENDANCE_MANAGE | 创建单条考勤记录 | +| `batchRecordAttendanceAction` | ATTENDANCE_MANAGE | 批量点名(班级+日期,表格形式录入每个学生状态) | +| `updateAttendanceAction` | ATTENDANCE_MANAGE | 更新考勤记录(状态、备注) | +| `deleteAttendanceAction` | ATTENDANCE_MANAGE | 删除考勤记录 | +| `getAttendanceAction` | ATTENDANCE_READ | 分页查询考勤记录(按 scope 过滤) | +| `getStudentAttendanceAction` | ATTENDANCE_READ | 获取学生考勤汇总(含 DataScope 二次校验) | +| `getClassAttendanceStatsAction` | ATTENDANCE_READ | 获取班级考勤统计 | +| `getClassAttendanceForDateAction` | ATTENDANCE_READ | 获取班级指定日期考勤(用于点名页加载已有记录) | +| `saveAttendanceRulesAction` | ATTENDANCE_MANAGE | 保存班级考勤规则(upsert) | +| `getAttendanceRulesAction` | ATTENDANCE_READ | 获取班级考勤规则 | + +### 导出函数 (data-access.ts) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getAttendanceRecords` | `(params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }) => Promise` | getAttendanceAction | +| `getClassAttendanceForDate` | `(classId: string, date: string) => Promise` | getClassAttendanceForDateAction | +| `createAttendanceRecord` | `(data: RecordAttendanceInput, recordedBy: string) => Promise` | recordAttendanceAction | +| `batchCreateAttendanceRecords` | `(data: BatchRecordAttendanceInput, recordedBy: string) => Promise` | batchRecordAttendanceAction | +| `updateAttendanceRecord` | `(id: string, data: UpdateAttendanceInput) => Promise` | updateAttendanceAction | +| `deleteAttendanceRecord` | `(id: string) => Promise` | deleteAttendanceAction | +| `getClassStudentsForAttendance` | `(classId: string) => Promise>` | 点名页学生列表 | +| `getAttendanceRules` | `(classId?: string) => Promise` | getAttendanceRulesAction | +| `upsertAttendanceRules` | `(data: AttendanceRuleInput) => Promise` | saveAttendanceRulesAction | + +### 导出函数 (data-access-stats.ts) + +> 从 data-access.ts 拆分以遵守单文件 ≤300 行规则。 + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getStudentAttendanceSummary` | `(studentId: string, startDate?: string, endDate?: string) => Promise` | getStudentAttendanceAction, student/attendance, parent/attendance | +| `getClassAttendanceStats` | `(classId: string, startDate?: string, endDate?: string) => Promise` | getClassAttendanceStatsAction, teacher/attendance/stats | + +### Schema (schema.ts) + +| Schema | 用途 | +|--------|------| +| `AttendanceStatusEnum` | 考勤状态枚举(present/absent/late/early_leave/excused) | +| `RecordAttendanceSchema` | 单条考勤记录校验(studentId, classId, scheduleId?, date, status, remark?) | +| `BatchRecordAttendanceSchema` | 批量考勤校验(records[{ studentId, classId, scheduleId?, date, status, remark? }]) | +| `UpdateAttendanceSchema` | 更新考勤校验(status?, remark?, scheduleId?) | +| `AttendanceRuleSchema` | 考勤规则校验(classId, lateThresholdMinutes?, earlyLeaveThresholdMinutes?, enableAutoMark?) | + +### 类型/接口 (types.ts) + +| 类型 | 定义 | +|------|------| +| `AttendanceStatus` | `"present" \| "absent" \| "late" \| "early_leave" \| "excused"` | +| `AttendanceRecord` | 考勤记录完整类型 | +| `AttendanceListItem` | 列表项类型(含 studentName, className, recorderName) | +| `AttendanceStats` | 统计类型(total, present, absent, late, earlyLeave, excused, presentRate, lateRate) | +| `StudentAttendanceSummary` | 学生考勤汇总(studentId, studentName, stats, recentRecords) | +| `ClassAttendanceSummary` | 班级考勤汇总(classId, className, date, stats, studentRecords) | +| `AttendanceRule` | 考勤规则类型(classId, lateThresholdMinutes, earlyLeaveThresholdMinutes, enableAutoMark) | +| `AttendanceQueryParams` | 查询参数(classId?, studentId?, date?, startDate?, endDate?, status?, page?, pageSize?) | +| `PaginatedAttendanceResult` | 分页结果(items, total, page, pageSize, totalPages) | +| `ATTENDANCE_STATUS_LABELS` | 状态中文标签常量 | +| `ATTENDANCE_STATUS_COLORS` | 状态颜色常量(用于 Badge) | + +### 导出组件 (components/) + +| 组件文件 | 功能 | +|---------|------| +| `attendance-sheet.tsx` | 批量点名表单(班级/日期选择器 + 学生表格 + 每行状态 Select + "全部标记到场"按钮) | +| `attendance-record-list.tsx` | 考勤记录列表表格(含删除确认对话框) | +| `attendance-stats-card.tsx` | 统计卡片(总数、到场、缺勤、迟到、早退、请假、出勤率、迟到率) | +| `attendance-filters.tsx` | URL 同步筛选器(班级、状态、日期) | +| `student-attendance-view.tsx` | 学生/家长视图(统计卡片 + 最近记录表格) | +| `attendance-rules-form.tsx` | 考勤规则配置表单(班级选择器、迟到/早退阈值、自动标记勾选) | + +--- + +## 模块:scheduling + +`src/modules/scheduling` + +排课与调课模块:管理员配置班级排课规则(每日课时、连续课时、午休、上下学时间、避免背靠背、科目均衡),自动排课引擎按规则生成周课表,调课/代课申请与审批流程,课表冲突检测。 + +> 所有 actions 均使用 `requirePermission()` 进行权限校验:规则配置/调课申请/冲突检测/查询使用 `requirePermission(SCHEDULE_ADJUST)`,自动排课/应用课表/审批调课使用 `requirePermission(SCHEDULE_AUTO)`。admin 角色拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 角色无排课权限。 + +### Server Actions (`actions.ts`) + +| Action | 权限 | 用途 | +|--------|------|------| +| `saveSchedulingRulesAction` | SCHEDULE_ADJUST | 保存班级排课规则(upsert,classId 为空时为全局规则) | +| `autoScheduleAction` | SCHEDULE_AUTO | 根据规则与科目分配生成预览课表(不落库) | +| `applyAutoScheduleAction` | SCHEDULE_AUTO | 将生成的课表写入 classSchedule 表(事务:先删后插) | +| `requestScheduleChangeAction` | SCHEDULE_ADJUST | 提交调课/代课申请(status=pending) | +| `approveScheduleChangeAction` | SCHEDULE_AUTO | 审批通过调课申请(status=approved) | +| `rejectScheduleChangeAction` | SCHEDULE_AUTO | 驳回调课申请(status=rejected) | +| `getScheduleChangesAction` | SCHEDULE_ADJUST | 查询调课申请列表(可按 classId/status/requesterId 过滤) | +| `getClassConflictsAction` | SCHEDULE_ADJUST | 检测班级课表时间重叠冲突 | + +### Data Access (`data-access.ts`) + +| 函数 | 签名 | 被使用 | +|------|------|--------| +| `getSchedulingRules` | `(classId?: string) => Promise` | saveSchedulingRulesAction, autoScheduleAction, admin/scheduling/rules | +| `upsertSchedulingRules` | `(data: SchedulingRuleInput) => Promise` | saveSchedulingRulesAction | +| `getScheduleChanges` | `(params: ScheduleChangeQueryParams) => Promise` | getScheduleChangesAction, admin/scheduling/changes, teacher/schedule-changes | +| `createScheduleChange` | `(data: ScheduleChangeInput, requestedBy: string) => Promise` | requestScheduleChangeAction | +| `updateScheduleChangeStatus` | `(id, status, approverId) => Promise` | approveScheduleChangeAction, rejectScheduleChangeAction | +| `getClassConflicts` | `(classId: string) => Promise` | getClassConflictsAction | +| `getAdminClassesForScheduling` | `() => Promise>` | 所有 scheduling 页面 | +| `getTeachersForScheduling` | `() => Promise>` | teacher/schedule-changes | +| `getClassroomsForScheduling` | `() => Promise>` | autoScheduleAction | +| `getClassSubjectsForScheduling` | `(classId) => Promise>` | autoScheduleAction | + +### Auto Scheduler (`auto-scheduler.ts`) + +| 函数 | 用途 | +|------|------| +| `autoSchedule` | 贪心+冲突检测排课算法:按科目每周课时降序,为每节课选择第一个满足约束的时段(午休、每日窗口、班级/教师/教室冲突、每日最大课时、避免背靠背) | +| `findOptimalSlot` | 在候选时段中找到第一个满足所有约束的时段 | +| `validateSchedule` | 校验生成的课表是否违反规则,返回冲突列表 | +| `buildDefaultTimeSlots` | 根据上下学时间和午休时间构建默认时段(周一至周五,上午4节+下午4节) | + +### Schemas (`schema.ts`) + +| Schema | 用途 | +|--------|------| +| `SchedulingRuleSchema` | 排课规则校验(classId, maxDailyHours?, maxContinuousHours?, lunchBreakStart?, lunchBreakEnd?, morningStart?, afternoonEnd?, avoidBackToBack?, balancedSubjects?) | +| `ScheduleChangeSchema` | 调课申请校验(classId, originalScheduleId?, originalTeacherId?, substituteTeacherId?, originalDate?, newDate?, newStartTime?, newEndTime?, reason) | +| `AutoScheduleParamsSchema` | 自动排课参数校验(classId, rules, subjects[], teachers[], classrooms[], timeSlots[]) | +| `ScheduleChangeStatusEnum` | 调课状态枚举(pending/approved/rejected/completed) | +| `ApproveScheduleChangeSchema` | 审批校验(changeId, reason?) | + +### Types (`types.ts`) + +| Type | 定义 | +|------|------| +| `ScheduleChangeStatus` | `"pending" \| "approved" \| "rejected" \| "completed"` | +| `SchedulingRule` | 排课规则完整类型 | +| `ScheduleChange` | 调课申请完整类型 | +| `ScheduleChangeListItem` | 列表项类型(含 className, originalTeacherName, substituteTeacherName, requesterName, approverName) | +| `TimeSlot` | `{ weekday, startTime, endTime }` | +| `ScheduleConflict` | `{ type, description, scheduleIds }`(type: teacher_overlap/classroom_overlap/class_overlap/rule_violation) | +| `AutoScheduleResult` | `{ success, scheduledCount, conflictCount, conflicts, schedules }` | +| `GeneratedSchedule` | `{ classId, weekday, startTime, endTime, course, location, teacherId, subjectId }` | +| `AutoScheduleParams` | `{ classId, rules, subjects, teachers, classrooms, timeSlots }` | +| `ScheduleChangeQueryParams` | `{ classId?, status?, requesterId? }` | +| `SCHEDULE_CHANGE_STATUS_LABELS` | 状态英文标签常量 | +| `SCHEDULE_CHANGE_STATUS_COLORS` | 状态颜色常量(用于 Badge) | + +### Components (`components/`) + +| 组件 | 用途 | +|------|------| +| `scheduling-rules-form.tsx` | 排课规则配置表单(班级选择器、每日最大课时、连续课时、午休时间、上下学时间、避免背靠背、科目均衡) | +| `auto-schedule-panel.tsx` | 自动排课面板(班级选择→预览→应用流程) | +| `auto-schedule-result.tsx` | 排课结果预览(课表表格 + 冲突/警告列表) | +| `schedule-change-form.tsx` | 调课/代课申请表单(班级、原任课教师、代课教师、原日期、新日期、新时间、原因) | +| `schedule-change-list.tsx` | 调课申请列表表格(含审批/驳回对话框,canApprove 控制审批按钮可见性) | +| `schedule-conflicts-view.tsx` | 冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表) | + --- ## 模块间依赖矩阵 -| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | -|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------| -| **shared** | - | - | - | - | - | - | - | - | - | - | - | -| **auth** | db,schema,permissions | - | - | - | - | - | - | - | - | - | - | -| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | -| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | -| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | -| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | -| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | -| **school** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | -| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | -| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | -| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | +| ↓ 使用 → | shared | auth | exams | homework | questions | textbooks | classes | school | dashboard | layout | settings | users | audit | announcements | files | grades | course-plans | parent | messaging | attendance | scheduling | +|----------|--------|------|-------|----------|-----------|-----------|---------|--------|-----------|--------|----------|-------|-------|---------------|-------|-------|-------------|--------|-----------|------------|-----------| +| **shared** | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **auth** | db,schema,permissions,login-logger | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **exams** | db,auth-guard,types,ai | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **homework** | db,auth-guard,types | auth | data-access.getExams | - | - | - | schema | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **questions** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **textbooks** | db,auth-guard,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **classes** | db,auth-guard,types | auth | - | homework-insights | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **school** | db,auth-guard,types,audit-logger | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **dashboard** | db,types | auth | - | data-access.getTeacherGradeTrends,getStudentDashboardGrades | - | - | data-access.getTeacherClasses,getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **layout** | hooks.usePermission | auth(useSession) | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | notification-dropdown | - | - | +| **settings** | db,auth-guard,ai,types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **users** | db,auth-guard(requireAuth,requirePermission),types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **audit** | db,auth-guard.requirePermission,types.permissions | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **announcements** | db,auth-guard,types | auth | - | - | - | - | - | - | data-access.getGrades | - | - | - | - | - | - | - | - | - | - | - | - | +| **files** | db,auth-guard(requireAuth,requirePermission),types,lib/file-storage | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **grades** | db,auth-guard,types,lib.excel | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **course-plans** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getAdminClasses,getStaffOptions | data-access.getAcademicYears | - | - | - | - | - | - | - | - | - | - | - | - | +| **parent** | db,auth-guard(requireAuth),types | auth | - | data-access.getStudentHomeworkAssignments,getStudentDashboardGrades | - | - | data-access.getStudentClasses,getStudentSchedule | - | - | - | - | - | - | - | - | data-access.getStudentGradeSummary | - | - | - | - | - | +| **messaging** | db,auth-guard(requirePermission,requireAuth),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **attendance** | db,auth-guard.requirePermission,types | auth | - | - | - | - | - | data-access.getTeacherClasses,getAdminClasses | - | - | - | - | - | - | - | - | - | - | - | - | - | +| **scheduling** | db,auth-guard(requirePermission,getAuthContext),types | auth | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | --- @@ -666,13 +2634,13 @@ 6. 在 `auth-guard.ts` 中通过 `classSubjectTeachers` 查询教师关联的 classIds,构建 `DataScope.class_taught` ### `permission` -1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(22 个权限点) -2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表 +1. 由 `shared/types/permissions.ts` 的 `Permissions` 常量定义(47 个权限点,含 `AUDIT_LOG_READ`、`ANNOUNCEMENT_MANAGE`、`FILE_UPLOAD`、`FILE_READ`、`FILE_DELETE`、`GRADE_RECORD_MANAGE`、`GRADE_RECORD_READ`、`COURSE_PLAN_MANAGE`、`COURSE_PLAN_READ`、`ATTENDANCE_MANAGE`、`ATTENDANCE_READ`、`MESSAGE_SEND`、`MESSAGE_READ`、`MESSAGE_DELETE`、`SCHEDULE_AUTO`、`SCHEDULE_ADJUST` 等) +2. 在 `shared/lib/permissions.ts` 中通过 `ROLE_PERMISSIONS` 映射角色到权限列表(admin 角色包含 `AUDIT_LOG_READ`、`COURSE_PLAN_MANAGE`+`COURSE_PLAN_READ`;admin/teacher 含 `FILE_UPLOAD/READ/DELETE` 及 `GRADE_RECORD_MANAGE/READ`;teacher/student/grade_head/teaching_head 含 `COURSE_PLAN_READ`;student/parent 含 `FILE_READ` 及 `GRADE_RECORD_READ`;admin/teacher 含 `ATTENDANCE_MANAGE`+`ATTENDANCE_READ`,student/parent/grade_head/teaching_head 含 `ATTENDANCE_READ`;admin/teacher/parent/grade_head/teaching_head 含 `MESSAGE_SEND/READ/DELETE`;student 含 `MESSAGE_READ/DELETE` 但无 `MESSAGE_SEND`;admin 含 `SCHEDULE_AUTO`+`SCHEDULE_ADJUST`,teacher/student/parent/grade_head/teaching_head 无排课权限) 3. 在 `auth.ts` JWT callback 中通过 `resolvePermissions(roleNames)` 合并多角色权限,存入 JWT 4. 在 `proxy.ts` middleware 中通过 `token.permissions` 检查路由访问权限 -5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限 +5. 在 `shared/lib/auth-guard.ts` 中通过 `requirePermission(permission)` 在 Server Action 层断言权限(如 audit-logs 页面使用 `requirePermission(AUDIT_LOG_READ)`;`DELETE /api/files/[id]` 使用 `requirePermission(FILE_DELETE)`;messaging/actions.ts 使用 `requirePermission(MESSAGE_SEND/READ/DELETE)`;attendance/actions.ts 使用 `requirePermission(ATTENDANCE_MANAGE/READ)`;users/actions.ts 的 `importUsersAction`/`exportUsersAction`/`downloadUserTemplateAction` 使用 `requirePermission(USER_MANAGE)`;grades/actions.ts 的 `exportGradesAction` 使用 `requirePermission(GRADE_RECORD_READ)`;`POST /api/import` 使用 `requirePermission(USER_MANAGE)`;scheduling/actions.ts 使用 `requirePermission(SCHEDULE_AUTO/SCHEDULE_ADJUST)`) 6. 在 `shared/hooks/use-permission.ts` 中通过 `hasPermission(permission)` 在客户端组件中条件渲染 -7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单 +7. 在 `layout/config/navigation.ts` 中作为 `NavItem.permission` 字段过滤侧边栏菜单(Audit Logs 菜单项使用 `Permissions.AUDIT_LOG_READ`;Messages 菜单项使用 `Permissions.MESSAGE_READ`;Attendance 菜单项 teacher 使用 `Permissions.ATTENDANCE_MANAGE`,student/parent 使用 `Permissions.ATTENDANCE_READ`;Import Users 菜单项使用 `Permissions.USER_MANAGE`;Scheduling 菜单项 admin 使用 `Permissions.SCHEDULE_ADJUST`/`SCHEDULE_AUTO`;teacher Schedule Changes 菜单项使用 `Permissions.SCHEDULE_ADJUST`) ### `DataScope` 1. 由 `auth-guard.ts` 的 `resolveDataScope(userId, roles)` 根据用户角色和 DB 关系动态计算 @@ -680,3 +2648,246 @@ 3. 传递到 `homework/data-access.getHomeworkAssignments({ scope })` 进行行级过滤 4. 传递到 `dashboard/data-access.getAdminDashboardData(scope)` 进行统计过滤 5. 在 exams/actions.ts 的 `updateExamAction`/`deleteExamAction` 中用于判断是否需要资源归属校验(`scope.type !== "all"`) +6. 对 parent 角色,`resolveDataScope` 查询 `parentStudentRelations` 表构建 `{ type: "children", childrenIds: string[] }`,传递到 `grades/data-access.getStudentGradeSummary` 等函数进行行级过滤 +7. 在 `parent/children/[studentId]/page.tsx` 中通过 `ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)` 二次校验家长拥有该子女 +8. 传递到 `attendance/data-access.getAttendanceRecords({ scope })` 进行行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部) +9. 在 `attendance/actions.ts` 的 `getStudentAttendanceAction` 中对 class_members/children 进行 DataScope 二次校验 + +--- + +## 路由表 + +### 根路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/` | 角色路由分发 | server | auth_required | 重定向到 `/dashboard` | + +### auth/* 子路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/login` | LoginForm | client | public | 登录页面 | +| `/register` | RegisterForm + registerAction | server | public | 注册页面(含未成年人信息保护、隐私政策/用户协议同意勾选) | +| `/privacy` | PrivacyPage | server | public | 隐私政策页面(信息收集/使用/保护、用户权利、Cookie、未成年人保护条款、联系方式) | +| `/terms` | TermsPage | server | public | 用户协议页面(服务说明、注册、行为规范、知识产权、免责、变更终止、法律适用) | + +### admin/school/* 子路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/school` | SchoolManagementHome | server | school:manage | 学校管理首页(聚合入口) | +| `/admin/school/schools` | SchoolList | server | school:manage | 学校列表(dataAccess: school/data-access.getSchools) | +| `/admin/school/grades` | GradeList | server | grade:manage | 年级列表(dataAccess: school/data-access.getGrades) | +| `/admin/school/grades/insights` | GradeInsights | server | grade:manage | 年级洞察(dataAccess: classes/data-access.getGradeHomeworkInsights) | +| `/admin/school/departments` | DepartmentList | server | school:manage | 部门列表(dataAccess: school/data-access.getDepartments) | +| `/admin/school/classes` | AdminClassList | server | school:manage | 班级列表(dataAccess: classes/data-access.getAdminClasses) | +| `/admin/school/academic-year` | AcademicYearList | server | school:manage | 学年列表(dataAccess: school/data-access.getAcademicYears) | + +### admin/audit-logs/* 子路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/audit-logs` | AuditLogView | server | audit_log:read | 操作日志列表(dataAccess: audit/data-access.getAuditLogs, getAuditModuleOptions;权限:requirePermission(AUDIT_LOG_READ)) | +| `/admin/audit-logs/login-logs` | LoginLogView | server | audit_log:read | 登录日志列表(dataAccess: audit/data-access.getLoginLogs;权限:requirePermission(AUDIT_LOG_READ)) | + +### admin/announcements/* 子路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/announcements` | AdminAnnouncementsView | client | announcement:manage | 公告管理首页(列表+创建对话框;dataAccess: announcements/data-access.getAnnouncements, school/data-access.getGrades) | +| `/admin/announcements/[id]` | AnnouncementForm | client | announcement:manage | 编辑公告(dataAccess: announcements/data-access.getAnnouncementById, school/data-access.getGrades;actions: updateAnnouncementAction) | + +### admin/files/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/files` | AdminFilesView | client | file:read | 管理员文件管理页面(上传+列表+删除;dataAccess: files/data-access.getAllFileAttachments;API: POST /api/upload, DELETE /api/files/[id] 需 file:delete) | + +### admin/course-plans/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/course-plans` | CoursePlanList | client | course_plan:manage | 管理员课程计划列表(支持状态筛选;dataAccess: course-plans/data-access.getCoursePlans) | +| `/admin/course-plans/create` | CoursePlanForm (create) | client | course_plan:manage | 创建课程计划(actions: createCoursePlanAction;dataAccess: classes/data-access.getAdminClasses, course-plans/data-access.getSubjectOptions, classes/data-access.getStaffOptions, school/data-access.getAcademicYears) | +| `/admin/course-plans/[id]` | CoursePlanDetail | client | course_plan:manage | 课程计划详情(含周计划表格;actions: deleteCoursePlanAction, createCoursePlanItemAction, updateCoursePlanItemAction, deleteCoursePlanItemAction, toggleCoursePlanItemCompletedAction;dataAccess: course-plans/data-access.getCoursePlanById) | +| `/admin/course-plans/[id]/edit` | CoursePlanForm (edit) | client | course_plan:manage | 编辑课程计划(actions: updateCoursePlanAction;dataAccess: course-plans/data-access.getCoursePlanById, classes/data-access.getAdminClasses, course-plans/data-access.getSubjectOptions, classes/data-access.getStaffOptions, school/data-access.getAcademicYears) | + +### admin/users/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/users/import` | UserImportPage (含 UserImportDialog) | server | user:manage | 用户批量导入页面(说明卡片+字段文档表+导入对话框;actions: users/actions.downloadUserTemplateAction, users/actions.importUsersAction;权限:requirePermission(USER_MANAGE)) | + +### announcements/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/announcements` | AnnouncementList | client | auth_required | 所有登录用户可查看的公告列表(仅 published;dataAccess: announcements/data-access.getAnnouncements) | + +### management/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/management/grade/classes` | GradeManagedClasses | server | grade:manage | 年级主任管理的班级(dataAccess: classes/data-access.getGradeManagedClasses) | +| `/management/grade/insights` | GradeInsightsView | server | grade:manage | 年级洞察视图(dataAccess: classes/data-access.getGradeHomeworkInsights) | + +### student/learning/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/student/learning/assignments` | StudentAssignmentList | server | homework:submit | 学生作业列表(dataAccess: homework/data-access.getStudentHomeworkAssignments) | +| `/student/learning/assignments/[assignmentId]` | HomeworkTakeView | client | homework:submit | 学生作答页面(actions: startHomeworkSubmissionAction, saveHomeworkAnswerAction, submitHomeworkAction) | +| `/student/learning/courses` | StudentCourseList | server | class:read | 学生课程列表(dataAccess: classes/data-access.getStudentClasses) | +| `/student/learning/textbooks` | StudentTextbookList | server | textbook:read | 学生教材列表(dataAccess: textbooks/data-access.getTextbooks) | +| `/student/learning/textbooks/[id]` | TextbookReader | client | textbook:read | 学生教材阅读器(dataAccess: textbooks/data-access.getTextbookById, getChaptersByTextbookId) | +| `/student/schedule` | StudentSchedule | server | class:read | 学生课表(dataAccess: classes/data-access.getStudentSchedule) | + +### teacher/homework/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/teacher/homework` | HomeworkHome | server | homework:create | 作业管理首页(聚合入口) | +| `/teacher/homework/assignments` | HomeworkAssignmentList | server | homework:create | 作业列表(dataAccess: homework/data-access.getHomeworkAssignments) | +| `/teacher/homework/assignments/create` | HomeworkAssignmentForm | client | homework:create | 创建作业(actions: createHomeworkAssignmentAction) | +| `/teacher/homework/assignments/[id]` | HomeworkAssignmentDetail | server | homework:create | 作业详情(dataAccess: homework/data-access.getHomeworkAssignmentById) | +| `/teacher/homework/assignments/[id]/submissions` | HomeworkSubmissionList | server | homework:grade | 作业提交列表(dataAccess: homework/data-access.getHomeworkSubmissions) | +| `/teacher/homework/submissions` | HomeworkReviewList | server | homework:grade | 批改列表(dataAccess: homework/data-access.getHomeworkAssignmentReviewList) | +| `/teacher/homework/submissions/[submissionId]` | HomeworkGradingView | client | homework:grade | 批改页面(actions: gradeHomeworkSubmissionAction, dataAccess: getHomeworkSubmissionDetails) | + +### teacher 其他路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/teacher/exams` | ExamDataTable | server | exam:read | 考试列表(dataAccess: exams/data-access.getExams) | +| `/teacher/exams/[id]/build` | ExamAssemblyPanel | client | exam:update | 组卷页面(components: assembly/*, actions: updateExamAction) | +| `/teacher/exams/grading` | ExamGradingList | server | exam:read | 考试批改列表 | +| `/teacher/exams/grading/[submissionId]` | ExamGradingView | client | exam:read | 考试批改页面 | +| `/teacher/classes/my/[id]` | ClassDetail | server | class:read | 班级详情(dataAccess: classes/data-access.getClassDetails) | +| `/teacher/classes` | TeacherClassList | server | class:read | 教师班级列表(dataAccess: classes/data-access.getTeacherClasses) | +| `/teacher/course-plans` | CoursePlanList (teacher) | client | course_plan:read | 教师课程计划列表(按当前用户 teacherId 过滤;dataAccess: course-plans/data-access.getCoursePlans) | +| `/teacher/course-plans/[id]` | CoursePlanDetail (teacher) | client | course_plan:read | 教师课程计划详情(只读,无编辑按钮;dataAccess: course-plans/data-access.getCoursePlanById) | + +### parent/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/parent/dashboard` | ParentDashboard | server | auth_required | 家长仪表盘首页(问候语 + 子女卡片网格;dataAccess: parent/data-access.getParentDashboardData;权限:requireAuth()) | +| `/parent/children/[studentId]` | ChildDetailHeader + ChildDetailPanel | server | auth_required | 子女详情页(头部 + 作业/成绩/课表面板;dataAccess: parent/data-access.getChildDashboardData;权限:requireAuth() + 二次校验 ctx.dataScope.childrenIds 包含 studentId) | +| `/parent/grades` | ParentGradesView | server | grade_record:read | 家长成绩视图(dataAccess: grades/data-access.getStudentGradeSummary,按 DataScope.children 过滤) | + +### messages/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/messages` | MessageList + NotificationList | server | message:read | 消息首页(收件箱/已发送列表 + 通知列表;dataAccess: messaging/data-access.getMessages, getNotifications;权限:requirePermission(MESSAGE_READ)) | +| `/messages/[id]` | MessageDetail | server | message:read | 消息详情(含回复线程;dataAccess: messaging/data-access.getMessageById, getMessageThread;actions: markMessageAsReadAction 自动已读;权限:requirePermission(MESSAGE_READ)) | +| `/messages/compose` | MessageCompose | server | message:send | 写消息页面(支持 reply 模式 via searchParams: receiverId, subject, parentMessageId;dataAccess: messaging/data-access.getRecipients;权限:requirePermission(MESSAGE_SEND)) | + +### attendance/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/teacher/attendance` | AttendanceRecordList + AttendanceFilters | server | attendance:manage | 教师考勤记录列表(dataAccess: attendance/data-access.getAttendanceRecords, classes/data-access.getTeacherClasses;权限:requirePermission(ATTENDANCE_MANAGE)) | +| `/teacher/attendance/sheet` | AttendanceSheet | client | attendance:manage | 批量点名页面(班级/日期选择 + 学生表格;actions: batchRecordAttendanceAction, getClassAttendanceForDateAction, getClassStudentsForAttendance;权限:requirePermission(ATTENDANCE_MANAGE)) | +| `/teacher/attendance/stats` | AttendanceStatsCard | server | attendance:read | 班级考勤统计(dataAccess: attendance/data-access-stats.getClassAttendanceStats, classes/data-access.getTeacherClasses;权限:requirePermission(ATTENDANCE_READ)) | +| `/student/attendance` | StudentAttendanceView | server | attendance:read | 学生考勤视图(统计卡片 + 最近记录;dataAccess: attendance/data-access-stats.getStudentAttendanceSummary;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己) | +| `/parent/attendance` | StudentAttendanceView (per child) | server | attendance:read | 家长考勤视图(遍历子女,每个子女展示 StudentAttendanceView;dataAccess: parent/data-access.getChildren, attendance/data-access-stats.getStudentAttendanceSummary;权限:requirePermission(ATTENDANCE_READ),DataScope.children 仅查子女) | +| `/admin/attendance` | AttendanceRecordList | server | attendance:manage | 管理员考勤总览(dataAccess: attendance/data-access.getAttendanceRecords(scope=all), classes/data-access.getAdminClasses;权限:requirePermission(ATTENDANCE_MANAGE)) | + +### scheduling/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/admin/scheduling/rules` | SchedulingRulesForm | server | schedule:adjust | 排课规则配置页面(dataAccess: scheduling/actions.getAdminClassesForScheduling, getSchedulingRules;actions: saveSchedulingRulesAction;权限:requirePermission(SCHEDULE_ADJUST)) | +| `/admin/scheduling/auto` | AutoSchedulePanel + AutoScheduleResultView | server | schedule:auto | 自动排课页面(dataAccess: scheduling/actions.getAdminClassesForScheduling;actions: autoScheduleAction, applyAutoScheduleAction;权限:requirePermission(SCHEDULE_AUTO)) | +| `/admin/scheduling/changes` | ScheduleChangeList + ScheduleConflictsView | server | schedule:adjust | 调课申请审批+冲突检测页面(dataAccess: scheduling/actions.getAdminClassesForScheduling, getScheduleChanges;actions: approveScheduleChangeAction, rejectScheduleChangeAction, getClassConflictsAction;权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO) | +| `/teacher/schedule-changes` | ScheduleChangeForm + ScheduleChangeList | server | schedule:adjust | 教师调课/代课申请页面(dataAccess: scheduling/actions.getAdminClassesForScheduling, getTeachersForScheduling, getScheduleChanges(requesterId=ctx.userId);actions: requestScheduleChangeAction;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请) | + +### grades/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/teacher/grades` | 成绩管理首页 | server | grade_record:read | 成绩列表(dataAccess: grades/actions.getGradeRecordsAction) | +| `/teacher/grades/entry` | 批量成绩录入 | server | grade_record:manage | 批量录入(actions: batchCreateGradeRecordsAction, createGradeRecordAction) | +| `/teacher/grades/stats` | 成绩统计报表 | server | grade_record:read | 班级统计+排名(dataAccess: getClassGradeStatsAction, getClassRankingAction) | +| `/teacher/grades/analytics` | 成绩趋势对比分析 | server | grade_record:read | 分析仪表盘(4 个分析图表并行加载;dataAccess: getGradeTrend, getGradeDistribution, getSubjectComparison, getClassComparison;权限:requirePermission(GRADE_RECORD_READ)) | +| `/student/grades` | StudentGradesView | server | grade_record:read | 学生成绩视图(dataAccess: getStudentGradeSummary,DataScope.class_members 仅查自己) | +| `/parent/grades` | ParentGradesView | server | grade_record:read | 家长成绩视图(dataAccess: getStudentGradeSummary,按 DataScope.children 过滤) | + +### settings/* 路由 + +| 路由 | 组件 | 类型 | 权限 | 说明 | +|------|------|------|------|------| +| `/settings` | 角色分发设置页 | server | auth_required | 根据权限渲染 Admin/Teacher/Student 设置视图(含 General/Appearance/Security/Notifications tab,Security tab 含 PasswordChangeForm,Notifications tab 含 NotificationPreferencesForm;dataAccess: messaging/notification-preferences.getNotificationPreferences) | +| `/settings/security` | SecuritySettingsPage | server | auth_required | 安全设置独立页面(PasswordChangeForm + 安全提示;权限:requireAuth()) | + +### API 路由(含速率限制) + +| 路由 | 方法 | 限流规则 | 说明 | +|------|------|---------|------| +| `/api/auth/[...nextauth]` | GET, POST | — | NextAuth 认证(登录流程内置 LOGIN 限流: 5次/15分钟) | +| `/api/ai/chat` | POST | AI_CHAT: 20次/分钟 | AI 聊天(按 userId 限流,超限返回 429 + Retry-After 头) | +| `/api/upload` | POST | UPLOAD: 10次/分钟 | 文件上传(按 userId 限流,超限返回 429 + Retry-After 头) | +| `/api/rate-limit-test` | GET | PASSWORD_CHANGE: 5次/分钟 | 限流测试端点(按 userId 限流,用于手动验证 429 响应) | +| `/api/export` | POST | — | Excel 导出 | +| `/api/import` | POST | — | Excel 解析预览 | +| `/api/files/[id]` | GET, DELETE | — | 文件元数据/删除 | +| `/api/files/batch-delete` | POST | — | 批量删除文件(需 FILE_DELETE 权限,先删磁盘文件再删 DB 记录) | +| `/api/search` | GET | — | 全文检索(questions/textbooks/exams/announcements,需登录;参数 q/type/page/pageSize) | +| `/api/onboarding/*` | GET, POST | — | 用户引导 | + +--- + +## DevOps 与脚本 + +### CI 配置 (`.gitea/workflows/ci.yml`) + +| Job | 触发条件 | 说明 | +|-----|---------|------| +| `build-deploy` | push/PR to main | 构建、测试、部署到 Docker(自托管 runner CDCD) | +| `security-audit` | push/PR to main | 依赖安全审计:`npm audit` moderate/critical 检查,上传 audit-report.json artifact | +| `scheduled-backup` | schedule cron `0 2 * * *` | 每天凌晨 2 点执行数据库备份,上传 backups/ artifact(保留 30 天) | + +### 运维脚本 (`scripts/`) + +| 脚本 | 用途 | +|------|------| +| `scripts/audit.sh` | Bash 依赖审计脚本,运行 `npm audit --audit-level=moderate`,失败时生成 audit-report.json | +| `scripts/audit.ps1` | PowerShell 版本依赖审计脚本(Windows 环境) | +| `scripts/backup-db.sh` | MySQL 数据库备份脚本,从 DATABASE_URL 解析连接信息,gzip 压缩备份,保留 30 天 | +| `scripts/restore-db.sh` | MySQL 数据库恢复脚本,从指定备份文件恢复 | +| `scripts/test-backup.sh` | 备份流程测试脚本,执行一次备份并验证 | + +### package.json 脚本 + +| 脚本 | 命令 | 说明 | +|------|------|------| +| `audit` | `npm audit --audit-level=moderate` | 依赖安全审计 | +| `audit:report` | `npm audit --json > audit-report.json` | 生成 JSON 审计报告 | +| `backup` | `bash scripts/backup-db.sh` | 执行数据库备份 | +| `restore` | `bash scripts/restore-db.sh` | 执行数据库恢复 | + +--- + +## E2E 测试 (`tests/e2e/`) + +| 测试文件 | 覆盖范围 | 依赖 | +|---------|---------|------| +| `smoke-auth.spec.ts` | 登录/注册页面控件渲染冒烟测试 | 无需 DB | +| `auth-business-flow.spec.ts` | 注册→登录→访问受保护区域完整流程 | DATABASE_URL | +| `full-route-regression.spec.ts` | 全路由清单完整性 + 公开/受保护路由守卫 | 无需 DB(守卫测试) | +| `auth.spec.ts` | 认证页面(登录/注册/隐私/协议)渲染 + 未认证重定向 | 无需 DB | +| `navigation.spec.ts` | admin/teacher/student 导航链接无 404 | DATABASE_URL + 测试账号 | +| `announcements.spec.ts` | 公告页面未认证重定向 + 登录后渲染 | 部分需 DATABASE_URL | +| `grades.spec.ts` | 成绩页面未认证重定向 + 登录后渲染 | 部分需 DATABASE_URL | + +### Playwright 配置 (`playwright.config.ts`) + +- `testDir`: `./tests/e2e` +- `baseURL`: `http://127.0.0.1:3000` +- `webServer`: 自动启动 `npm run dev`,端口 3000,超时 180s +- `webServer.env`: 注入 `SKIP_ENV_VALIDATION=1`、`NEXTAUTH_SECRET`、`NEXTAUTH_URL`、`DATABASE_URL`(测试库) +- `projects`: chromium(CI 通道为 undefined,本地为 chrome) +- `retries`: CI 2 次,本地 0 次 +- `workers`: CI 2 个,本地默认 diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 814c579..e5a8273 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -48,15 +48,32 @@ "USER_MANAGE": "user:manage", "AI_CHAT": "ai:chat", "AI_CONFIGURE": "ai:configure", - "SETTINGS_ADMIN": "settings:admin" + "SETTINGS_ADMIN": "settings:admin", + "AUDIT_LOG_READ": "audit_log:read", + "ANNOUNCEMENT_MANAGE": "announcement:manage", + "ANNOUNCEMENT_READ": "announcement:read", + "GRADE_RECORD_MANAGE": "grade_record:manage", + "GRADE_RECORD_READ": "grade_record:read", + "FILE_UPLOAD": "file:upload", + "FILE_READ": "file:read", + "FILE_DELETE": "file:delete", + "COURSE_PLAN_MANAGE": "course_plan:manage", + "COURSE_PLAN_READ": "course_plan:read", + "ATTENDANCE_MANAGE": "attendance:manage", + "ATTENDANCE_READ": "attendance:read", + "MESSAGE_SEND": "message:send", + "MESSAGE_READ": "message:read", + "MESSAGE_DELETE": "message:delete", + "SCHEDULE_AUTO": "schedule:auto", + "SCHEDULE_ADJUST": "schedule:adjust" }, "rolePermissions": { - "admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN"], - "teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT"], - "student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT"], - "parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ"], - "grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT"], - "teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT"] + "admin": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","TEXTBOOK_DELETE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_DELETE","CLASS_ENROLL","CLASS_SCHEDULE","SCHOOL_MANAGE","GRADE_MANAGE","USER_MANAGE","AI_CHAT","AI_CONFIGURE","SETTINGS_ADMIN","AUDIT_LOG_READ","ANNOUNCEMENT_MANAGE","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_MANAGE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE","SCHEDULE_AUTO","SCHEDULE_ADJUST"], + "teacher": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","CLASS_ENROLL","CLASS_SCHEDULE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_MANAGE","GRADE_RECORD_READ","FILE_UPLOAD","FILE_READ","FILE_DELETE","COURSE_PLAN_READ","ATTENDANCE_MANAGE","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], + "student": ["EXAM_READ","HOMEWORK_SUBMIT","QUESTION_READ","TEXTBOOK_READ","CLASS_READ","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_READ","MESSAGE_DELETE"], + "parent": ["EXAM_READ","TEXTBOOK_READ","CLASS_READ","ANNOUNCEMENT_READ","GRADE_RECORD_READ","FILE_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], + "grade_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_CREATE","CLASS_READ","CLASS_UPDATE","CLASS_ENROLL","CLASS_SCHEDULE","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"], + "teaching_head": ["EXAM_CREATE","EXAM_READ","EXAM_UPDATE","EXAM_DELETE","EXAM_DUPLICATE","EXAM_PUBLISH","EXAM_AI_GENERATE","HOMEWORK_CREATE","HOMEWORK_GRADE","QUESTION_CREATE","QUESTION_READ","QUESTION_UPDATE","QUESTION_DELETE","TEXTBOOK_CREATE","TEXTBOOK_READ","TEXTBOOK_UPDATE","CLASS_READ","GRADE_MANAGE","AI_CHAT","ANNOUNCEMENT_READ","GRADE_RECORD_READ","COURSE_PLAN_READ","ATTENDANCE_READ","MESSAGE_SEND","MESSAGE_READ","MESSAGE_DELETE"] }, "dataScopeTypes": { "all": "管理员:无过滤", @@ -186,6 +203,81 @@ "purpose": "合并多角色的权限列表(去重)", "deps": ["ROLE_PERMISSIONS"], "usedBy": ["auth.ts (JWT callback)"] + }, + { + "name": "logAudit", + "file": "lib/audit-logger.ts", + "signature": "logAudit(params: LogAuditParams): Promise", + "purpose": "记录操作日志(静默失败)", + "deps": ["auth", "shared.db", "shared.db.schema.auditLogs", "next/headers"], + "usedBy": ["school/actions.ts", "其他Server Actions"] + }, + { + "name": "logLoginEvent", + "file": "lib/login-logger.ts", + "signature": "logLoginEvent(params: LogLoginEventParams): Promise", + "purpose": "记录登录日志(signin/signout/signup,静默失败)", + "deps": ["shared.db", "shared.db.schema.loginLogs", "next/headers"], + "usedBy": ["auth.ts (events.signIn, events.signOut)"] + }, + { + "name": "isAllowedMimeType", + "file": "lib/file-storage.ts", + "signature": "isAllowedMimeType(mimeType: string): boolean", + "purpose": "判断MIME类型是否在允许上传的白名单中", + "deps": ["ALLOWED_MIME_TYPES 常量"], + "usedBy": ["app/api/upload/route.ts", "files/components/file-upload.tsx"] + }, + { + "name": "generateStoragePath", + "file": "lib/file-storage.ts", + "signature": "generateStoragePath(originalName: string): string", + "purpose": "根据原始文件名生成存储路径 uploads/YYYY-MM/cuid.ext(相对于public/)", + "deps": ["@paralleldrive/cuid2"], + "usedBy": ["app/api/upload/route.ts"] + }, + { + "name": "getFileExtension", + "file": "lib/file-storage.ts", + "signature": "getFileExtension(filename: string): string", + "purpose": "从文件名中提取小写扩展名(不含点)", + "deps": [], + "usedBy": ["shared/lib/file-storage 内部"] + }, + { + "name": "formatFileSize", + "file": "lib/file-storage.ts", + "signature": "formatFileSize(bytes: number): string", + "purpose": "将字节数格式化为人类可读字符串(如 1.5 MB、800 KB)", + "deps": [], + "usedBy": ["files/components/file-upload.tsx", "files/components/file-list.tsx", "files/components/file-preview.tsx"] + }, + { + "name": "exportToExcel", + "file": "lib/excel.ts", + "signature": "exportToExcel(params: { sheets: ExcelSheet[] }): Promise", + "params": {"sheets": "ExcelSheet[],每个含 name/columns/rows"}, + "purpose": "将多 sheet 数据导出为 Excel Buffer(表头加粗+冻结首行+自动筛选)", + "deps": ["exceljs"], + "usedBy": ["users/import-export.exportUsersToExcel", "grades/export.exportGradeRecordsToExcel", "grades/export.exportClassGradeReportToExcel"] + }, + { + "name": "parseExcel", + "file": "lib/excel.ts", + "signature": "parseExcel(buffer: Buffer): Promise", + "returns": "ParsedSheet[],每个含 sheetName/rows", + "purpose": "从 Buffer 解析 Excel 文件,首行作为表头,返回每 sheet 的行记录数组", + "deps": ["exceljs"], + "usedBy": ["users/actions.importUsersAction", "app/api/import/route.ts"] + }, + { + "name": "generateTemplate", + "file": "lib/excel.ts", + "signature": "generateTemplate(params: { sheets: TemplateSheet[] }): Promise", + "params": {"sheets": "TemplateSheet[],每个含 name/columns(含 note)/sampleRows?"}, + "purpose": "生成导入模板 Buffer(表头加粗+第二行填写说明+示例行)", + "deps": ["exceljs"], + "usedBy": ["users/import-export.generateUserImportTemplate"] } ], "hooks": [ @@ -219,66 +311,130 @@ "signature": "usePermission(): { permissions: Permission[]; roles: string[]; hasPermission: (p: Permission) => boolean; hasAnyPermission: (...p: Permission[]) => boolean; hasAllPermissions: (...p: Permission[]) => boolean; hasRole: (r: string) => boolean }", "purpose": "客户端权限检查Hook", "usedBy": ["layout/app-sidebar.tsx", "exams/components", "homework/components"] + }, + { + "name": "logDataChange", + "file": "lib/change-logger.ts", + "signature": "logDataChange(params: LogDataChangeParams): Promise", + "purpose": "记录数据变更日志(写入 dataChangeLogs 表,自动获取 changedBy/changedByName/ipAddress;静默失败)", + "deps": ["auth", "shared/db (dataChangeLogs)", "next/headers", "@paralleldrive/cuid2"], + "usedBy": ["待扩展(数据变更场景)"] + }, + { + "name": "StorageProvider", + "file": "lib/storage-provider.ts", + "signature": "interface StorageProvider { save(file: Buffer, storagePath: string): Promise; read(storagePath: string): Promise; delete(storagePath: string): Promise; exists(storagePath: string): Promise; getUrl(storagePath: string): string }", + "purpose": "文件存储抽象接口,便于未来切换到 OSS/S3", + "usedBy": ["app/api/files/batch-delete/route.ts"] + }, + { + "name": "LocalStorageProvider", + "file": "lib/storage-provider.ts", + "signature": "class LocalStorageProvider implements StorageProvider", + "purpose": "本地磁盘存储实现,文件持久化到 public/uploads/...,URL 为 /uploads/...", + "deps": ["fs/promises", "path"], + "usedBy": ["通过 storageProvider 实例使用"] + }, + { + "name": "storageProvider", + "file": "lib/storage-provider.ts", + "signature": "const storageProvider: StorageProvider", + "purpose": "默认存储 Provider 单例(LocalStorageProvider 实例),替换此实例可迁移到 OSS/S3", + "usedBy": ["app/api/files/batch-delete/route.ts"] } ], "components": [ {"name": "AuthSessionProvider", "file": "components/auth-session-provider.tsx", "purpose": "NextAuth SessionProvider包装", "usedBy": ["app/layout.tsx"]}, {"name": "OnboardingGate", "file": "components/onboarding-gate.tsx", "purpose": "新用户引导流程", "usedBy": ["app/layout.tsx"]}, {"name": "ThemeProvider", "file": "components/theme-provider.tsx", "purpose": "next-themes主题切换", "usedBy": ["app/layout.tsx"]}, - {"name": "EmptyState", "file": "components/empty-state.tsx", "purpose": "列表空状态展示", "usedBy": ["exams", "homework", "questions", "textbooks"]} + {"name": "EmptyState", "file": "components/ui/empty-state.tsx", "purpose": "列表空状态展示", "usedBy": ["exams", "homework", "questions", "textbooks"]}, + {"name": "GlobalSearch", "file": "components/global-search.tsx", "props": "{ className?, placeholder? }", "purpose": "全局搜索组件(防抖300ms调用 GET /api/search,Cmd/Ctrl+K快捷键聚焦,Escape关闭,↑/↓键盘导航,Enter跳转;下拉展示 question/textbook/exam/announcement 四类结果)", "internalDeps": ["useDebounce", "Input", "Link", "useRouter"], "usedBy": ["layout/components/site-header.tsx"]}, + {"name": "Switch", "file": "components/ui/switch.tsx", "basedOn": "@radix-ui/react-switch", "props": "Radix Switch Root props (checked, onCheckedChange, disabled, id, aria-label)", "purpose": "开关切换UI组件(shadcn风格,checked/unchecked两态)", "usedBy": ["settings/components/notification-preferences-form.tsx"]} + ], + "constants": [ + {"name": "ROLE_PERMISSIONS", "file": "lib/permissions.ts", "type": "const", "description": "角色-权限映射表", "usedBy": ["resolvePermissions", "auth.ts JWT callback"]}, + {"name": "Permissions", "file": "types/permissions.ts", "type": "const", "description": "38个权限点常量对象(含 FILE_UPLOAD/FILE_READ/FILE_DELETE)", "usedBy": ["auth-guard", "use-permission", "所有actions"]}, + {"name": "db", "file": "db/index.ts", "type": "const", "description": "Drizzle ORM 实例", "usedBy": ["所有业务模块"]}, + {"name": "questionTypeEnum", "file": "db/schema.ts", "type": "const", "description": "题目类型枚举", "usedBy": ["questions", "exams"]}, + {"name": "classEnrollmentStatusEnum", "file": "db/schema.ts", "type": "const", "description": "选课状态枚举", "usedBy": ["classes"]} + ], + "files": [ + {"path": "db/relations.ts", "description": "20+ 个 Drizzle relations 定义", "usedBy": ["所有业务模块的关联查询"]}, + {"path": "next-auth.d.ts", "description": "NextAuth Session/JWT 类型扩展声明", "usedBy": ["auth.ts", "useSession"]} ], "types": [ {"name": "ActionState", "file": "types/action-state.ts", "definition": "ActionState = { success: boolean; message?: string; errors?: Record; data?: T }", "usedBy": ["所有模块Server Action"]}, {"name": "Permission", "file": "types/permissions.ts", "definition": "Permission = (typeof Permissions)[keyof typeof Permissions]", "usedBy": ["auth-guard", "use-permission", "所有actions"]}, - {"name": "DataScope", "file": "types/permissions.ts", "definition": "DataScope = { type: 'all' } | { type: 'owned'; userId: string } | { type: 'class_taught'; classIds: string[]; subjectIds?: string[] } | { type: 'grade_managed'; gradeIds: string[] } | { type: 'class_members' } | { type: 'children'; childrenIds: string[] }", "usedBy": ["auth-guard", "exams/data-access", "homework/data-access", "dashboard/data-access"]}, + {"name": "DataScope", "file": "types/permissions.ts", "definition": "DataScope = { type: 'all' } | { type: 'owned'; userId: string } | { type: 'class_taught'; classIds: string[]; subjectIds?: string[] } | { type: 'grade_managed'; gradeIds: string[] } | { type: 'class_members' } | { type: 'children'; childrenIds: string[] }", "usedBy": ["auth-guard", "exams/data-access", "homework/data-access", "dashboard/data-access", "grades/data-access", "parent/children/[studentId]/page.tsx"]}, {"name": "AuthContext", "file": "types/permissions.ts", "definition": "AuthContext = { userId: string; roles: string[]; permissions: Permission[]; dataScope: DataScope }", "usedBy": ["auth-guard", "所有调用requirePermission的Server Action"]}, {"name": "PermissionDeniedError", "file": "lib/auth-guard.ts", "definition": "class PermissionDeniedError extends Error { constructor(permission: string) }", "usedBy": ["所有Server Action的try/catch"]} ] }, "dbTables": { - "users": {"fields": ["id","name","email","emailVerified","image","password","phone","address","gender","age","gradeId","departmentId","onboardedAt","createdAt","updatedAt"], "usedBy": ["auth","users","dashboard","classes"]}, - "accounts": {"fields": ["userId","type","provider","providerAccountId","refresh_token","access_token"], "usedBy": ["auth"]}, - "sessions": {"fields": ["id","sessionToken","userId","expires"], "usedBy": ["auth"]}, + "users": {"fields": ["id","name","email","emailVerified","image","password","phone","address","gender","age","birthDate","guardianName","guardianPhone","guardianRelation","consentAcceptedAt","gradeId","departmentId","onboardedAt","createdAt","updatedAt"], "usedBy": ["auth","users","dashboard","classes"]}, + "accounts": {"fields": ["userId","type","provider","providerAccountId","refresh_token","access_token","expires_at","token_type","scope","id_token","session_state"], "usedBy": ["auth"]}, + "sessions": {"fields": ["sessionToken","userId","expires"], "usedBy": ["auth"]}, "verificationTokens": {"fields": ["identifier","token","expires"], "usedBy": ["auth"]}, - "roles": {"fields": ["id","name"], "usedBy": ["auth","auth-guard"]}, + "roles": {"fields": ["id","name","description","createdAt","updatedAt"], "usedBy": ["auth","auth-guard"]}, "usersToRoles": {"fields": ["userId","roleId"], "usedBy": ["auth","auth-guard"]}, "rolePermissions": {"fields": ["roleId","permission"], "usedBy": ["auth (seed)"]}, "knowledgePoints": {"fields": ["id","name","description","anchorText","parentId","chapterId","level","order","createdAt","updatedAt"], "usedBy": ["textbooks","questions"]}, "questions": {"fields": ["id","content","type","difficulty","authorId","parentId","createdAt","updatedAt"], "usedBy": ["questions","exams","homework"]}, "questionsToKnowledgePoints": {"fields": ["questionId","knowledgePointId"], "usedBy": ["questions"]}, - "subjects": {"fields": ["id","name","order"], "usedBy": ["exams","textbooks"]}, + "subjects": {"fields": ["id","name","code","order","createdAt","updatedAt"], "usedBy": ["exams","textbooks"]}, "textbooks": {"fields": ["id","title","subject","grade","publisher","createdAt","updatedAt"], "usedBy": ["textbooks"]}, "chapters": {"fields": ["id","textbookId","title","order","parentId","content","createdAt","updatedAt"], "usedBy": ["textbooks"]}, "departments": {"fields": ["id","name","description","createdAt","updatedAt"], "usedBy": ["school"]}, - "classrooms": {"fields": ["id","location","capacity","createdAt","updatedAt"], "usedBy": ["school"]}, + "classrooms": {"fields": ["id","name","building","floor","capacity","createdAt","updatedAt"], "usedBy": ["school"]}, "academicYears": {"fields": ["id","name","startDate","endDate","isActive","createdAt","updatedAt"], "usedBy": ["school"]}, "schools": {"fields": ["id","name","code","createdAt","updatedAt"], "usedBy": ["school","classes"]}, "grades": {"fields": ["id","schoolId","name","order","gradeHeadId","teachingHeadId","createdAt","updatedAt"], "usedBy": ["school","classes","exams","auth-guard"]}, - "classes": {"fields": ["id","schoolId","gradeId","teacherId","name","homeroom","room","invitationCode","createdAt","updatedAt"], "usedBy": ["classes","homework","auth-guard"]}, - "classSubjectTeachers": {"fields": ["classId","teacherId","subject"], "usedBy": ["classes","auth-guard"]}, - "classEnrollments": {"fields": ["classId","studentId","status","joinedAt"], "usedBy": ["classes","homework"]}, - "classSchedule": {"fields": ["id","classId","weekday","startTime","endTime","course","location"], "usedBy": ["classes"]}, - "exams": {"fields": ["id","creatorId","title","description","subjectId","gradeId","status","difficulty","totalScore","durationMin","scheduledAt","structure","createdAt","updatedAt"], "usedBy": ["exams","homework"]}, - "examQuestions": {"fields": ["examId","questionId"], "usedBy": ["exams"]}, - "examSubmissions": {"fields": ["id","examId","studentId","score","submittedAt"], "usedBy": ["exams"]}, - "submissionAnswers": {"fields": ["id","submissionId","questionId","answer","score","feedback"], "usedBy": ["exams"]}, - "homeworkAssignments": {"fields": ["id","creatorId","sourceExamId","title","description","status","availableAt","dueAt","allowLate","lateDueAt","maxAttempts","createdAt","updatedAt"], "usedBy": ["homework"]}, - "homeworkAssignmentQuestions": {"fields": ["assignmentId","questionId"], "usedBy": ["homework"]}, - "homeworkAssignmentTargets": {"fields": ["assignmentId","studentId"], "usedBy": ["homework"]}, - "homeworkSubmissions": {"fields": ["id","assignmentId","studentId","status","attemptNo","score","submittedAt"], "usedBy": ["homework"]}, - "homeworkAnswers": {"fields": ["id","submissionId","questionId","answer","score","feedback"], "usedBy": ["homework"]}, - "aiProviders": {"fields": ["id","provider","baseUrl","model","apiKeyEncrypted","isDefault","updatedAt"], "usedBy": ["settings","ai"]} + "classes": {"fields": ["id","schoolId","gradeId","teacherId","name","homeroom","room","invitationCode","schoolName","grade","createdAt","updatedAt"], "usedBy": ["classes","homework","auth-guard"]}, + "classSubjectTeachers": {"fields": ["classId","teacherId","subjectId","createdAt","updatedAt"], "usedBy": ["classes","auth-guard"]}, + "classEnrollments": {"fields": ["classId","studentId","status","createdAt"], "usedBy": ["classes","homework"]}, + "classSchedule": {"fields": ["id","classId","weekday","startTime","endTime","course","location","createdAt","updatedAt"], "usedBy": ["classes"]}, + "exams": {"fields": ["id","creatorId","title","description","subjectId","gradeId","status","structure","startTime","endTime","createdAt","updatedAt"], "usedBy": ["exams","homework"]}, + "examQuestions": {"fields": ["examId","questionId","score","order"], "usedBy": ["exams"]}, + "examSubmissions": {"fields": ["id","examId","studentId","score","submittedAt","status","createdAt","updatedAt"], "usedBy": ["exams"]}, + "submissionAnswers": {"fields": ["id","submissionId","questionId","answerContent","score","feedback","createdAt","updatedAt"], "usedBy": ["exams"]}, + "homeworkAssignments": {"fields": ["id","creatorId","sourceExamId","title","description","status","availableAt","dueAt","allowLate","lateDueAt","maxAttempts","structure","createdAt","updatedAt"], "usedBy": ["homework"]}, + "homeworkAssignmentQuestions": {"fields": ["assignmentId","questionId","score","order"], "usedBy": ["homework"]}, + "homeworkAssignmentTargets": {"fields": ["assignmentId","studentId","createdAt"], "usedBy": ["homework"]}, + "homeworkSubmissions": {"fields": ["id","assignmentId","studentId","status","attemptNo","score","submittedAt","startedAt","isLate","createdAt","updatedAt"], "usedBy": ["homework"]}, + "homeworkAnswers": {"fields": ["id","submissionId","questionId","answerContent","score","feedback","createdAt","updatedAt"], "usedBy": ["homework"]}, + "aiProviders": {"fields": ["id","provider","baseUrl","model","apiKeyEncrypted","apiKeyLast4","isDefault","createdBy","updatedBy","createdAt","updatedAt"], "usedBy": ["settings","ai"]}, + "auditLogs": {"fields": ["id","userId","userName","action","module","targetId","targetType","detail","ipAddress","userAgent","status","createdAt"], "usedBy": ["audit","shared/lib/audit-logger"]}, + "loginLogs": {"fields": ["id","userId","userEmail","action","status","ipAddress","userAgent","errorMessage","createdAt"], "usedBy": ["audit","shared/lib/login-logger","auth"]}, + "dataChangeLogs": {"fields": ["id","tableName","recordId","action","oldValue","newValue","changedBy","changedByName","ipAddress","createdAt"], "usedBy": ["audit","shared/lib/change-logger"]}, + "announcements": {"fields": ["id","title","content","type","status","targetGradeId","targetClassId","authorId","publishedAt","createdAt","updatedAt"], "usedBy": ["announcements"]}, + "fileAttachments": {"fields": ["id","filename","originalName","mimeType","size","storagePath","url","uploaderId","targetType","targetId","createdAt"], "usedBy": ["files"]}, + "gradeRecords": {"fields": ["id","studentId","classId","subjectId","examId","academicYearId","title","score","fullScore","type","semester","recordedBy","remark","createdAt","updatedAt"], "usedBy": ["grades"]}, + "coursePlans": {"fields": ["id","classId","subjectId","teacherId","academicYearId","semester","totalHours","completedHours","weeklyHours","startDate","endDate","syllabus","objectives","status","createdBy","createdAt","updatedAt"], "usedBy": ["course-plans"]}, + "coursePlanItems": {"fields": ["id","planId","week","topic","content","hours","textbookChapter","notes","isCompleted","completedAt","createdAt","updatedAt"], "usedBy": ["course-plans"]}, + "parentStudentRelations": {"fields": ["id","parentId","studentId","relation","createdAt"], "usedBy": ["parent","auth-guard"]}, + "messages": {"fields": ["id","senderId","receiverId","subject","content","isRead","readAt","parentMessageId","createdAt"], "usedBy": ["messaging"]}, + "messageNotifications": {"fields": ["id","userId","type","title","content","link","isRead","createdAt"], "usedBy": ["messaging"]}, + "notificationPreferences": {"fields": ["id","userId","emailEnabled","smsEnabled","pushEnabled","homeworkNotifications","gradeNotifications","announcementNotifications","messageNotifications","attendanceNotifications","createdAt","updatedAt"], "usedBy": ["messaging","settings"]}, + "attendanceRecords": {"fields": ["id","studentId","classId","scheduleId","date","status","remark","recordedBy","createdAt","updatedAt"], "usedBy": ["attendance"]}, + "attendanceRules": {"fields": ["id","classId","lateThresholdMinutes","earlyLeaveThresholdMinutes","enableAutoMark","createdAt","updatedAt"], "usedBy": ["attendance"]}, + "schedulingRules": {"fields": ["id","classId","maxDailyHours","maxContinuousHours","lunchBreakStart","lunchBreakEnd","morningStart","afternoonEnd","avoidBackToBack","balancedSubjects","createdAt","updatedAt"], "usedBy": ["scheduling"]}, + "scheduleChanges": {"fields": ["id","originalScheduleId","classId","originalTeacherId","substituteTeacherId","originalDate","newDate","newStartTime","newEndTime","reason","status","requestedBy","approvedBy","createdAt","updatedAt"], "usedBy": ["scheduling"]}, + "passwordSecurity": {"fields": ["id","userId","failedLoginAttempts","lockedUntil","passwordChangedAt","mustChangePassword","lastPasswordChange","createdAt","updatedAt"], "usedBy": ["auth","settings"]} } }, "auth": { "path": "src/auth.ts", - "description": "用户认证:NextAuth配置、JWT/Session callbacks、middleware", + "description": "用户认证:NextAuth配置、JWT/Session callbacks、events回调(登录日志)、middleware。集成密码安全策略(账户锁定、失败登录追踪)和登录速率限制", "exports": { "functions": [ - {"name": "auth", "signature": "auth(): Promise", "purpose": "获取当前用户Session", "usedBy": ["auth-guard.ts", "所有Server Component页面"]}, + {"name": "auth", "signature": "auth(): Promise", "purpose": "获取当前用户Session", "usedBy": ["auth-guard.ts", "所有Server Component页面", "audit-logger.ts"]}, {"name": "handlers", "signature": "{ GET, POST }", "purpose": "NextAuth Route Handler", "usedBy": ["app/api/auth/[...nextauth]/route.ts"]}, {"name": "signIn", "signature": "signIn(provider, options?)", "purpose": "登录", "usedBy": ["login-form.tsx"]}, {"name": "signOut", "signature": "signOut(options?)", "purpose": "登出", "usedBy": ["site-header.tsx"]} + ], + "events": [ + {"name": "signIn", "signature": "async signIn({ user }) => void", "purpose": "用户登录成功后记录登录日志", "deps": ["shared/lib/login-logger.logLoginEvent"], "params": "{ userId: user.id, userEmail: user.email, action: 'signin', status: 'success' }"}, + {"name": "signOut", "signature": "async signOut(message) => void", "purpose": "用户登出后记录登录日志(处理NextAuth v5不同message形状)", "deps": ["shared/lib/login-logger.logLoginEvent"], "params": "{ userId?, userEmail, action: 'signout', status: 'success' }"} ] }, "middleware": { @@ -309,7 +465,7 @@ {"name": "updateExamAction", "permission": "EXAM_UPDATE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新考试(含资源归属校验)", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]}, {"name": "deleteExamAction", "permission": "EXAM_DELETE", "signature": "同上", "purpose": "删除考试(含资源归属校验)", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-actions.tsx"]}, {"name": "duplicateExamAction", "permission": "EXAM_DUPLICATE", "signature": "同上", "purpose": "复制考试", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-actions.tsx"]}, - {"name": "getExamPreviewAction", "permission": "EXAM_READ", "signature": "同上", "purpose": "获取考试预览数据", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-viewer.tsx"]}, + {"name": "getExamPreviewAction", "permission": "EXAM_READ", "signature": "(examId: string) => Promise }>>", "purpose": "获取考试预览数据", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-viewer.tsx"]}, {"name": "getSubjectsAction", "permission": "EXAM_READ", "signature": "() => Promise>", "purpose": "获取科目列表", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]}, {"name": "getGradesAction", "permission": "EXAM_READ", "signature": "同上", "purpose": "获取年级列表", "deps": ["requirePermission","shared/db"], "usedBy": ["exam-form.tsx"]} ], @@ -317,17 +473,74 @@ {"name": "getExams", "signature": "getExams(params: GetExamsParams & { scope: DataScope }): Promise", "purpose": "查询考试列表(含数据权限过滤)", "usedBy": ["teacher/exams/all/page.tsx", "homework创建页面"]}, {"name": "getExamById", "signature": "getExamById(id: string, scope?: DataScope): Promise", "purpose": "按ID获取考试详情", "usedBy": ["exam详情/编辑页面"]}, {"name": "persistExamDraft", "signature": "persistExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description }): Promise", "purpose": "持久化手动考试草稿", "usedBy": ["createExamAction"]}, - {"name": "persistAiGeneratedExamDraft", "signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise", "purpose": "持久化AI生成考试草稿", "usedBy": ["createAiExamAction"]} + {"name": "persistAiGeneratedExamDraft", "signature": "persistAiGeneratedExamDraft(input: { examId, title, creatorId, subjectId, gradeId, scheduledAt?, description, structure, generated }): Promise", "purpose": "持久化AI生成考试草稿", "usedBy": ["createAiExamAction"]}, + {"name": "omitScheduledAtFromDescription", "signature": "(description: string | null) => string", "purpose": "从描述中移除scheduledAt信息", "usedBy": ["exams/data-access内部"]}, + {"name": "resolveSubjectGradeNames", "signature": "(input: { subjectId?, gradeId? }) => Promise<{ subjectName?, gradeName? }>", "purpose": "解析科目与年级名称", "usedBy": ["exams/data-access内部"]}, + {"name": "buildExamDescription", "signature": "(input: { subject, grade, difficulty, totalScore, durationMin, scheduledAt?, questionCount? }) => string", "purpose": "构建考试描述文本", "usedBy": ["exams/data-access内部"]}, + {"name": "GetExamsParams", "type": "type", "definition": "{ q?, status?, difficulty?, page?, pageSize? }", "usedBy": ["getExams", "getExamsAction"]} ], "aiPipeline": [ {"name": "generateAiPreviewData", "signature": "(input: { title, subject?, grade?, difficulty, totalScore, durationMin, questionCount?, sourceText, aiProviderId? }) => Promise<{ ok, data?, rawOutput?, message? }>", "purpose": "AI预览生成", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["previewAiExamAction"]}, {"name": "generateAiCreateDraftFromSource", "signature": "同上", "purpose": "AI从源文本生成完整考试", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["createAiExamAction"]}, - {"name": "regenerateAiQuestionByInstruction", "signature": "(input: { instruction, originalQuestion, sourceText?, aiProviderId? }) => Promise<{ ok, data?, message? }>", "purpose": "AI按指令重写题目", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["regenerateAiQuestionAction"]} + {"name": "regenerateAiQuestionByInstruction", "signature": "(input: { instruction, originalQuestion, sourceText?, aiProviderId? }) => Promise<{ ok, data?, message? }>", "purpose": "AI按指令重写题目", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["regenerateAiQuestionAction"]}, + {"name": "AiQuestionSchema", "type": "const", "description": "zod schema 校验AI生成题目", "usedBy": ["ai-pipeline内部"]}, + {"name": "AiInsertQuestionSchema", "type": "const", "description": "zod schema 校验AI插入题目", "usedBy": ["ai-pipeline内部"]}, + {"name": "AiGeneratedQuestion", "type": "type", "definition": "{ id, type, difficulty, score, content }", "usedBy": ["ai-pipeline内部", "exams/components"]}, + {"name": "AiGeneratedStructureNode", "type": "type", "definition": "{ id, type: \"group\"|\"question\", title?, questionId?, score?, children? }", "usedBy": ["ai-pipeline内部", "exams/components"]}, + {"name": "AiGeneratedStructureNodeSchema", "type": "const", "description": "zod schema 校验AI生成结构节点(递归)", "usedBy": ["ai-pipeline内部"]}, + {"name": "AiGeneratedStructureSchema", "type": "const", "description": "zod schema 校验AI生成结构", "usedBy": ["ai-pipeline内部"]}, + {"name": "generateAiExamDraft", "type": "function", "signature": "(input) => Promise", "purpose": "生成AI考试草稿", "deps": ["shared/lib/ai.createAiChatCompletion"], "usedBy": ["ai-pipeline内部"]} ], "types": [ {"name": "Exam", "definition": "{ id, title, subject, grade, status, difficulty, totalScore, durationMin, questionCount, scheduledAt?, createdAt, updatedAt?, tags? }", "usedBy": ["exams/components", "homework/types", "dashboard/types"]}, {"name": "AiPreviewData", "definition": "{ title, rawOutput?, sections?, questions? }", "usedBy": ["exams/actions", "exams/components"]}, - {"name": "AiRewriteQuestionData", "definition": "{ type, difficulty, score, content }", "usedBy": ["exams/actions", "exams/components"]} + {"name": "AiRewriteQuestionData", "definition": "{ type, difficulty, score, content }", "usedBy": ["exams/actions", "exams/components"]}, + {"name": "ExamStatus", "type": "type", "definition": "\"draft\" | \"published\" | \"archived\"", "usedBy": ["exams/components", "exams/data-access"]}, + {"name": "ExamDifficulty", "type": "type", "definition": "1 | 2 | 3 | 4 | 5", "usedBy": ["exams/components", "exams/data-access"]}, + {"name": "SubmissionStatus", "type": "type", "definition": "\"pending\" | \"graded\"", "usedBy": ["exams/components", "exams/data-access"]}, + {"name": "ExamSubmission", "type": "interface", "definition": "{ id, examId, examTitle, studentName, submittedAt, score?, status }", "usedBy": ["exams/components"]} + ], + "components": [ + {"name": "ExamPaperPreview", "file": "assembly/exam-paper-preview.tsx", "purpose": "试卷预览"}, + {"name": "QuestionBankList", "file": "assembly/question-bank-list.tsx", "purpose": "题库列表(组卷选择)"}, + {"name": "SelectedQuestionList", "file": "assembly/selected-question-list.tsx", "purpose": "已选题目列表", "types": ["ExamNode"]}, + {"name": "StructureEditor", "file": "assembly/structure-editor.tsx", "purpose": "试卷结构编辑器"}, + {"name": "ExamActions", "file": "exam-actions.tsx", "purpose": "考试操作按钮(删除/复制等)"}, + {"name": "ExamAiGenerator", "file": "exam-ai-generator.tsx", "purpose": "AI生成考试面板"}, + {"name": "ExamAssembly", "file": "exam-assembly.tsx", "purpose": "组卷主界面"}, + {"name": "ExamBasicInfoForm", "file": "exam-basic-info-form.tsx", "purpose": "考试基本信息表单"}, + {"name": "ExamCard", "file": "exam-card.tsx", "purpose": "考试卡片"}, + {"name": "examColumns", "file": "exam-columns.tsx", "type": "ColumnDef[]", "purpose": "考试表格列定义"}, + {"name": "ExamDataTable", "file": "exam-data-table.tsx", "purpose": "考试数据表格"}, + {"name": "ExamFilters", "file": "exam-filters.tsx", "purpose": "考试筛选器"}, + {"name": "formSchema", "file": "exam-form-types.ts", "type": "const", "purpose": "表单zod schema"}, + {"name": "ExamFormValues", "file": "exam-form-types.ts", "type": "type", "purpose": "表单值类型"}, + {"name": "PreviewQuestion", "file": "exam-form-types.ts", "type": "type", "purpose": "预览题目类型"}, + {"name": "EditableQuestionContent", "file": "exam-form-types.ts", "type": "type", "purpose": "可编辑题目内容"}, + {"name": "PreviewSnapshotMeta", "file": "exam-form-types.ts", "type": "type", "purpose": "预览快照元数据"}, + {"name": "PreviewBackgroundTask", "file": "exam-form-types.ts", "type": "type", "purpose": "预览后台任务"}, + {"name": "aiProviderLabels", "file": "exam-form-types.ts", "type": "const", "purpose": "AI Provider标签映射"}, + {"name": "defaultValues", "file": "exam-form-types.ts", "type": "const", "purpose": "表单默认值"}, + {"name": "previewTaskStorageKey", "file": "exam-form-types.ts", "type": "const", "purpose": "预览任务localStorage key"}, + {"name": "ExamForm", "file": "exam-form.tsx", "purpose": "考试表单(创建/编辑)"}, + {"name": "ExamGrid", "file": "exam-grid.tsx", "purpose": "考试网格视图"}, + {"name": "ExamModeSelector", "file": "exam-mode-selector.tsx", "purpose": "考试模式选择(手动/AI)"}, + {"name": "ExamPreviewDialog", "file": "exam-preview-dialog.tsx", "purpose": "考试预览对话框"}, + {"name": "ExamPreviewQuestionEditor", "file": "exam-preview-question-editor.tsx", "purpose": "预览题目编辑器"}, + {"name": "buildPreviewNodes", "file": "exam-preview-utils.ts", "type": "function", "purpose": "构建预览节点"}, + {"name": "parseEditableContent", "file": "exam-preview-utils.ts", "type": "function", "purpose": "解析可编辑内容"}, + {"name": "flattenPreviewQuestions", "file": "exam-preview-utils.ts", "type": "function", "purpose": "扁平化预览题目"}, + {"name": "findPreviewQuestionNode", "file": "exam-preview-utils.ts", "type": "function", "purpose": "查找预览题目节点"}, + {"name": "updatePreviewQuestionNodeInList", "file": "exam-preview-utils.ts", "type": "function", "purpose": "更新预览题目节点"}, + {"name": "buildPreviewSignature", "file": "exam-preview-utils.ts", "type": "function", "purpose": "构建预览签名"}, + {"name": "buildPreviewPayload", "file": "exam-preview-utils.ts", "type": "function", "purpose": "构建预览payload"}, + {"name": "buildPreviewRequestData", "file": "exam-preview-utils.ts", "type": "function", "purpose": "构建预览请求数据"}, + {"name": "ExamViewer", "file": "exam-viewer.tsx", "purpose": "考试查看器"}, + {"name": "QuestionOptionsEditor", "file": "question-options-editor.tsx", "purpose": "题目选项编辑器"}, + {"name": "QuestionSubQuestionsEditor", "file": "question-sub-questions-editor.tsx", "purpose": "子题目编辑器"} + ], + "hooks": [ + {"name": "useExamPreview", "file": "use-exam-preview.ts", "signature": "(form: UseFormReturn) => { previewOpen, setPreviewOpen, previewLoading, previewNodes, handlePreview, handleBackgroundPreview, handleOpenPreviewTask, handleRewriteSelectedQuestion }", "purpose": "考试预览Hook", "usedBy": ["exam-form.tsx"]} ] } }, @@ -349,12 +562,50 @@ {"name": "getHomeworkSubmissions", "signature": "(params?: { assignmentId?, classId?, creatorId?, scope? }) => Promise", "usedBy": ["teacher提交列表"]}, {"name": "getStudentHomeworkAssignments", "signature": "(studentId: string) => Promise", "usedBy": ["student/dashboard"]}, {"name": "getStudentDashboardGrades", "signature": "(studentId: string) => Promise", "usedBy": ["dashboard/data-access.ts"]}, - {"name": "getHomeworkAssignmentAnalytics", "signature": "(assignmentId: string) => Promise", "usedBy": ["homework错误分析组件"]} + {"name": "getHomeworkAssignmentAnalytics", "signature": "(assignmentId: string) => Promise", "usedBy": ["homework错误分析组件"]}, + {"name": "getHomeworkAssignmentById", "signature": "(id: string, scope?: DataScope) => Promise<...>", "purpose": "按ID获取作业详情", "usedBy": ["homework详情页"]}, + {"name": "getHomeworkSubmissionDetails", "signature": "(submissionId: string) => Promise", "purpose": "获取提交详情(含答案)", "usedBy": ["homework-grading-view.tsx"]}, + {"name": "getDemoStudentUser", "signature": "() => Promise<{ id: string; name: string } | null>", "purpose": "获取演示学生用户", "usedBy": ["homework内部"]}, + {"name": "getStudentHomeworkTakeData", "signature": "(assignmentId: string, studentId: string) => Promise", "purpose": "获取学生作答数据", "usedBy": ["homework-take-view.tsx"]} + ], + "schema": [ + {"name": "CreateHomeworkAssignmentSchema", "type": "const", "description": "zod schema 创建作业", "usedBy": ["createHomeworkAssignmentAction", "homework-assignment-form.tsx"]}, + {"name": "CreateHomeworkAssignmentInput", "type": "type", "definition": "z.infer", "usedBy": ["createHomeworkAssignmentAction"]}, + {"name": "GradeHomeworkSchema", "type": "const", "description": "zod schema 批改作业", "usedBy": ["gradeHomeworkSubmissionAction", "homework-grading-view.tsx"]} ], "types": [ {"name": "StudentDashboardGradeProps", "definition": "{ trend, recent, ranking }", "usedBy": ["dashboard/types.ts"]}, {"name": "HomeworkAssignmentListItem", "usedBy": ["homework列表页"]}, - {"name": "StudentHomeworkTakeData", "usedBy": ["homework-take-view.tsx"]} + {"name": "StudentHomeworkTakeData", "usedBy": ["homework-take-view.tsx"]}, + {"name": "HomeworkAssignmentStatus", "type": "type", "definition": "作业状态枚举", "usedBy": ["homework/components", "homework/data-access"]}, + {"name": "HomeworkSubmissionStatus", "type": "type", "definition": "提交状态枚举", "usedBy": ["homework/components", "homework/data-access"]}, + {"name": "TeacherGradeTrendItem", "type": "type", "definition": "教师年级趋势项", "usedBy": ["dashboard (教师仪表盘)"]}, + {"name": "HomeworkAssignmentReviewListItem", "type": "type", "definition": "批改列表项", "usedBy": ["teacher批改列表"]}, + {"name": "HomeworkSubmissionListItem", "type": "type", "definition": "提交列表项", "usedBy": ["teacher提交列表"]}, + {"name": "HomeworkQuestionContent", "type": "type", "definition": "作业题目内容", "usedBy": ["homework/components"]}, + {"name": "HomeworkSubmissionAnswerDetails", "type": "type", "definition": "提交答案详情", "usedBy": ["homework-grading-view.tsx"]}, + {"name": "HomeworkSubmissionDetails", "type": "type", "definition": "提交详情(含答案列表)", "usedBy": ["homework-grading-view.tsx"]}, + {"name": "StudentHomeworkProgressStatus", "type": "type", "definition": "学生作业进度状态", "usedBy": ["student/dashboard"]}, + {"name": "StudentHomeworkAssignmentListItem", "type": "type", "definition": "学生作业列表项", "usedBy": ["student/dashboard"]}, + {"name": "StudentHomeworkPerformanceItem", "type": "type", "definition": "学生表现项", "usedBy": ["student/dashboard"]}, + {"name": "StudentHomeworkPerformanceSummary", "type": "type", "definition": "学生表现汇总", "usedBy": ["student/dashboard"]}, + {"name": "StudentHomeworkTakeQuestion", "type": "type", "definition": "学生作答题目", "usedBy": ["homework-take-view.tsx"]}, + {"name": "HomeworkAssignmentQuestionAnalytics", "type": "type", "definition": "作业题目分析", "usedBy": ["homework错误分析组件"]}, + {"name": "HomeworkAssignmentAnalytics", "type": "type", "definition": "作业整体分析", "usedBy": ["homework错误分析组件"]}, + {"name": "StudentHomeworkScoreAnalytics", "type": "type", "definition": "学生成绩分析", "usedBy": ["student/dashboard"]}, + {"name": "StudentRanking", "type": "type", "definition": "学生排名", "usedBy": ["student/dashboard"]} + ], + "components": [ + {"name": "HomeworkAssignmentExamContentCard", "file": "homework-assignment-exam-content-card.tsx", "purpose": "作业考试内容卡片"}, + {"name": "HomeworkAssignmentExamErrorExplorerLazy", "file": "homework-assignment-exam-error-explorer-lazy.tsx", "purpose": "作业错误分析(懒加载)"}, + {"name": "HomeworkAssignmentExamErrorExplorer", "file": "homework-assignment-exam-error-explorer.tsx", "purpose": "作业错误分析探索器"}, + {"name": "HomeworkAssignmentExamPreviewPane", "file": "homework-assignment-exam-preview-pane.tsx", "purpose": "作业考试预览面板"}, + {"name": "HomeworkAssignmentForm", "file": "homework-assignment-form.tsx", "purpose": "作业创建表单"}, + {"name": "HomeworkAssignmentQuestionErrorDetailPanel", "file": "homework-assignment-question-error-detail-panel.tsx", "purpose": "题目错误详情面板"}, + {"name": "HomeworkAssignmentQuestionErrorOverviewCard", "file": "homework-assignment-question-error-overview-card.tsx", "purpose": "题目错误概览卡片"}, + {"name": "HomeworkGradingView", "file": "homework-grading-view.tsx", "purpose": "作业批改视图"}, + {"name": "HomeworkTakeView", "file": "homework-take-view.tsx", "purpose": "学生作答视图"}, + {"name": "HomeworkReviewView", "file": "student-homework-review-view.tsx", "purpose": "学生作业复习视图"} ] } }, @@ -369,9 +620,28 @@ {"name": "getQuestionsAction", "permission": "QUESTION_READ", "signature": "(params: GetQuestionsParams) => Promise<...>", "purpose": "查询题目列表", "deps": ["requirePermission","data-access.getQuestions"], "usedBy": ["teacher/questions/page.tsx"]}, {"name": "getKnowledgePointOptionsAction", "permission": "QUESTION_READ", "signature": "() => Promise", "purpose": "获取知识点选项", "deps": ["requirePermission","shared/db"], "usedBy": ["create-question-dialog.tsx"]} ], + "dataAccess": [ + {"name": "getQuestions", "signature": "(params?: GetQuestionsParams) => Promise<{ data: Question[], meta: { page, pageSize, total, totalPages } }>", "type": "cache function", "purpose": "查询题目列表(缓存)", "usedBy": ["getQuestionsAction", "teacher/questions/page.tsx"]}, + {"name": "GetQuestionsParams", "type": "type", "definition": "{ q?, page?, pageSize?, ids?, knowledgePointId?, type?, difficulty? }", "usedBy": ["getQuestions", "getQuestionsAction"]} + ], + "schema": [ + {"name": "QuestionTypeEnum", "type": "const", "description": "zod enum: z.enum([\"single_choice\", \"multiple_choice\", \"text\", \"judgment\", \"composite\"])", "usedBy": ["CreateQuestionSchema", "questions/components"]}, + {"name": "BaseQuestionSchema", "type": "const", "description": "zod schema 基础题目校验", "usedBy": ["CreateQuestionSchema"]}, + {"name": "CreateQuestionInput", "type": "type", "definition": "z.infer", "usedBy": ["createNestedQuestion", "create-question-dialog.tsx"]}, + {"name": "CreateQuestionSchema", "type": "const", "description": "zod schema 创建题目(递归支持嵌套)", "usedBy": ["createNestedQuestion", "create-question-dialog.tsx"]} + ], "types": [ {"name": "Question", "definition": "{ id, content, type, difficulty, createdAt, updatedAt, author, knowledgePoints, childrenCount? }", "usedBy": ["exams (题目选择)", "homework (作业题目)"]}, - {"name": "KnowledgePointOption", "definition": "{ id, name, chapterId, chapterTitle, textbookId, textbookTitle, subject, grade }", "usedBy": ["create-question-dialog.tsx"]} + {"name": "KnowledgePointOption", "definition": "{ id, name, chapterId, chapterTitle, textbookId, textbookTitle, subject, grade }", "usedBy": ["create-question-dialog.tsx"]}, + {"name": "QuestionType", "type": "type", "definition": "z.infer", "usedBy": ["questions/components", "exams/components"]} + ], + "components": [ + {"name": "CreateQuestionButton", "file": "create-question-button.tsx", "purpose": "创建题目按钮"}, + {"name": "CreateQuestionDialog", "file": "create-question-dialog.tsx", "purpose": "创建题目对话框"}, + {"name": "QuestionActions", "file": "question-actions.tsx", "purpose": "题目操作按钮"}, + {"name": "columns", "file": "question-columns.tsx", "type": "ColumnDef[]", "purpose": "题目表格列定义"}, + {"name": "QuestionDataTable", "file": "question-data-table.tsx", "purpose": "题目数据表格"}, + {"name": "QuestionFilters", "file": "question-filters.tsx", "purpose": "题目筛选器"} ] } }, @@ -396,15 +666,47 @@ {"name": "getTextbookById", "signature": "(id) => Promise", "usedBy": ["teacher/textbooks/[id]/page.tsx"]}, {"name": "getChaptersByTextbookId", "signature": "(textbookId) => Promise", "usedBy": ["textbook-reader.tsx"]}, {"name": "getKnowledgePointsByChapterId", "signature": "(chapterId) => Promise", "usedBy": ["textbook-reader.tsx"]}, - {"name": "getKnowledgePointsByTextbookId", "signature": "(textbookId) => Promise", "usedBy": ["textbook-reader.tsx"]} + {"name": "getKnowledgePointsByTextbookId", "signature": "(textbookId) => Promise", "usedBy": ["textbook-reader.tsx"]}, + {"name": "createTextbook", "signature": "(input: CreateTextbookInput) => Promise", "purpose": "创建教材", "usedBy": ["createTextbookAction"]}, + {"name": "updateTextbook", "signature": "(id: string, input: UpdateTextbookInput) => Promise", "purpose": "更新教材", "usedBy": ["updateTextbookAction"]}, + {"name": "deleteTextbook", "signature": "(id: string) => Promise", "purpose": "删除教材", "usedBy": ["deleteTextbookAction"]}, + {"name": "createChapter", "signature": "(input: CreateChapterInput) => Promise", "purpose": "创建章节", "usedBy": ["createChapterAction"]}, + {"name": "updateChapterContent", "signature": "(input: UpdateChapterContentInput) => Promise", "purpose": "更新章节内容", "usedBy": ["updateChapterContentAction"]}, + {"name": "deleteChapter", "signature": "(chapterId: string, textbookId: string) => Promise", "purpose": "删除章节", "usedBy": ["deleteChapterAction"]}, + {"name": "createKnowledgePoint", "signature": "(input: CreateKnowledgePointInput) => Promise", "purpose": "创建知识点", "usedBy": ["createKnowledgePointAction"]}, + {"name": "updateKnowledgePoint", "signature": "(input: UpdateKnowledgePointInput) => Promise", "purpose": "更新知识点", "usedBy": ["updateKnowledgePointAction"]}, + {"name": "deleteKnowledgePoint", "signature": "(kpId: string, textbookId: string) => Promise", "purpose": "删除知识点", "usedBy": ["deleteKnowledgePointAction"]}, + {"name": "reorderChapters", "signature": "(chapterId: string, newIndex: number, parentId: string | null, textbookId: string) => Promise", "purpose": "章节排序", "usedBy": ["reorderChaptersAction"]} ], "hooks": [ - {"name": "useTextSelection", "file": "hooks/use-text-selection.ts", "signature": "(contentRef, onCreateKP) => { selectedText, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }", "usedBy": ["textbook-content-panel.tsx"]}, - {"name": "useKnowledgePointActions", "file": "hooks/use-knowledge-point-actions.ts", "signature": "(textbookId, selectedChapterId, highlightedKpId, setHighlightedKpId) => { editingKp, editKpDialogOpen, ..., requestDeleteKP, confirmDeleteKP, handleUpdateKP }", "usedBy": ["textbook-reader.tsx"]} + {"name": "useTextSelection", "file": "hooks/use-text-selection.ts", "signature": "() => { selectedText, setSelectedText, selectionRef, contentRef, setCreateDialogOpen, setIsCreating, createDialogOpen, isCreating, handleContentPointerDown, handleContextMenuChange }", "purpose": "文本选区Hook(无参数)", "usedBy": ["textbook-content-panel.tsx"]}, + {"name": "useKnowledgePointActions", "file": "hooks/use-knowledge-point-actions.ts", "signature": "(textbookId, selectedChapterId, selectedChapterTextbookId, highlightedKpId, setHighlightedKpId, onKpCreated?) => { editingKp, setEditingKp, editKpDialogOpen, setEditKpDialogOpen, isUpdatingKp, questionDialogOpen, setQuestionDialogOpen, targetKpForQuestion, setTargetKpForQuestion, deleteConfirmOpen, setDeleteConfirmOpen, handleCreateKnowledgePoint, requestDeleteKP, confirmDeleteKP, handleUpdateKP }", "purpose": "知识点操作Hook(6参数)", "usedBy": ["textbook-reader.tsx"]} ], "types": [ {"name": "Chapter", "definition": "{ id, textbookId, title, order, parentId, content?, children? }", "usedBy": ["textbooks/components", "questions (知识点关联)"]}, - {"name": "KnowledgePoint", "definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }", "usedBy": ["textbooks/components", "questions/types"]} + {"name": "KnowledgePoint", "definition": "{ id, name, description?, anchorText?, parentId?, chapterId?, level, order }", "usedBy": ["textbooks/components", "questions/types"]}, + {"name": "Textbook", "type": "type", "definition": "{ id, title, subject, grade, publisher, createdAt, updatedAt }", "usedBy": ["textbooks/components", "textbooks/data-access"]}, + {"name": "CreateTextbookInput", "type": "type", "definition": "创建教材输入", "usedBy": ["createTextbook", "createTextbookAction"]}, + {"name": "UpdateTextbookInput", "type": "type", "definition": "更新教材输入", "usedBy": ["updateTextbook", "updateTextbookAction"]}, + {"name": "CreateChapterInput", "type": "type", "definition": "创建章节输入", "usedBy": ["createChapter", "createChapterAction"]}, + {"name": "UpdateChapterContentInput", "type": "type", "definition": "更新章节内容输入", "usedBy": ["updateChapterContent", "updateChapterContentAction"]}, + {"name": "CreateKnowledgePointInput", "type": "type", "definition": "创建知识点输入", "usedBy": ["createKnowledgePoint", "createKnowledgePointAction"]}, + {"name": "UpdateKnowledgePointInput", "type": "type", "definition": "更新知识点输入", "usedBy": ["updateKnowledgePoint", "updateKnowledgePointAction"]} + ], + "components": [ + {"name": "ChapterSidebarList", "purpose": "章节侧边栏列表"}, + {"name": "CreateChapterDialog", "purpose": "创建章节对话框"}, + {"name": "CreateKnowledgePointDialog", "purpose": "创建知识点对话框"}, + {"name": "KnowledgeGraph", "purpose": "知识图谱可视化"}, + {"name": "KnowledgePointDialogs", "purpose": "知识点对话框集合"}, + {"name": "KnowledgePointList", "purpose": "知识点列表"}, + {"name": "KnowledgePointPanel", "purpose": "知识点面板"}, + {"name": "TextbookCard", "purpose": "教材卡片"}, + {"name": "TextbookContentPanel", "purpose": "教材内容面板"}, + {"name": "TextbookFilters", "purpose": "教材筛选器"}, + {"name": "TextbookFormDialog", "purpose": "教材表单对话框"}, + {"name": "TextbookReader", "purpose": "教材阅读器"}, + {"name": "TextbookSettingsDialog", "purpose": "教材设置对话框"} ] } }, @@ -432,26 +734,91 @@ {"name": "deleteAdminClassAction", "permission": "CLASS_DELETE", "signature": "(classId) => Promise", "purpose": "管理员删除班级"} ], "dataAccess": [ - {"name": "getTeacherClasses", "signature": "(params?: { teacherId? }) => Promise", "usedBy": ["teacher/classes/my", "dashboard"]}, + {"name": "getTeacherClasses", "signature": "(params?: { teacherId?: string }) => Promise", "usedBy": ["teacher/classes/my", "dashboard"]}, {"name": "getAdminClasses", "signature": "() => Promise", "usedBy": ["admin班级管理"]}, {"name": "getGradeManagedClasses", "signature": "(userId) => Promise", "usedBy": ["grade_head班级管理"]}, {"name": "getStudentClasses", "signature": "(studentId) => Promise", "usedBy": ["student/dashboard"]}, {"name": "getStudentSchedule", "signature": "(studentId) => Promise", "usedBy": ["student课表"]}, - {"name": "getClassStudents", "signature": "(classId, scope?) => Promise", "usedBy": ["teacher/classes/students"]}, - {"name": "getClassSchedule", "signature": "(classId) => Promise", "usedBy": ["teacher/classes/schedule"]}, - {"name": "getClassHomeworkInsights", "signature": "(classId) => Promise", "usedBy": ["classes作业洞察"]}, - {"name": "getGradeHomeworkInsights", "signature": "(gradeId) => Promise", "usedBy": ["年级作业洞察"]} + {"name": "getClassStudents", "signature": "(params?: { classId?, q?, status?, teacherId? }) => Promise", "usedBy": ["teacher/classes/students"]}, + {"name": "getClassSchedule", "signature": "(params?: { classId?, teacherId? }) => Promise", "usedBy": ["teacher/classes/schedule"]}, + {"name": "getClassHomeworkInsights", "signature": "(params: { classId, teacherId?, limit? }) => Promise", "usedBy": ["classes作业洞察"]}, + {"name": "getGradeHomeworkInsights", "signature": "(params: { gradeId, limit? }) => Promise", "usedBy": ["年级作业洞察"]}, + {"name": "getTeacherIdForMutations", "signature": "() => Promise", "purpose": "获取当前教师ID(用于写操作)", "usedBy": ["classes写操作内部"]}, + {"name": "getClassSubjects", "signature": "(classId: string) => Promise", "purpose": "获取班级学科列表", "usedBy": ["class-detail组件"]}, + {"name": "getTeacherOptions", "signature": "() => Promise", "purpose": "获取教师选项列表", "usedBy": ["class-detail组件"]}, + {"name": "getTeacherTeachingSubjects", "signature": "(teacherId: string) => Promise<...>", "purpose": "获取教师所教学科", "usedBy": ["classes内部"]}, + {"name": "getManagedGrades", "signature": "(userId: string) => Promise<...>", "purpose": "获取所管年级", "usedBy": ["grade_head视图"]}, + {"name": "getStudentsSubjectScores", "signature": "(...) => Promise<...>", "purpose": "获取学生学科成绩", "usedBy": ["classes内部"]}, + {"name": "getClassStudentSubjectScoresV2", "signature": "(...) => Promise<...>", "purpose": "获取班级学生学科成绩V2", "usedBy": ["classes内部"]}, + {"name": "createTeacherClass", "signature": "(input) => Promise", "purpose": "教师创建班级", "usedBy": ["createTeacherClassAction"]}, + {"name": "createAdminClass", "signature": "(input) => Promise", "purpose": "管理员创建班级", "usedBy": ["createAdminClassAction"]}, + {"name": "ensureClassInvitationCode", "signature": "(classId: string) => Promise<{ code: string }>", "purpose": "确保邀请码存在", "usedBy": ["ensureClassInvitationCodeAction"]}, + {"name": "regenerateClassInvitationCode", "signature": "(classId: string) => Promise<{ code: string }>", "purpose": "重新生成邀请码", "usedBy": ["regenerateClassInvitationCodeAction"]}, + {"name": "enrollStudentByInvitationCode", "signature": "(code: string, studentId: string) => Promise<{ classId: string }>", "purpose": "通过邀请码注册学生", "usedBy": ["joinClassByInvitationCodeAction"]}, + {"name": "enrollTeacherByInvitationCode", "signature": "(code: string, teacherId: string) => Promise<...>", "purpose": "通过邀请码注册教师", "usedBy": ["classes内部"]}, + {"name": "updateTeacherClass", "signature": "(classId: string, input) => Promise", "purpose": "教师更新班级", "usedBy": ["updateTeacherClassAction"]}, + {"name": "updateAdminClass", "signature": "(classId: string, input) => Promise", "purpose": "管理员更新班级", "usedBy": ["updateAdminClassAction"]}, + {"name": "setClassSubjectTeachers", "signature": "(classId: string, assignments: ClassSubjectTeacherAssignment[]) => Promise", "purpose": "设置班级学科教师", "usedBy": ["classes内部"]}, + {"name": "deleteTeacherClass", "signature": "(classId: string) => Promise", "purpose": "教师删除班级", "usedBy": ["deleteTeacherClassAction"]}, + {"name": "deleteAdminClass", "signature": "(classId: string) => Promise", "purpose": "管理员删除班级", "usedBy": ["deleteAdminClassAction"]}, + {"name": "enrollStudentByEmail", "signature": "(classId: string, email: string) => Promise", "purpose": "通过邮箱注册学生", "usedBy": ["enrollStudentByEmailAction"]}, + {"name": "setStudentEnrollmentStatus", "signature": "(classId: string, studentId: string, status: string) => Promise", "purpose": "设置学生状态", "usedBy": ["setStudentEnrollmentStatusAction"]}, + {"name": "createClassScheduleItem", "signature": "(input: CreateClassScheduleItemInput) => Promise", "purpose": "创建课表项", "usedBy": ["createClassScheduleItemAction"]}, + {"name": "updateClassScheduleItem", "signature": "(scheduleId: string, input: UpdateClassScheduleItemInput) => Promise", "purpose": "更新课表项", "usedBy": ["updateClassScheduleItemAction"]}, + {"name": "deleteClassScheduleItem", "signature": "(scheduleId: string) => Promise", "purpose": "删除课表项", "usedBy": ["deleteClassScheduleItemAction"]} + ], + "types": [ + {"name": "TeacherClass", "type": "type", "definition": "教师班级对象", "usedBy": ["getTeacherClasses", "dashboard"]}, + {"name": "AssignmentSummary", "type": "type", "definition": "作业摘要", "usedBy": ["classes/components"]}, + {"name": "TeacherOption", "type": "type", "definition": "教师选项", "usedBy": ["getTeacherOptions", "class-detail组件"]}, + {"name": "DEFAULT_CLASS_SUBJECTS", "type": "const", "definition": "默认班级学科列表", "usedBy": ["classes内部"]}, + {"name": "ClassSubject", "type": "type", "definition": "班级学科", "usedBy": ["getClassSubjects", "classes/components"]}, + {"name": "ClassSubjectTeacherAssignment", "type": "type", "definition": "班级学科教师分配", "usedBy": ["setClassSubjectTeachers"]}, + {"name": "AdminClassListItem", "type": "type", "definition": "管理员班级列表项", "usedBy": ["getAdminClasses", "getGradeManagedClasses"]}, + {"name": "CreateTeacherClassInput", "type": "type", "definition": "教师创建班级输入", "usedBy": ["createTeacherClass"]}, + {"name": "UpdateTeacherClassInput", "type": "type", "definition": "教师更新班级输入", "usedBy": ["updateTeacherClass"]}, + {"name": "ClassStudent", "type": "type", "definition": "班级学生", "usedBy": ["getClassStudents", "classes/components"]}, + {"name": "ClassScheduleItem", "type": "type", "definition": "班级课表项", "usedBy": ["getClassSchedule", "classes/components"]}, + {"name": "CreateClassScheduleItemInput", "type": "type", "definition": "创建课表项输入", "usedBy": ["createClassScheduleItem"]}, + {"name": "UpdateClassScheduleItemInput", "type": "type", "definition": "更新课表项输入", "usedBy": ["updateClassScheduleItem"]}, + {"name": "StudentEnrolledClass", "type": "type", "definition": "学生已注册班级", "usedBy": ["getStudentClasses", "dashboard"]}, + {"name": "StudentScheduleItem", "type": "type", "definition": "学生课表项", "usedBy": ["getStudentSchedule", "dashboard"]}, + {"name": "ScoreStats", "type": "type", "definition": "成绩统计", "usedBy": ["classes/components"]}, + {"name": "ClassHomeworkAssignmentStats", "type": "type", "definition": "班级作业统计", "usedBy": ["classes/components"]}, + {"name": "ClassHomeworkInsights", "type": "type", "definition": "班级作业洞察", "usedBy": ["getClassHomeworkInsights", "classes/components"]}, + {"name": "GradeHomeworkClassSummary", "type": "type", "definition": "年级作业班级汇总", "usedBy": ["classes/components"]}, + {"name": "GradeHomeworkInsights", "type": "type", "definition": "年级作业洞察", "usedBy": ["getGradeHomeworkInsights", "classes/components"]} + ], + "components": [ + {"name": "StudentsTable", "purpose": "学生表格"}, + {"name": "StudentsFilters", "purpose": "学生筛选器"}, + {"name": "ScheduleView", "purpose": "课表视图"}, + {"name": "MyClassesGrid", "purpose": "我的班级网格"}, + {"name": "AdminClassesClient", "purpose": "管理员班级客户端"}, + {"name": "ScheduleFilters", "purpose": "课表筛选器"}, + {"name": "GradeClassesClient", "purpose": "年级班级客户端"}, + {"name": "transformAssignmentsToChartData", "file": "class-detail/transformAssignmentsToChartData", "type": "function", "purpose": "转换作业为图表数据"}, + {"name": "ClassSubmissionTrendChart", "file": "class-detail/ClassSubmissionTrendChart", "purpose": "班级提交趋势图表"}, + {"name": "ClassTrendsWidget", "file": "class-detail/ClassTrendsWidget", "purpose": "班级趋势组件"}, + {"name": "ClassStudentsWidget", "file": "class-detail/ClassStudentsWidget", "purpose": "班级学生组件"}, + {"name": "ClassScheduleGrid", "file": "class-detail/ClassScheduleGrid", "purpose": "班级课表网格"}, + {"name": "ClassScheduleWidget", "file": "class-detail/ClassScheduleWidget", "purpose": "班级课表组件"}, + {"name": "ClassQuickActions", "file": "class-detail/ClassQuickActions", "purpose": "班级快捷操作"}, + {"name": "EditClassDialog", "file": "class-detail/EditClassDialog", "purpose": "编辑班级对话框"}, + {"name": "ClassOverviewStats", "file": "class-detail/ClassOverviewStats", "purpose": "班级概览统计"}, + {"name": "ClassHeader", "file": "class-detail/ClassHeader", "purpose": "班级头部"}, + {"name": "ClassAssignmentsWidget", "file": "class-detail/ClassAssignmentsWidget", "purpose": "班级作业组件"} ] } }, "school": { "path": "src/modules/school", - "description": "学校基础数据管理:学校、年级、部门、学年的CRUD", + "description": "学校基础数据管理:学校、年级、部门、学年的CRUD。学校CRUD actions在写操作成功后调用logAudit()记录操作日志", "exports": { "actions": [ - {"name": "createSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(prevState, formData) => Promise>", "purpose": "创建学校"}, - {"name": "updateSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId, prevState, formData) => Promise>", "purpose": "更新学校"}, - {"name": "deleteSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId) => Promise>", "purpose": "删除学校"}, + {"name": "createSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(prevState, formData) => Promise>", "purpose": "创建学校", "auditLog": "school.create"}, + {"name": "updateSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId, prevState, formData) => Promise>", "purpose": "更新学校", "auditLog": "school.update"}, + {"name": "deleteSchoolAction", "permission": "SCHOOL_MANAGE", "signature": "(schoolId) => Promise>", "purpose": "删除学校", "auditLog": "school.delete"}, {"name": "createGradeAction", "permission": "GRADE_MANAGE", "signature": "(prevState, formData) => Promise>", "purpose": "创建年级"}, {"name": "updateGradeAction", "permission": "GRADE_MANAGE", "signature": "(gradeId, prevState, formData) => Promise>", "purpose": "更新年级"}, {"name": "deleteGradeAction", "permission": "GRADE_MANAGE", "signature": "(gradeId) => Promise>", "purpose": "删除年级"}, @@ -469,6 +836,25 @@ {"name": "getAcademicYears", "signature": "() => Promise", "usedBy": ["admin学年管理"]}, {"name": "getStaffOptions", "signature": "() => Promise", "usedBy": ["school组件"]}, {"name": "getGradesForStaff", "signature": "(staffId) => Promise", "usedBy": ["grade_head视图"]} + ], + "schema": [ + {"name": "UpsertDepartmentSchema", "type": "const", "description": "zod schema 部门upsert", "usedBy": ["createDepartmentAction", "updateDepartmentAction"]}, + {"name": "UpsertAcademicYearSchema", "type": "const", "description": "zod schema 学年upsert", "usedBy": ["createAcademicYearAction", "updateAcademicYearAction"]}, + {"name": "UpsertSchoolSchema", "type": "const", "description": "zod schema 学校upsert", "usedBy": ["createSchoolAction", "updateSchoolAction"]}, + {"name": "UpsertGradeSchema", "type": "const", "description": "zod schema 年级upsert", "usedBy": ["createGradeAction", "updateGradeAction"]} + ], + "types": [ + {"name": "DepartmentListItem", "type": "type", "definition": "部门列表项", "usedBy": ["getDepartments", "school/components"]}, + {"name": "AcademicYearListItem", "type": "type", "definition": "学年列表项", "usedBy": ["getAcademicYears", "school/components"]}, + {"name": "SchoolListItem", "type": "type", "definition": "学校列表项", "usedBy": ["getSchools", "school/components"]}, + {"name": "StaffOption", "type": "type", "definition": "员工选项", "usedBy": ["getStaffOptions", "school/components"]}, + {"name": "GradeListItem", "type": "type", "definition": "年级列表项", "usedBy": ["getGrades", "school/components", "exams"]} + ], + "components": [ + {"name": "SchoolsClient", "purpose": "学校管理客户端"}, + {"name": "GradesClient", "purpose": "年级管理客户端"}, + {"name": "DepartmentsClient", "purpose": "部门管理客户端"}, + {"name": "AcademicYearClient", "purpose": "学年管理客户端"} ] } }, @@ -482,7 +868,29 @@ "types": [ {"name": "StudentDashboardProps", "definition": "{ studentName, enrolledClassCount, dueSoonCount, overdueCount, gradedCount, todayScheduleItems, upcomingAssignments, grades }", "deps": ["homework/types.StudentDashboardGradeProps"], "usedBy": ["student-dashboard-view.tsx"]}, {"name": "TeacherDashboardData", "definition": "{ classes, schedule, assignments, submissions, teacherName, gradeTrends }", "deps": ["homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "usedBy": ["teacher-dashboard-view.tsx"]}, - {"name": "AdminDashboardData", "definition": "{ activeSessionsCount, userCount, userRoleCounts, classCount, ... }", "usedBy": ["admin/dashboard/page.tsx"]} + {"name": "AdminDashboardData", "definition": "{ activeSessionsCount, userCount, userRoleCounts, classCount, ... }", "usedBy": ["admin/dashboard/page.tsx"]}, + {"name": "AdminDashboardUserRoleCount", "type": "type", "definition": "管理员仪表盘用户角色计数", "usedBy": ["admin-dashboard/AdminDashboardView"]}, + {"name": "AdminDashboardRecentUser", "type": "type", "definition": "管理员仪表盘最近用户", "usedBy": ["admin-dashboard/AdminDashboardView"]}, + {"name": "StudentTodayScheduleItem", "type": "type", "definition": "学生今日课表项", "usedBy": ["student-dashboard/StudentTodayScheduleCard"]}, + {"name": "TeacherTodayScheduleItem", "type": "type", "definition": "教师今日课表项", "usedBy": ["teacher-dashboard/TeacherSchedule"]} + ], + "components": [ + {"name": "AdminDashboardView", "file": "admin-dashboard/AdminDashboardView", "purpose": "管理员仪表盘视图"}, + {"name": "StudentDashboard", "file": "student-dashboard/StudentDashboard", "purpose": "学生仪表盘(注意:非StudentDashboardView)"}, + {"name": "StudentDashboardHeader", "file": "student-dashboard/StudentDashboardHeader", "purpose": "学生仪表盘头部"}, + {"name": "StudentGradesCard", "file": "student-dashboard/StudentGradesCard", "purpose": "学生成绩卡片"}, + {"name": "StudentStatsGrid", "file": "student-dashboard/StudentStatsGrid", "purpose": "学生统计网格"}, + {"name": "StudentTodayScheduleCard", "file": "student-dashboard/StudentTodayScheduleCard", "purpose": "学生今日课表卡片"}, + {"name": "StudentUpcomingAssignmentsCard", "file": "student-dashboard/StudentUpcomingAssignmentsCard", "purpose": "学生即将到来作业卡片"}, + {"name": "TeacherDashboardView", "file": "teacher-dashboard/TeacherDashboardView", "purpose": "教师仪表盘视图"}, + {"name": "TeacherClassesCard", "file": "teacher-dashboard/TeacherClassesCard", "purpose": "教师班级卡片"}, + {"name": "TeacherDashboardHeader", "file": "teacher-dashboard/TeacherDashboardHeader", "purpose": "教师仪表盘头部"}, + {"name": "TeacherGradeTrends", "file": "teacher-dashboard/TeacherGradeTrends", "purpose": "教师年级趋势"}, + {"name": "TeacherHomeworkCard", "file": "teacher-dashboard/TeacherHomeworkCard", "purpose": "教师作业卡片"}, + {"name": "TeacherQuickActions", "file": "teacher-dashboard/TeacherQuickActions", "purpose": "教师快捷操作"}, + {"name": "TeacherSchedule", "file": "teacher-dashboard/TeacherSchedule", "purpose": "教师课表"}, + {"name": "TeacherStats", "file": "teacher-dashboard/TeacherStats", "purpose": "教师统计"}, + {"name": "RecentSubmissions", "file": "teacher-dashboard/RecentSubmissions", "purpose": "最近提交"} ] } }, @@ -492,21 +900,534 @@ "exports": { "components": [ {"name": "AppSidebar", "purpose": "根据权限渲染侧边栏导航", "internalDeps": ["usePermission", "NAV_CONFIG"]}, - {"name": "SiteHeader", "purpose": "顶部导航栏", "internalDeps": ["useSession", "signOut"]} + {"name": "SiteHeader", "purpose": "顶部导航栏(集成 GlobalSearch 全局搜索:Cmd/Ctrl+K 唤起、300ms 防抖、↑/↓ 导航、Enter 跳转;NotificationDropdown 通知下拉)", "internalDeps": ["useSession", "signOut", "shared/components/global-search.GlobalSearch", "messaging/components/notification-dropdown.NotificationDropdown"]}, + {"name": "SidebarProvider", "props": "{ children, sidebar }", "purpose": "侧边栏上下文Provider"}, + {"name": "useSidebar", "type": "hook", "purpose": "侧边栏状态Hook"} + ], + "types": [ + {"name": "Role", "type": "type", "definition": "\"admin\" | \"teacher\" | \"student\" | \"parent\"", "usedBy": ["NAV_CONFIG", "usePermission"]}, + {"name": "NavItem", "type": "type", "definition": "{ title, href, icon?, permission? }", "usedBy": ["NAV_CONFIG", "AppSidebar"]} ], "config": [ - {"name": "NAV_CONFIG", "type": "Record", "note": "每个NavItem含permission字段用于权限过滤"} + {"name": "NAV_CONFIG", "type": "Record", "note": "每个NavItem含permission字段用于权限过滤。admin角色菜单包含Audit Logs项(icon: ScrollText, href: /admin/audit-logs, permission: AUDIT_LOG_READ),含子项Operation Logs与Login Logs。admin角色菜单的School Management子菜单包含Import Users项(href: /admin/users/import, permission: USER_MANAGE)。admin角色菜单包含Scheduling项(icon: CalendarClock, href: /admin/scheduling/rules, permission: SCHEDULE_ADJUST),含子项Rules(/admin/scheduling/rules, permission: SCHEDULE_ADJUST)、Auto Schedule(/admin/scheduling/auto, permission: SCHEDULE_AUTO)、Change Requests(/admin/scheduling/changes, permission: SCHEDULE_ADJUST)。teacher角色菜单包含Grades项(icon: GraduationCap, permission: GRADE_RECORD_READ),含子项All Grades(/teacher/grades)、Batch Entry(/teacher/grades/entry, permission: GRADE_RECORD_MANAGE)、Statistics(/teacher/grades/stats)。teacher角色菜单包含Schedule Changes项(icon: CalendarClock, href: /teacher/schedule-changes, permission: SCHEDULE_ADJUST)。student角色菜单包含My Grades项(icon: GraduationCap, href: /student/grades, permission: GRADE_RECORD_READ)。parent角色菜单包含Dashboard项(icon: LayoutDashboard, href: /parent/dashboard,无permission字段仅需登录)、Grades项(icon: GraduationCap, href: /parent/grades, permission: GRADE_RECORD_READ)、Announcements项(icon: Megaphone, href: /announcements, permission: ANNOUNCEMENT_READ)"} ] } }, "settings": { "path": "src/modules/settings", - "description": "系统设置:AI Provider配置、用户偏好", + "description": "系统设置:AI Provider配置、用户偏好、密码安全(修改密码、强度校验)", "exports": { "actions": [ {"name": "getAiProviderSummaries", "permission": "AI_CONFIGURE", "signature": "() => Promise", "purpose": "获取AI Provider列表"}, {"name": "upsertAiProviderAction", "permission": "AI_CONFIGURE", "signature": "(data) => Promise>", "purpose": "创建/更新AI Provider", "deps": ["shared/lib/ai (encrypt/decrypt)"]}, - {"name": "testAiProviderAction", "permission": "AI_CONFIGURE", "signature": "(data) => Promise>", "purpose": "测试AI Provider连通性", "deps": ["shared/lib/ai.testAiProviderConfig"]} + {"name": "testAiProviderAction", "permission": "AI_CONFIGURE", "signature": "(data) => Promise>", "purpose": "测试AI Provider连通性", "deps": ["shared/lib/ai.testAiProviderConfig"]}, + {"name": "changePasswordAction", "file": "actions-password.ts", "permission": "requireAuth()", "signature": "(prevState: ActionState, formData: FormData) => Promise>", "purpose": "修改当前用户密码(校验当前密码、新密码策略、速率限制 PASSWORD_CHANGE: 5次/分钟)", "deps": ["requireAuth", "validatePassword", "rateLimit", "bcryptjs (hash/compare)", "shared/db (users, passwordSecurity)"], "usedBy": ["components/password-change-form.tsx"]} + ], + "types": [ + {"name": "AiProviderSummary", "type": "type", "definition": "AI Provider摘要", "usedBy": ["getAiProviderSummaries", "settings/components"]} + ], + "components": [ + {"name": "AiProviderSettingsCard", "purpose": "AI Provider设置卡片"}, + {"name": "AdminSettingsView", "purpose": "管理员设置视图(General/Appearance/Security/Notifications tab,Security 含 PasswordChangeForm,Notifications 含 NotificationPreferencesForm)"}, + {"name": "ProfileSettingsForm", "purpose": "个人资料设置表单"}, + {"name": "ThemePreferencesCard", "purpose": "主题偏好卡片"}, + {"name": "StudentSettingsView", "purpose": "学生设置视图(含 Notifications tab)"}, + {"name": "TeacherSettingsView", "purpose": "教师设置视图(含 Notifications tab)"}, + {"name": "PasswordChangeForm", "purpose": "密码修改表单(当前密码/新密码/确认密码 + 强度指示器 + 需求提示)", "deps": ["changePasswordAction", "getPasswordStrength", "PASSWORD_REQUIREMENT_HINTS"]}, + {"name": "NotificationPreferencesForm", "file": "components/notification-preferences-form.tsx", "purpose": "通知偏好设置表单(Switch 切换 email/sms/push 通道 + 5 个分类开关:作业/成绩/公告/消息/考勤;隐藏 checkbox 与 Switch 同步,useActionState 调用 updateNotificationPreferencesAction)", "deps": ["updateNotificationPreferencesAction", "shared/components/ui/switch", "shared/components/ui/card", "react.useActionState"], "usedBy": ["AdminSettingsView", "TeacherSettingsView", "StudentSettingsView"]} + ] + } + }, + "users": { + "path": "src/modules/users", + "description": "用户个人资料管理 + 用户批量导入/导出(Excel)", + "exports": { + "actions": [ + { "name": "updateUserProfile", "signature": "(data: UpdateUserProfileInput) => Promise", "file": "actions.ts", "permission": "requireAuth()", "deps": ["shared.db", "shared.db.schema.users", "shared.lib.auth-guard.requireAuth"] }, + { "name": "UpdateUserProfileInput", "type": "type", "file": "actions.ts", "definition": "{ name?, phone?, address?, gender?, age? }" }, + { "name": "downloadUserTemplateAction", "signature": "() => Promise>", "file": "actions.ts", "permission": "USER_MANAGE", "purpose": "生成用户导入模板(返回 base64 编码的 Excel)", "deps": ["requirePermission", "import-export.generateUserImportTemplate"], "usedBy": ["components/user-import-dialog.tsx"] }, + { "name": "importUsersAction", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "file": "actions.ts", "permission": "USER_MANAGE", "purpose": "导入用户:接收文件,解析+验证+批量创建(默认密码 123456)", "deps": ["requirePermission", "shared.lib.excel.parseExcel", "import-export.parseUserImportData", "import-export.batchImportUsers"], "usedBy": ["components/user-import-dialog.tsx"] }, + { "name": "exportUsersAction", "signature": "(role?: string) => Promise>", "file": "actions.ts", "permission": "USER_MANAGE", "purpose": "导出用户列表(返回 base64 编码的 Excel)", "deps": ["requirePermission", "import-export.exportUsersToExcel"], "usedBy": ["待扩展"] } + ], + "dataAccess": [ + { "name": "getUserProfile", "signature": "(userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.users"] }, + { "name": "UserProfile", "type": "type", "file": "data-access.ts", "definition": "{ id, name, email, image, role, phone, address, gender, age, onboardedAt, createdAt, updatedAt }" } + ], + "importExport": [ + { "name": "generateUserImportTemplate", "signature": "() => Promise", "file": "import-export.ts", "purpose": "生成用户导入模板(列:姓名/邮箱/角色/手机/班级邀请码,含示例行)", "deps": ["shared.lib.excel.generateTemplate"], "usedBy": ["actions.downloadUserTemplateAction"] }, + { "name": "parseUserImportData", "signature": "(rows: Record[]) => UserImportValidation", "file": "import-export.ts", "purpose": "解析并验证导入行(校验姓名/邮箱格式/角色枚举/邀请码仅 student)", "deps": [], "usedBy": ["actions.importUsersAction"] }, + { "name": "batchImportUsers", "signature": "(records: UserImportRecord[]) => Promise", "file": "import-export.ts", "purpose": "批量创建用户(默认密码 123456 bcrypt 哈希,自动创建 usersToRoles,student 通过邀请码自动加入班级)", "deps": ["shared.db", "shared.db.schema.users", "shared.db.schema.roles", "shared.db.schema.usersToRoles", "shared.db.schema.classes", "shared.db.schema.classEnrollments", "bcryptjs", "@paralleldrive/cuid2"], "usedBy": ["actions.importUsersAction"] }, + { "name": "exportUsersToExcel", "signature": "(params: { scope: DataScope; role?: string }) => Promise", "file": "import-export.ts", "purpose": "导出用户列表到 Excel(含姓名/邮箱/手机/性别/年龄/角色/创建时间)", "deps": ["shared.db", "shared.db.schema.users", "shared.db.schema.roles", "shared.db.schema.usersToRoles", "shared.lib.excel.exportToExcel"], "usedBy": ["actions.exportUsersAction", "app/api/export/route.ts"] } + ], + "types": [ + { "name": "UserImportRecord", "type": "type", "file": "import-export.ts", "definition": "{ name, email, role, phone?, invitationCode? }", "usedBy": ["parseUserImportData", "batchImportUsers"] }, + { "name": "UserImportValidation", "type": "type", "file": "import-export.ts", "definition": "{ valid: UserImportRecord[], invalid: Array<{ row, record, errors }> }", "usedBy": ["parseUserImportData"] }, + { "name": "UserImportResult", "type": "type", "file": "import-export.ts", "definition": "{ successCount, failedCount, errors: Array<{ row, email, error }> }", "usedBy": ["batchImportUsers", "importUsersAction"] } + ], + "components": [ + { "name": "UserImportDialog", "file": "components/user-import-dialog.tsx", "purpose": "用户批量导入对话框(4 状态:idle/preview/importing/done;下载模板→上传预览→确认导入→结果展示)", "usedBy": ["app/(dashboard)/admin/users/import/page.tsx"] } + ] + } + }, + "audit": { + "path": "src/modules/audit", + "description": "操作日志、登录日志与数据变更日志查询,支持 Excel 导出", + "exports": { + "dataAccess": [ + { "name": "getAuditLogs", "signature": "(params?: AuditLogQueryParams) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.auditLogs"], "usedBy": ["app/(dashboard)/admin/audit-logs/page.tsx"] }, + { "name": "getLoginLogs", "signature": "(params?: LoginLogQueryParams) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.loginLogs"], "usedBy": ["app/(dashboard)/admin/audit-logs/login-logs/page.tsx"] }, + { "name": "getAuditModuleOptions", "signature": "() => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.auditLogs"], "usedBy": ["app/(dashboard)/admin/audit-logs/page.tsx"] }, + { "name": "getDataChangeLogs", "signature": "(params?: DataChangeLogQueryParams) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.dataChangeLogs"], "usedBy": ["getDataChangeLogsAction"] }, + { "name": "getDataChangeStats", "signature": "() => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.dataChangeLogs"], "usedBy": ["getDataChangeLogsAction"] }, + { "name": "getDataChangeTableOptions", "signature": "() => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.dataChangeLogs"], "usedBy": ["getDataChangeLogsAction"] }, + { "name": "getDataChangeLogsForExport", "signature": "(params?: DataChangeLogQueryParams) => Promise", "file": "data-access.ts", "deps": ["getDataChangeLogs"], "usedBy": ["exportDataChangeLogsAction"] }, + { "name": "getAuditLogsForExport", "signature": "(params?: AuditLogQueryParams) => Promise", "file": "data-access.ts", "deps": ["getAuditLogs"], "usedBy": ["exportAuditLogsAction"] }, + { "name": "getLoginLogsForExport", "signature": "(params?: LoginLogQueryParams) => Promise", "file": "data-access.ts", "deps": ["getLoginLogs"], "usedBy": ["exportLoginLogsAction"] } + ], + "actions": [ + { "name": "getDataChangeLogsAction", "permission": "AUDIT_LOG_READ", "signature": "(params?: DataChangeLogQueryParams) => Promise>", "file": "actions.ts", "purpose": "获取数据变更日志(分页结果 + tableOptions + stats 三者并行加载)", "deps": ["requirePermission", "data-access.getDataChangeLogs", "data-access.getDataChangeTableOptions", "data-access.getDataChangeStats"], "usedBy": ["待扩展"] }, + { "name": "exportAuditLogsAction", "permission": "AUDIT_LOG_READ", "signature": "(params?: AuditLogQueryParams) => Promise>", "file": "actions.ts", "purpose": "导出操作日志为 Excel", "deps": ["requirePermission", "data-access.getAuditLogsForExport", "shared.lib.excel.exportToExcel"], "usedBy": ["待扩展"] }, + { "name": "exportLoginLogsAction", "permission": "AUDIT_LOG_READ", "signature": "(params?: LoginLogQueryParams) => Promise>", "file": "actions.ts", "purpose": "导出登录日志为 Excel", "deps": ["requirePermission", "data-access.getLoginLogsForExport", "shared.lib.excel.exportToExcel"], "usedBy": ["待扩展"] }, + { "name": "exportDataChangeLogsAction", "permission": "AUDIT_LOG_READ", "signature": "(params?: DataChangeLogQueryParams) => Promise>", "file": "actions.ts", "purpose": "导出数据变更日志为 Excel", "deps": ["requirePermission", "data-access.getDataChangeLogsForExport", "shared.lib.excel.exportToExcel"], "usedBy": ["待扩展"] } + ], + "types": [ + { "name": "AuditLog", "type": "interface", "file": "types.ts", "definition": "{ id, userId, userName, action, module, targetId, targetType, detail, ipAddress, userAgent, status, createdAt }" }, + { "name": "LoginLog", "type": "interface", "file": "types.ts", "definition": "{ id, userId, userEmail, action, status, ipAddress, userAgent, errorMessage, createdAt }" }, + { "name": "AuditLogQueryParams", "type": "type", "file": "types.ts", "definition": "{ userId?, module?, action?, status?, page?, pageSize?, startDate?, endDate? }" }, + { "name": "LoginLogQueryParams", "type": "type", "file": "types.ts", "definition": "{ userId?, action?, status?, page?, pageSize?, startDate?, endDate? }" }, + { "name": "PaginatedResult", "type": "interface", "file": "types.ts", "definition": "{ items: T[], total, page, pageSize, totalPages }" }, + { "name": "DataChangeAction", "type": "type", "file": "types.ts", "definition": "'create' | 'update' | 'delete'", "usedBy": ["data-access", "shared/lib/change-logger", "DataChangeLog"] }, + { "name": "DataChangeLog", "type": "interface", "file": "types.ts", "definition": "{ id, tableName, recordId, action, oldValue, newValue, changedBy, changedByName, ipAddress, createdAt }", "usedBy": ["audit/data-access", "audit/actions"] }, + { "name": "DataChangeStat", "type": "interface", "file": "types.ts", "definition": "{ tableName: string, count: number }", "usedBy": ["getDataChangeStats", "getDataChangeLogsAction"] }, + { "name": "DataChangeLogQueryParams", "type": "type", "file": "types.ts", "definition": "{ tableName?, recordId?, action?, userId?, page?, pageSize?, startDate?, endDate? }", "usedBy": ["getDataChangeLogs", "getDataChangeLogsForExport", "getDataChangeLogsAction", "exportDataChangeLogsAction"] } + ], + "components": [ + { "name": "AuditLogTable", "file": "components/audit-log-table.tsx", "purpose": "操作日志表格(分页)" }, + { "name": "AuditLogFilters", "file": "components/audit-log-filters.tsx", "purpose": "操作日志筛选器(模块/操作/状态/日期)" }, + { "name": "AuditLogView", "file": "components/audit-log-view.tsx", "purpose": "操作日志视图(筛选+表格+分页)" }, + { "name": "LoginLogTable", "file": "components/login-log-table.tsx", "purpose": "登录日志表格(分页)" }, + { "name": "LoginLogFilters", "file": "components/login-log-filters.tsx", "purpose": "登录日志筛选器(操作/状态/日期)" }, + { "name": "LoginLogView", "file": "components/login-log-view.tsx", "purpose": "登录日志视图(筛选+表格+分页)" } + ] + } + }, + "announcements": { + "path": "src/modules/announcements", + "description": "通知公告系统:创建、编辑、发布、归档、删除公告,所有登录用户可查看已发布公告", + "exports": { + "actions": [ + { "name": "createAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "创建公告(草稿/已发布)", "deps": ["requirePermission", "shared/db"], "usedBy": ["announcement-form.tsx"] }, + { "name": "updateAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新公告", "deps": ["requirePermission", "shared/db"], "usedBy": ["announcement-form.tsx"] }, + { "name": "deleteAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string) => Promise>", "purpose": "删除公告", "deps": ["requirePermission", "shared/db"], "usedBy": ["announcement-detail.tsx"] }, + { "name": "publishAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string) => Promise>", "purpose": "发布公告", "deps": ["requirePermission", "shared/db"], "usedBy": ["announcement-detail.tsx"] }, + { "name": "archiveAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string) => Promise>", "purpose": "归档公告", "deps": ["requirePermission", "shared/db"], "usedBy": ["announcement-detail.tsx"] }, + { "name": "getAnnouncementsAction", "permission": "requireAuth", "signature": "(params?: GetAnnouncementsParams) => Promise>", "purpose": "获取公告列表(所有登录用户可读)", "deps": ["requireAuth", "data-access.getAnnouncements"], "usedBy": ["待扩展"] } + ], + "dataAccess": [ + { "name": "getAnnouncements", "signature": "(params?: { status?, type?, page?, pageSize? }) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.announcements"], "usedBy": ["admin/announcements/page.tsx", "announcements/page.tsx"] }, + { "name": "getAnnouncementById", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.announcements"], "usedBy": ["admin/announcements/[id]/page.tsx"] } + ], + "schemas": [ + { "name": "CreateAnnouncementSchema", "type": "zod", "file": "schema.ts", "definition": "{ title, content, type?, status?, targetGradeId?, targetClassId?, publishedAt? }", "usedBy": ["createAnnouncementAction"] }, + { "name": "UpdateAnnouncementSchema", "type": "zod", "file": "schema.ts", "definition": "{ title, content, type?, status?, targetGradeId?, targetClassId?, publishedAt? }", "usedBy": ["updateAnnouncementAction"] } + ], + "types": [ + { "name": "Announcement", "type": "interface", "file": "types.ts", "definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, createdAt, updatedAt }", "usedBy": ["announcements/components", "页面"] }, + { "name": "AnnouncementListItem", "type": "type", "file": "types.ts", "definition": "= Announcement", "usedBy": ["列表页"] }, + { "name": "AnnouncementStatus", "type": "type", "file": "types.ts", "definition": "\"draft\" | \"published\" | \"archived\"", "usedBy": ["data-access", "components"] }, + { "name": "AnnouncementType", "type": "type", "file": "types.ts", "definition": "\"school\" | \"grade\" | \"class\"", "usedBy": ["data-access", "components"] }, + { "name": "GetAnnouncementsParams", "type": "interface", "file": "types.ts", "definition": "{ status?, type?, page?, pageSize? }", "usedBy": ["getAnnouncements", "getAnnouncementsAction"] } + ], + "components": [ + { "name": "AnnouncementList", "file": "components/announcement-list.tsx", "purpose": "公告列表(支持状态筛选)" }, + { "name": "AnnouncementCard", "file": "components/announcement-card.tsx", "purpose": "单条公告卡片" }, + { "name": "AnnouncementForm", "file": "components/announcement-form.tsx", "purpose": "创建/编辑表单" }, + { "name": "AnnouncementDetail", "file": "components/announcement-detail.tsx", "purpose": "详情查看(含发布/归档/删除操作)" }, + { "name": "AdminAnnouncementsView", "file": "components/admin-announcements-view.tsx", "purpose": "管理端公告视图(列表+创建对话框)" } + ] + } + }, + "files": { + "path": "src/modules/files", + "description": "文件上传与管理:通过 API 路由处理文件上传(保存到 public/uploads/YYYY-MM/),记录文件元数据到 DB,支持按关联资源(exam/textbook/question/announcement)多态查询、下载与删除", + "exports": { + "dataAccess": [ + { "name": "createFileAttachment", "signature": "(data: CreateFileAttachmentInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["app/api/upload/route.ts"] }, + { "name": "getFileAttachment", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["app/api/files/[id]/route.ts"] }, + { "name": "getFileAttachmentsByTarget", "signature": "(targetType: string, targetId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["按关联资源查询文件列表"] }, + { "name": "getFileAttachmentsByUploader", "signature": "(uploaderId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["按上传者查询文件列表"] }, + { "name": "getAllFileAttachments", "signature": "(limit?: number) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["app/(dashboard)/admin/files/page.tsx"] }, + { "name": "deleteFileAttachment", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.fileAttachments"], "usedBy": ["app/api/files/[id]/route.ts"] }, + { "name": "deleteFileAttachments", "signature": "(ids: string[]) => Promise", "file": "data-access.ts", "purpose": "批量删除文件附件记录(仅删 DB 行,磁盘文件由调用方处理;失败时回退到逐条删除)", "deps": ["shared.db", "shared.db.schema.fileAttachments", "drizzle-orm.inArray"], "usedBy": ["app/api/files/batch-delete/route.ts"] }, + { "name": "getFileAttachmentsWithFilters", "signature": "(params: FileAttachmentQueryParams) => Promise", "file": "data-access.ts", "purpose": "按 mimeType(精确或前缀匹配)与 search(originalName/filename 模糊匹配)筛选文件列表,支持 limit/offset 分页", "deps": ["shared.db", "shared.db.schema.fileAttachments", "drizzle-orm.like", "drizzle-orm.or", "drizzle-orm.and"], "usedBy": ["app/(dashboard)/admin/files/page.tsx"] }, + { "name": "getFileStats", "signature": "() => Promise", "file": "data-access.ts", "purpose": "获取文件统计(总数、总大小、按 mimeType 分组的 count/size)", "deps": ["shared.db", "shared.db.schema.fileAttachments", "drizzle-orm.count", "drizzle-orm.sql"], "usedBy": ["app/(dashboard)/admin/files/page.tsx"] }, + { "name": "getFileAttachmentsByIds", "signature": "(ids: string[]) => Promise", "file": "data-access.ts", "purpose": "按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)", "deps": ["shared.db", "shared.db.schema.fileAttachments", "drizzle-orm.inArray"], "usedBy": ["app/api/files/batch-delete/route.ts"] } + ], + "types": [ + { "name": "FileAttachment", "type": "interface", "file": "types.ts", "definition": "{ id, filename, originalName, mimeType, size, storagePath, url, uploaderId, targetType, targetId, createdAt }", "usedBy": ["files/components", "data-access", "API 路由"] }, + { "name": "FileUploadResult", "type": "interface", "file": "types.ts", "definition": "{ id, url, filename, originalName, size, mimeType }", "usedBy": ["app/api/upload/route.ts 响应", "file-upload.tsx 回调"] }, + { "name": "FileTargetType", "type": "type", "file": "types.ts", "definition": "\"exam\" | \"textbook\" | \"question\" | \"announcement\"", "usedBy": ["types.FileAttachment.targetType", "file-upload.tsx"] }, + { "name": "CreateFileAttachmentInput", "type": "interface", "file": "types.ts", "definition": "{ id, filename, originalName, mimeType, size, storagePath, url, uploaderId, targetType?, targetId? }", "usedBy": ["createFileAttachment"] }, + { "name": "FileAttachmentQueryParams", "type": "interface", "file": "types.ts", "definition": "{ mimeType?, search?, limit?, offset? }", "usedBy": ["getFileAttachmentsWithFilters"] }, + { "name": "FileStats", "type": "interface", "file": "types.ts", "definition": "{ totalCount, totalSize, byType: Array<{ mimeType, count, size }> }", "usedBy": ["getFileStats"] }, + { "name": "BatchDeleteResult", "type": "interface", "file": "types.ts", "definition": "{ success, deletedCount, failedIds: string[] }", "usedBy": ["deleteFileAttachments", "app/api/files/batch-delete/route.ts"] } + ], + "components": [ + { "name": "FileUpload", "file": "components/file-upload.tsx", "purpose": "文件上传组件(拖拽+点击上传,进度条,文件类型校验,调用 /api/upload)" }, + { "name": "FileList", "file": "components/file-list.tsx", "purpose": "文件列表展示(图标、文件名、大小、下载链接、删除按钮)" }, + { "name": "FilePreview", "file": "components/file-preview.tsx", "purpose": "文件预览(图片直接预览,PDF iframe,其他下载)" }, + { "name": "FileIcon", "file": "components/file-icon.tsx", "purpose": "根据 MIME 类型显示不同图标与颜色" }, + { "name": "AdminFilesView", "file": "components/admin-files-view.tsx", "purpose": "管理端文件视图(上传+列表+删除)" } + ] + } + }, + "grades": { + "path": "src/modules/grades", + "description": "成绩分析模块:成绩录入(单条+批量)、查询(按班级/科目/考试/学期过滤)、统计报表(均分、中位数、标准差、及格率、优秀率、排名)、Excel 导出(成绩明细+统计汇总/班级多科目横向对比)、趋势对比分析(成绩趋势、班级对比、科目对比、分数分布、排名趋势)", + "exports": { + "dataAccess": [ + { "name": "getGradeRecords", "signature": "(params: GradeQueryParams & { scope: DataScope; currentUserId?: string }) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.classes", "shared.db.schema.classEnrollments", "shared.db.schema.subjects", "shared.db.schema.users"], "usedBy": ["grades/actions.getGradeRecordsAction"] }, + { "name": "getGradeRecordById", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions.getGradeRecordByIdAction"] }, + { "name": "createGradeRecord", "signature": "(data: CreateGradeRecordInput, recordedBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions.createGradeRecordAction"] }, + { "name": "batchCreateGradeRecords", "signature": "(data: BatchCreateGradeRecordInput, recordedBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions.batchCreateGradeRecordsAction"] }, + { "name": "updateGradeRecord", "signature": "(id: string, data: UpdateGradeRecordInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions.updateGradeRecordAction"] }, + { "name": "deleteGradeRecord", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions.deleteGradeRecordAction"] }, + { "name": "getClassGradeStats", "signature": "(classId: string, subjectId?: string, examId?: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/data-access.getClassGradeStatsWithMeta"] }, + { "name": "getClassGradeStatsWithMeta", "signature": "(classId: string, subjectId?: string, examId?: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.classes", "shared.db.schema.subjects"], "usedBy": ["grades/actions.getClassGradeStatsAction"] }, + { "name": "getStudentGradeSummary", "signature": "(studentId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.subjects"], "usedBy": ["grades/actions.getStudentGradeSummaryAction"] }, + { "name": "getClassRanking", "signature": "(classId: string, subjectId?: string, examId?: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.users"], "usedBy": ["grades/actions.getClassRankingAction"] }, + { "name": "getClassStudentsForEntry", "signature": "(classId: string) => Promise<{ id: string; name: string }[]>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classEnrollments", "shared.db.schema.users"], "usedBy": ["grades/components/batch-grade-entry"] }, + { "name": "getGradeTrend", "signature": "(params: { studentId; subjectId?; semester?; scope: DataScope }) => Promise", "file": "data-access-analytics.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.classEnrollments"], "usedBy": ["grades/actions-analytics.getGradeTrendAction", "teacher/grades/analytics"] }, + { "name": "getClassComparison", "signature": "(params: { gradeId; subjectId; examId?; scope: DataScope }) => Promise", "file": "data-access-analytics.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.classes"], "usedBy": ["grades/actions-analytics.getClassComparisonAction", "teacher/grades/analytics"] }, + { "name": "getSubjectComparison", "signature": "(params: { classId; examId?; semester?; scope: DataScope }) => Promise", "file": "data-access-analytics.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.subjects"], "usedBy": ["grades/actions-analytics.getSubjectComparisonAction", "teacher/grades/analytics"] }, + { "name": "getGradeDistribution", "signature": "(params: { classId; subjectId?; examId?; scope: DataScope }) => Promise", "file": "data-access-analytics.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords"], "usedBy": ["grades/actions-analytics.getGradeDistributionAction", "teacher/grades/analytics"] }, + { "name": "getRankingTrend", "signature": "(studentId: string, subjectId?, semester?) => Promise", "file": "data-access-ranking.ts", "deps": ["shared.db", "shared.db.schema.gradeRecords", "shared.db.schema.classEnrollments"], "usedBy": ["grades/actions-analytics.getRankingTrendAction"] } + ], + "actions": [ + { "name": "createGradeRecordAction", "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_MANAGE", "usedBy": ["grades/components/grade-record-form"] }, + { "name": "batchCreateGradeRecordsAction", "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_MANAGE", "usedBy": ["grades/components/batch-grade-entry"] }, + { "name": "updateGradeRecordAction", "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_MANAGE", "usedBy": ["grades/components/grade-record-list"] }, + { "name": "deleteGradeRecordAction", "signature": "(prevState, formData) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_MANAGE", "usedBy": ["grades/components/grade-record-list"] }, + { "name": "getGradeRecordsAction", "signature": "(params) => Promise", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "usedBy": ["teacher/grades/page"] }, + { "name": "getClassGradeStatsAction", "signature": "(classId, subjectId?, examId?) => Promise", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "usedBy": ["teacher/grades/stats/page"] }, + { "name": "getStudentGradeSummaryAction", "signature": "(studentId?) => Promise", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "usedBy": ["student/grades/page", "parent/grades/page"] }, + { "name": "getClassRankingAction", "signature": "(classId, subjectId?, examId?) => Promise", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "usedBy": ["teacher/grades/stats/page"] }, + { "name": "getGradeRecordByIdAction", "signature": "(id) => Promise", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "usedBy": ["grades/components/grade-record-list"] }, + { "name": "exportGradesAction", "signature": "(params: { classId: string; subjectId?: string; examId?: string; reportType?: \"detail\" | \"class\" }) => Promise>", "file": "actions.ts", "permission": "GRADE_RECORD_READ", "purpose": "导出成绩到 Excel(detail=成绩明细+统计汇总,class=班级多科目横向对比总表),返回 base64 buffer", "deps": ["requirePermission", "export.exportGradeRecordsToExcel", "export.exportClassGradeReportToExcel", "export.formatDateForFile"], "usedBy": ["grades/components/export-button.tsx"] }, + { "name": "getGradeTrendAction", "signature": "(params) => Promise", "file": "actions-analytics.ts", "permission": "GRADE_RECORD_READ", "purpose": "获取成绩趋势(按学生/科目/学期,返回归一化分数趋势点)", "usedBy": ["teacher/grades/analytics"] }, + { "name": "getClassComparisonAction", "signature": "(params) => Promise", "file": "actions-analytics.ts", "permission": "GRADE_RECORD_READ", "purpose": "获取班级对比(同年级各班的均分/及格率/优秀率)", "usedBy": ["teacher/grades/analytics"] }, + { "name": "getSubjectComparisonAction", "signature": "(params) => Promise", "file": "actions-analytics.ts", "permission": "GRADE_RECORD_READ", "purpose": "获取科目对比(同班级各科目雷达图数据)", "usedBy": ["teacher/grades/analytics"] }, + { "name": "getGradeDistributionAction", "signature": "(params) => Promise", "file": "actions-analytics.ts", "permission": "GRADE_RECORD_READ", "purpose": "获取分数分布(90-100/80-89/70-79/60-69/<60 各区间人数)", "usedBy": ["teacher/grades/analytics"] }, + { "name": "getRankingTrendAction", "signature": "(studentId, subjectId?, semester?) => Promise", "file": "actions-analytics.ts", "permission": "GRADE_RECORD_READ", "purpose": "获取排名趋势(学生历次考试排名变化,含 DataScope 二次校验)", "usedBy": ["待扩展"] } + ], + "schemas": [ + { "name": "CreateGradeRecordSchema", "type": "ZodSchema", "file": "schema.ts", "usedBy": ["createGradeRecordAction"] }, + { "name": "BatchCreateGradeRecordSchema", "type": "ZodSchema", "file": "schema.ts", "usedBy": ["batchCreateGradeRecordsAction"] }, + { "name": "UpdateGradeRecordSchema", "type": "ZodSchema", "file": "schema.ts", "usedBy": ["updateGradeRecordAction"] } + ], + "types": [ + { "name": "GradeRecord", "type": "interface", "file": "types.ts", "usedBy": ["data-access", "actions"] }, + { "name": "GradeRecordListItem", "type": "interface", "file": "types.ts", "usedBy": ["data-access", "actions", "components/grade-record-list"] }, + { "name": "GradeStats", "type": "interface", "file": "types.ts", "definition": "{ count, average, median, stdDev, passRate, excellentRate, maxScore, minScore }", "usedBy": ["data-access", "components/grade-stats-card"] }, + { "name": "ClassGradeStats", "type": "interface", "file": "types.ts", "usedBy": ["data-access", "actions", "components/class-grade-report"] }, + { "name": "StudentGradeSummary", "type": "interface", "file": "types.ts", "usedBy": ["data-access", "actions", "components/student-grade-summary"] }, + { "name": "ClassRankingItem", "type": "interface", "file": "types.ts", "usedBy": ["data-access", "actions", "components/class-grade-report"] }, + { "name": "GradeRecordType", "type": "type", "file": "types.ts", "definition": "\"exam\" | \"quiz\" | \"assignment\" | \"monthly\" | \"midterm\" | \"final\"", "usedBy": ["types.GradeRecord.type"] }, + { "name": "GradeRecordSemester", "type": "type", "file": "types.ts", "definition": "\"1\" | \"2\"", "usedBy": ["types.GradeRecord.semester"] }, + { "name": "GradeQueryParams", "type": "interface", "file": "types.ts", "usedBy": ["data-access.getGradeRecords"] }, + { "name": "GradeTrendPoint", "type": "interface", "file": "types.ts", "definition": "{ date, title, score, fullScore, normalizedScore, type }", "usedBy": ["data-access-analytics.getGradeTrend", "grade-trend-chart"] }, + { "name": "GradeTrendResult", "type": "interface", "file": "types.ts", "definition": "{ label, points: GradeTrendPoint[], averageScore }", "usedBy": ["data-access-analytics.getGradeTrend", "grade-trend-chart"] }, + { "name": "ClassComparisonItem", "type": "interface", "file": "types.ts", "definition": "{ classId, className, averageScore, passRate, excellentRate, studentCount }", "usedBy": ["data-access-analytics.getClassComparison", "class-comparison-chart"] }, + { "name": "SubjectComparisonItem", "type": "interface", "file": "types.ts", "definition": "{ subjectId, subjectName, averageScore, passRate, excellentRate }", "usedBy": ["data-access-analytics.getSubjectComparison", "subject-comparison-chart"] }, + { "name": "GradeDistributionBucket", "type": "interface", "file": "types.ts", "definition": "{ label, min, max, count, percentage }", "usedBy": ["data-access-analytics.getGradeDistribution", "grade-distribution-chart"] }, + { "name": "GradeDistributionResult", "type": "interface", "file": "types.ts", "definition": "{ buckets: GradeDistributionBucket[], totalCount }", "usedBy": ["data-access-analytics.getGradeDistribution", "grade-distribution-chart"] }, + { "name": "RankingTrendPoint", "type": "interface", "file": "types.ts", "definition": "{ title, date, rank, totalStudents, score }", "usedBy": ["data-access-ranking.getRankingTrend"] }, + { "name": "RankingTrendResult", "type": "interface", "file": "types.ts", "definition": "{ studentName, points: RankingTrendPoint[] }", "usedBy": ["data-access-ranking.getRankingTrend"] } + ], + "importExport": [ + { "name": "exportGradeRecordsToExcel", "signature": "(params: { classId: string; subjectId?: string; examId?: string; scope: DataScope }) => Promise", "file": "export.ts", "purpose": "导出成绩单(Sheet1 成绩明细,Sheet2 统计汇总:均分/中位数/最高分/最低分/标准差/及格率/优秀率/参考人数)", "deps": ["shared.lib.excel.exportToExcel", "data-access.getGradeRecords", "data-access.getClassGradeStats"], "usedBy": ["actions.exportGradesAction", "app/api/export/route.ts"] }, + { "name": "exportClassGradeReportToExcel", "signature": "(params: { classId: string; scope: DataScope }) => Promise", "file": "export.ts", "purpose": "导出班级成绩总表(多科目横向对比,含总分/平均分/排名列)", "deps": ["shared.db", "shared.db.schema.classes", "shared.db.schema.subjects", "shared.db.schema.gradeRecords", "shared.db.schema.users", "shared.lib.excel.exportToExcel", "data-access.getGradeRecords"], "usedBy": ["actions.exportGradesAction"] }, + { "name": "formatDateForFile", "signature": "(d?: Date) => string", "file": "export.ts", "purpose": "格式化日期为 YYYY-MM-DD 用于文件名", "deps": [], "usedBy": ["actions.exportGradesAction"] } + ], + "components": [ + { "name": "GradeRecordForm", "file": "components/grade-record-form.tsx", "purpose": "单条成绩录入表单(选择学生、班级、科目、考试,输入分数、满分、类型、学期、备注)" }, + { "name": "BatchGradeEntry", "file": "components/batch-grade-entry.tsx", "purpose": "批量录入界面(选择班级+科目+考试,表格形式录入每个学生分数)" }, + { "name": "GradeRecordList", "file": "components/grade-record-list.tsx", "purpose": "成绩列表(含查询筛选、删除对话框)" }, + { "name": "GradeStatsCard", "file": "components/grade-stats-card.tsx", "purpose": "统计卡片(均分、中位数、标准差、及格率、优秀率、最高分、最低分)" }, + { "name": "GradeQueryFilters", "file": "components/grade-query-filters.tsx", "purpose": "查询筛选器(班级、科目、考试类型、学期)" }, + { "name": "StudentGradeSummary", "file": "components/student-grade-summary.tsx", "purpose": "学生成绩汇总视图(按科目分组展示成绩趋势)" }, + { "name": "ClassGradeReport", "file": "components/class-grade-report.tsx", "purpose": "班级成绩报表(含统计+排名)" }, + { "name": "ExportButton", "file": "components/export-button.tsx", "purpose": "成绩导出按钮(DropdownMenu 选择 detail/class 报表类型,调用 exportGradesAction 并触发浏览器下载)", "usedBy": ["teacher/grades/page.tsx", "teacher/grades/stats/page.tsx"] }, + { "name": "GradeTrendChart", "file": "components/grade-trend-chart.tsx", "purpose": "成绩趋势折线图(recharts LineChart,归一化分数 0-100)", "deps": ["recharts", "shared/components/ui/chart"] }, + { "name": "ClassComparisonChart", "file": "components/class-comparison-chart.tsx", "purpose": "班级对比柱状图(recharts BarChart,均分/及格率/优秀率)", "deps": ["recharts", "shared/components/ui/chart"] }, + { "name": "SubjectComparisonChart", "file": "components/subject-comparison-chart.tsx", "purpose": "科目对比雷达图(recharts RadarChart)", "deps": ["recharts", "shared/components/ui/chart"] }, + { "name": "GradeDistributionChart", "file": "components/grade-distribution-chart.tsx", "purpose": "分数分布柱状图(recharts BarChart,彩色区间 90-100/80-89/70-79/60-69/<60)", "deps": ["recharts", "shared/components/ui/chart"] } + ] + } + }, + "course-plans": { + "path": "src/modules/course-plans", + "description": "课程计划管理:创建、编辑、删除课程计划(含周计划条目),管理员可管理全部,教师/学生/年级主任/教务主任可查看", + "exports": { + "actions": [ + { "name": "createCoursePlanAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "创建课程计划", "deps": ["requirePermission", "shared/db", "data-access.createCoursePlan"], "usedBy": ["course-plan-form.tsx"] }, + { "name": "updateCoursePlanAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新课程计划", "deps": ["requirePermission", "shared/db", "data-access.updateCoursePlan"], "usedBy": ["course-plan-form.tsx"] }, + { "name": "deleteCoursePlanAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(id: string) => Promise>", "purpose": "删除课程计划", "deps": ["requirePermission", "shared/db", "data-access.deleteCoursePlan"], "usedBy": ["course-plan-detail.tsx"] }, + { "name": "getCoursePlansAction", "permission": "COURSE_PLAN_READ", "signature": "(params?: GetCoursePlansParams) => Promise>", "purpose": "获取课程计划列表", "deps": ["requirePermission", "data-access.getCoursePlans"], "usedBy": ["待扩展"] }, + { "name": "getCoursePlanAction", "permission": "COURSE_PLAN_READ", "signature": "(id: string) => Promise>", "purpose": "获取课程计划详情(含周计划条目)", "deps": ["requirePermission", "data-access.getCoursePlanById"], "usedBy": ["待扩展"] }, + { "name": "createCoursePlanItemAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "创建周计划条目", "deps": ["requirePermission", "shared/db", "data-access.createCoursePlanItem"], "usedBy": ["course-plan-item-editor.tsx"] }, + { "name": "updateCoursePlanItemAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新周计划条目", "deps": ["requirePermission", "shared/db", "data-access.updateCoursePlanItem"], "usedBy": ["course-plan-item-editor.tsx"] }, + { "name": "deleteCoursePlanItemAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(id: string) => Promise>", "purpose": "删除周计划条目", "deps": ["requirePermission", "shared/db", "data-access.deleteCoursePlanItem"], "usedBy": ["course-plan-item-editor.tsx"] }, + { "name": "toggleCoursePlanItemCompletedAction", "permission": "COURSE_PLAN_MANAGE", "signature": "(id: string, completed: boolean) => Promise>", "purpose": "切换周计划条目完成状态", "deps": ["requirePermission", "shared/db", "data-access.updateCoursePlanItem"], "usedBy": ["course-plan-detail.tsx"] } + ], + "dataAccess": [ + { "name": "getCoursePlans", "signature": "(params?: GetCoursePlansParams) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlans", "shared.db.schema.classes", "shared.db.schema.subjects", "shared.db.schema.users"], "usedBy": ["admin/course-plans/page.tsx", "teacher/course-plans/page.tsx"] }, + { "name": "getCoursePlanById", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlans", "shared.db.schema.coursePlanItems", "shared.db.schema.classes", "shared.db.schema.subjects", "shared.db.schema.users"], "usedBy": ["admin/course-plans/[id]/page.tsx", "teacher/course-plans/[id]/page.tsx"] }, + { "name": "createCoursePlan", "signature": "(data: CreateCoursePlanInput, createdBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlans", "@paralleldrive/cuid2"], "usedBy": ["createCoursePlanAction"] }, + { "name": "updateCoursePlan", "signature": "(id: string, data: Partial) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlans"], "usedBy": ["updateCoursePlanAction"] }, + { "name": "deleteCoursePlan", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlans"], "usedBy": ["deleteCoursePlanAction"] }, + { "name": "createCoursePlanItem", "signature": "(data: CreateCoursePlanItemInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlanItems", "@paralleldrive/cuid2"], "usedBy": ["createCoursePlanItemAction"] }, + { "name": "updateCoursePlanItem", "signature": "(id: string, data: Partial) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlanItems"], "usedBy": ["updateCoursePlanItemAction", "toggleCoursePlanItemCompletedAction"] }, + { "name": "deleteCoursePlanItem", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlanItems"], "usedBy": ["deleteCoursePlanItemAction"] }, + { "name": "reorderCoursePlanItems", "signature": "(planId: string, items: ReorderCoursePlanItemInput[]) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.coursePlanItems"], "usedBy": ["待扩展"] }, + { "name": "getSubjectOptions", "signature": "() => Promise<{id:string;name:string}[]>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.subjects"], "usedBy": ["admin/course-plans/create/page.tsx", "admin/course-plans/[id]/edit/page.tsx"] } + ], + "schemas": [ + { "name": "CreateCoursePlanSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId, subjectId, teacherId, academicYearId?, semester?, totalHours?, weeklyHours?, startDate?, endDate?, syllabus?, objectives?, status? }", "usedBy": ["createCoursePlanAction"] }, + { "name": "UpdateCoursePlanSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId?, subjectId?, teacherId?, academicYearId?, semester?, totalHours?, completedHours?, weeklyHours?, startDate?, endDate?, syllabus?, objectives?, status? }", "usedBy": ["updateCoursePlanAction"] }, + { "name": "CreateCoursePlanItemSchema", "type": "zod", "file": "schema.ts", "definition": "{ planId, week, topic, content?, hours?, textbookChapter?, notes? }", "usedBy": ["createCoursePlanItemAction"] }, + { "name": "UpdateCoursePlanItemSchema", "type": "zod", "file": "schema.ts", "definition": "{ week?, topic?, content?, hours?, textbookChapter?, notes?, isCompleted?, completedAt? }", "usedBy": ["updateCoursePlanItemAction"] } + ], + "types": [ + { "name": "CoursePlan", "type": "interface", "file": "types.ts", "definition": "{ id, classId, subjectId, teacherId, academicYearId, semester, totalHours, completedHours, weeklyHours, startDate, endDate, syllabus, objectives, status, createdBy, createdAt, updatedAt }", "usedBy": ["course-plans/components", "data-access"] }, + { "name": "CoursePlanItem", "type": "interface", "file": "types.ts", "definition": "{ id, planId, week, topic, content, hours, textbookChapter, notes, isCompleted, completedAt, createdAt, updatedAt }", "usedBy": ["course-plans/components", "data-access"] }, + { "name": "CoursePlanListItem", "type": "interface", "file": "types.ts", "definition": "= CoursePlan & { className, subjectName, teacherName }", "usedBy": ["列表页", "data-access"] }, + { "name": "CoursePlanWithItems", "type": "interface", "file": "types.ts", "definition": "= CoursePlanListItem & { items: CoursePlanItem[] }", "usedBy": ["详情页", "data-access"] }, + { "name": "CoursePlanStatus", "type": "type", "file": "types.ts", "definition": "\"planning\" | \"active\" | \"completed\" | \"paused\"", "usedBy": ["data-access", "components"] }, + { "name": "CoursePlanSemester", "type": "type", "file": "types.ts", "definition": "\"1\" | \"2\"", "usedBy": ["data-access", "components"] }, + { "name": "GetCoursePlansParams", "type": "interface", "file": "types.ts", "definition": "{ classId?, teacherId?, subjectId?, status? }", "usedBy": ["getCoursePlans", "getCoursePlansAction"] }, + { "name": "ReorderCoursePlanItemInput", "type": "interface", "file": "types.ts", "definition": "{ id, week }", "usedBy": ["reorderCoursePlanItems"] } + ], + "components": [ + { "name": "CoursePlanProgress", "file": "components/course-plan-progress.tsx", "purpose": "进度条组件(completedHours/totalHours 百分比)" }, + { "name": "CoursePlanList", "file": "components/course-plan-list.tsx", "purpose": "课程计划列表(支持状态筛选,URL同步)" }, + { "name": "CoursePlanForm", "file": "components/course-plan-form.tsx", "purpose": "创建/编辑表单(班级、科目、教师、学年、学期、状态、课时、日期、大纲、目标)" }, + { "name": "CoursePlanItemEditor", "file": "components/course-plan-item-editor.tsx", "purpose": "周计划条目编辑器(Dialog,支持创建/编辑/删除/切换完成)" }, + { "name": "CoursePlanDetail", "file": "components/course-plan-detail.tsx", "purpose": "详情视图(计划信息、进度、大纲、目标、周计划表格、删除确认)" } + ] + } + }, + "parent": { + "path": "src/modules/parent", + "description": "家长端仪表盘:聚合家长关联子女的学习数据(课表、作业、成绩、班级),支持多子女切换查看。家长通过 parentStudentRelations 表关联子女,DataScope 解析为 children 类型", + "exports": { + "dataAccess": [ + { "name": "getChildren", "signature": "(parentId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.parentStudentRelations", "react.cache"], "usedBy": ["getParentDashboardData (内部)", "parent/children/[studentId]/page.tsx"] }, + { "name": "getChildBasicInfo", "signature": "(studentId: string, relation?: string | null) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.users", "shared.db.schema.grades", "shared.db.schema.classEnrollments", "shared.db.schema.classes", "react.cache"], "usedBy": ["getChildDashboardData (内部)"] }, + { "name": "getChildDashboardData", "signature": "(studentId: string, relation?: string | null) => Promise", "file": "data-access.ts", "deps": ["getChildBasicInfo", "classes/data-access.getStudentClasses", "classes/data-access.getStudentSchedule", "homework/data-access.getStudentHomeworkAssignments", "homework/data-access.getStudentDashboardGrades", "grades/data-access.getStudentGradeSummary", "react.cache"], "usedBy": ["parent/children/[studentId]/page.tsx", "getParentDashboardData (内部)"] }, + { "name": "getParentDashboardData", "signature": "(parentId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.users", "getChildren", "getChildDashboardData", "react.cache"], "usedBy": ["parent/dashboard/page.tsx"] } + ], + "types": [ + { "name": "ParentChildRelation", "type": "type", "file": "types.ts", "definition": "{ id, parentId, studentId, relation: string | null, createdAt: string }", "usedBy": ["getChildren", "getParentDashboardData"] }, + { "name": "ChildBasicInfo", "type": "type", "file": "types.ts", "definition": "{ id, name: string | null, email, image: string | null, gradeName: string | null, className: string | null, classId: string | null, relation: string | null }", "usedBy": ["getChildBasicInfo", "ChildDashboardData.basicInfo"] }, + { "name": "ChildScheduleItem", "type": "type", "file": "types.ts", "definition": "{ id, classId, className, course, startTime, endTime, location: string | null }", "usedBy": ["ChildDashboardData.todaySchedule", "child-schedule-card.tsx"] }, + { "name": "ChildHomeworkSummary", "type": "type", "file": "types.ts", "definition": "{ pendingCount, submittedCount, gradedCount, overdueCount, recentAssignments: StudentHomeworkAssignmentListItem[] }", "usedBy": ["ChildDashboardData.homeworkSummary", "child-homework-summary.tsx"] }, + { "name": "ChildDashboardData", "type": "type", "file": "types.ts", "definition": "{ basicInfo: ChildBasicInfo, enrolledClasses: StudentEnrolledClass[], todaySchedule: ChildScheduleItem[], homeworkSummary: ChildHomeworkSummary, gradeTrend: StudentDashboardGradeProps, gradeSummary: StudentGradeSummary | null }", "usedBy": ["getChildDashboardData", "ParentDashboardData.children", "所有 child-* 组件"] }, + { "name": "ParentDashboardData", "type": "type", "file": "types.ts", "definition": "{ parentName: string | null, children: ChildDashboardData[] }", "usedBy": ["getParentDashboardData", "parent-dashboard.tsx"] } + ], + "components": [ + { "name": "ParentDashboard", "file": "components/parent-dashboard.tsx", "purpose": "主容器组件(问候语、子女卡片网格、空状态)" }, + { "name": "ChildCard", "file": "components/child-card.tsx", "purpose": "子女卡片(头像、姓名、班级、待完成/逾期/平均分统计,点击跳转详情)" }, + { "name": "ChildDetailHeader", "file": "components/child-detail-header.tsx", "purpose": "子女详情页头部(返回按钮、头像、姓名、班级、年级、关系)" }, + { "name": "ChildDetailPanel", "file": "components/child-detail-panel.tsx", "purpose": "子女详情面板容器(组合 homework/grade/schedule 三个子组件)" }, + { "name": "ChildHomeworkSummary", "file": "components/child-homework-summary.tsx", "purpose": "子女作业概览(pending/submitted/graded/overdue 统计 + 最近作业列表)" }, + { "name": "ChildGradeSummary", "file": "components/child-grade-summary.tsx", "purpose": "子女成绩概览(Recharts 折线图趋势 + 最新分数 + 班级排名 + 最近成绩列表,use client)" }, + { "name": "ChildScheduleCard", "file": "components/child-schedule-card.tsx", "purpose": "子女今日课表卡片(课程、时间、地点、班级)" } + ] + } + }, + "messaging": { + "path": "src/modules/messaging", + "description": "站内消息系统:用户间私信收发(支持回复链)、站内通知(多态类型:message/announcement/homework/grade),SiteHeader 通知下拉菜单展示未读数", + "exports": { + "actions": [ + { "name": "sendMessageAction", "permission": "MESSAGE_SEND", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "发送消息(同时为收件人创建通知;支持 parentMessageId 回复)", "deps": ["requirePermission", "shared/db", "data-access.createMessage", "data-access.createNotification", "revalidatePath"], "usedBy": ["message-compose.tsx"] }, + { "name": "markMessageAsReadAction", "permission": "MESSAGE_READ", "signature": "(id: string) => Promise>", "purpose": "标记消息已读(设置 readAt)", "deps": ["requirePermission", "data-access.markMessageAsRead", "revalidatePath"], "usedBy": ["message-detail.tsx", "messages/[id]/page.tsx"] }, + { "name": "deleteMessageAction", "permission": "MESSAGE_DELETE", "signature": "(id: string) => Promise>", "purpose": "删除消息(仅发送者或接收者可删)", "deps": ["requirePermission", "data-access.deleteMessage", "revalidatePath"], "usedBy": ["message-detail.tsx"] }, + { "name": "getMessagesAction", "permission": "MESSAGE_READ", "signature": "(params?: { type?, page?, pageSize? }) => Promise>>", "purpose": "获取消息列表(收件箱/已发送,分页)", "deps": ["requirePermission", "data-access.getMessages"], "usedBy": ["message-list.tsx"] }, + { "name": "getMessageDetailAction", "permission": "MESSAGE_READ", "signature": "(id: string) => Promise>", "purpose": "获取消息详情(含回复线程)", "deps": ["requirePermission", "data-access.getMessageById", "data-access.getMessageThread"], "usedBy": ["message-detail.tsx"] }, + { "name": "getRecipientsAction", "permission": "MESSAGE_SEND", "signature": "() => Promise>", "purpose": "获取可发送对象列表(按 DataScope 过滤)", "deps": ["requirePermission", "data-access.getRecipients"], "usedBy": ["messages/compose/page.tsx"] }, + { "name": "getNotificationsAction", "permission": "requireAuth", "signature": "(params?: { page?, pageSize? }) => Promise>>", "purpose": "获取当前用户通知列表(分页)", "deps": ["requireAuth", "data-access.getNotifications"], "usedBy": ["notification-dropdown.tsx", "notification-list.tsx"] }, + { "name": "markNotificationAsReadAction", "permission": "requireAuth", "signature": "(id: string) => Promise>", "purpose": "标记单条通知已读", "deps": ["requireAuth", "data-access.markNotificationAsRead", "revalidatePath"], "usedBy": ["notification-dropdown.tsx", "notification-list.tsx"] }, + { "name": "markAllNotificationsAsReadAction", "permission": "requireAuth", "signature": "() => Promise>", "purpose": "标记所有通知已读", "deps": ["requireAuth", "data-access.markAllNotificationsAsRead", "revalidatePath"], "usedBy": ["notification-dropdown.tsx", "notification-list.tsx"] }, + { "name": "getNotificationPreferencesAction", "permission": "requireAuth", "signature": "() => Promise>", "purpose": "获取当前用户的通知偏好设置(首次访问自动创建默认记录)", "deps": ["requireAuth", "notification-preferences.getNotificationPreferences"], "usedBy": ["settings/page.tsx", "settings/components/notification-preferences-form.tsx"] }, + { "name": "updateNotificationPreferencesAction", "permission": "requireAuth", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新当前用户的通知偏好设置(upsert 语义,未提供字段保留原值)", "deps": ["requireAuth", "notification-preferences.upsertNotificationPreferences", "revalidatePath"], "usedBy": ["settings/components/notification-preferences-form.tsx"] } + ], + "dataAccess": [ + { "name": "getMessages", "signature": "(userId: string, params?: { type?, page?, pageSize? }) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages", "shared.db.schema.users"], "usedBy": ["getMessagesAction"] }, + { "name": "getMessageById", "signature": "(id: string, userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages", "shared.db.schema.users"], "usedBy": ["getMessageDetailAction", "messages/[id]/page.tsx"] }, + { "name": "getMessageThread", "signature": "(rootId: string, userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages", "shared.db.schema.users"], "usedBy": ["getMessageDetailAction"] }, + { "name": "createMessage", "signature": "(input: CreateMessageInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages", "@paralleldrive/cuid2"], "usedBy": ["sendMessageAction"] }, + { "name": "markMessageAsRead", "signature": "(id: string, userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages"], "usedBy": ["markMessageAsReadAction", "messages/[id]/page.tsx"] }, + { "name": "deleteMessage", "signature": "(id: string, userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages"], "usedBy": ["deleteMessageAction"] }, + { "name": "getUnreadMessageCount", "signature": "(userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messages"], "usedBy": ["待扩展"] }, + { "name": "getNotifications", "signature": "(userId: string, params?: { page?, pageSize? }) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messageNotifications"], "usedBy": ["getNotificationsAction"] }, + { "name": "createNotification", "signature": "(input: CreateNotificationInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messageNotifications", "@paralleldrive/cuid2"], "usedBy": ["sendMessageAction (内部调用)"] }, + { "name": "markNotificationAsRead", "signature": "(id: string, userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messageNotifications"], "usedBy": ["markNotificationAsReadAction"] }, + { "name": "markAllNotificationsAsRead", "signature": "(userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messageNotifications"], "usedBy": ["markAllNotificationsAsReadAction"] }, + { "name": "getUnreadNotificationCount", "signature": "(userId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.messageNotifications"], "usedBy": ["待扩展"] }, + { "name": "getRecipients", "signature": "(ctx: AuthContext) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.users", "shared.db.schema.classEnrollments", "shared.db.schema.classes", "shared.db.schema.grades"], "usedBy": ["getRecipientsAction", "messages/compose/page.tsx"] } + ], + "notificationPreferences": [ + { "name": "getNotificationPreferences", "signature": "(userId: string) => Promise", "file": "notification-preferences.ts", "purpose": "获取用户通知偏好(React cache 包装;若不存在则自动创建默认记录,并发冲突回退到查询)", "deps": ["shared.db", "shared.db.schema.notificationPreferences", "react.cache", "@paralleldrive/cuid2"], "usedBy": ["getNotificationPreferencesAction", "settings/page.tsx"] }, + { "name": "upsertNotificationPreferences", "signature": "(userId: string, input: UpdateNotificationPreferencesInput) => Promise", "file": "notification-preferences.ts", "purpose": "更新或插入用户通知偏好(存在则部分更新,不存在则插入;未提供字段保留原值)", "deps": ["shared.db", "shared.db.schema.notificationPreferences", "@paralleldrive/cuid2"], "usedBy": ["updateNotificationPreferencesAction"] } + ], + "schemas": [ + { "name": "SendMessageSchema", "type": "zod", "file": "schema.ts", "definition": "{ receiverId: string, subject?: string, content: string, parentMessageId?: string }", "usedBy": ["sendMessageAction"] } + ], + "types": [ + { "name": "Message", "type": "type", "file": "types.ts", "definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }", "usedBy": ["messaging/components", "页面"] }, + { "name": "MessageListItem", "type": "type", "file": "types.ts", "definition": "消息列表项类型(同 Message 精简版)", "usedBy": ["列表页"] }, + { "name": "MessageThread", "type": "type", "file": "types.ts", "definition": "{ root: Message, replies: Message[] }", "usedBy": ["详情页"] }, + { "name": "Notification", "type": "type", "file": "types.ts", "definition": "{ id, userId, type: NotificationType, title, content: string | null, link: string | null, isRead, createdAt }", "usedBy": ["notification-dropdown", "notification-list"] }, + { "name": "NotificationListItem", "type": "type", "file": "types.ts", "definition": "通知列表项类型(同 Notification)", "usedBy": ["列表页"] }, + { "name": "NotificationType", "type": "type", "file": "types.ts", "definition": "'message' | 'announcement' | 'homework' | 'grade'", "usedBy": ["data-access", "components"] }, + { "name": "MessageType", "type": "type", "file": "types.ts", "definition": "'inbox' | 'sent'", "usedBy": ["getMessages 参数"] }, + { "name": "CreateMessageInput", "type": "type", "file": "types.ts", "definition": "{ senderId, receiverId, subject?, content, parentMessageId? }", "usedBy": ["createMessage"] }, + { "name": "CreateNotificationInput", "type": "type", "file": "types.ts", "definition": "{ userId, type: NotificationType, title, content?, link? }", "usedBy": ["createNotification"] }, + { "name": "RecipientOption", "type": "type", "file": "types.ts", "definition": "{ id: string, name: string }", "usedBy": ["compose 页面下拉选项"] }, + { "name": "PaginatedResult", "type": "type", "file": "types.ts", "definition": "{ items: T[], total: number, page: number, pageSize: number, totalPages: number }", "usedBy": ["getMessages", "getNotifications"] }, + { "name": "NotificationPreferences", "type": "interface", "file": "types.ts", "definition": "{ id, userId, emailEnabled, smsEnabled, pushEnabled, homeworkNotifications, gradeNotifications, announcementNotifications, messageNotifications, attendanceNotifications, createdAt, updatedAt }", "usedBy": ["notification-preferences", "getNotificationPreferencesAction", "updateNotificationPreferencesAction", "settings/components/notification-preferences-form"] }, + { "name": "UpdateNotificationPreferencesInput", "type": "interface", "file": "types.ts", "definition": "{ emailEnabled?, smsEnabled?, pushEnabled?, homeworkNotifications?, gradeNotifications?, announcementNotifications?, messageNotifications?, attendanceNotifications? }", "usedBy": ["upsertNotificationPreferences", "updateNotificationPreferencesAction"] } + ], + "components": [ + { "name": "MessageList", "file": "components/message-list.tsx", "purpose": "消息列表(收件箱/已发送 Tab 切换,已读/未读标记,usePermission 控制写消息按钮)" }, + { "name": "MessageDetail", "file": "components/message-detail.tsx", "purpose": "消息详情(含回复线程、回复/删除操作,AlertDialog 删除确认,usePermission 控制按钮可见性)" }, + { "name": "MessageCompose", "file": "components/message-compose.tsx", "purpose": "写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式)" }, + { "name": "NotificationDropdown", "file": "components/notification-dropdown.tsx", "purpose": "SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,滚动列表,标记已读,查看全部链接)" }, + { "name": "NotificationList", "file": "components/notification-list.tsx", "purpose": "通知完整列表(全部标记已读、单条标记已读、查看链接)" } + ] + } + }, + "attendance": { + "path": "src/modules/attendance", + "description": "学生考勤管理:教师按班级/日期点名(单条/批量)、查询考勤记录、统计出勤率/迟到率,学生/家长查看本人/子女考勤汇总,管理员查看全校考勤记录。支持班级考勤规则配置。", + "exports": { + "actions": [ + { "name": "recordAttendanceAction", "permission": "ATTENDANCE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "创建单条考勤记录", "deps": ["requirePermission", "data-access.createAttendanceRecord", "revalidatePath"], "usedBy": ["attendance-record-list.tsx"] }, + { "name": "batchRecordAttendanceAction", "permission": "ATTENDANCE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "批量点名(班级+日期,表格形式录入每个学生状态)", "deps": ["requirePermission", "data-access.batchCreateAttendanceRecords", "revalidatePath"], "usedBy": ["attendance-sheet.tsx"] }, + { "name": "updateAttendanceAction", "permission": "ATTENDANCE_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "更新考勤记录(状态、备注)", "deps": ["requirePermission", "data-access.updateAttendanceRecord", "revalidatePath"], "usedBy": ["attendance-record-list.tsx"] }, + { "name": "deleteAttendanceAction", "permission": "ATTENDANCE_MANAGE", "signature": "(id: string) => Promise>", "purpose": "删除考勤记录", "deps": ["requirePermission", "data-access.deleteAttendanceRecord", "revalidatePath"], "usedBy": ["attendance-record-list.tsx"] }, + { "name": "getAttendanceAction", "permission": "ATTENDANCE_READ", "signature": "(params?: AttendanceQueryParams) => Promise>", "purpose": "分页查询考勤记录(按 scope 过滤)", "deps": ["requirePermission", "data-access.getAttendanceRecords"], "usedBy": ["teacher/attendance/page.tsx", "admin/attendance/page.tsx"] }, + { "name": "getStudentAttendanceAction", "permission": "ATTENDANCE_READ", "signature": "(studentId: string) => Promise>", "purpose": "获取学生考勤汇总(含 DataScope 二次校验:class_members 仅查自己,children 仅查子女)", "deps": ["requirePermission", "data-access-stats.getStudentAttendanceSummary"], "usedBy": ["student/attendance/page.tsx", "parent/attendance/page.tsx"] }, + { "name": "getClassAttendanceStatsAction", "permission": "ATTENDANCE_READ", "signature": "(classId: string, startDate?: string, endDate?: string) => Promise>", "purpose": "获取班级考勤统计", "deps": ["requirePermission", "data-access-stats.getClassAttendanceStats"], "usedBy": ["teacher/attendance/stats/page.tsx"] }, + { "name": "getClassAttendanceForDateAction", "permission": "ATTENDANCE_READ", "signature": "(classId: string, date: string) => Promise>", "purpose": "获取班级指定日期考勤(用于点名页加载已有记录)", "deps": ["requirePermission", "data-access.getClassAttendanceForDate"], "usedBy": ["attendance-sheet.tsx"] }, + { "name": "saveAttendanceRulesAction", "permission": "ATTENDANCE_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "保存班级考勤规则(upsert)", "deps": ["requirePermission", "data-access.upsertAttendanceRules", "revalidatePath"], "usedBy": ["attendance-rules-form.tsx"] }, + { "name": "getAttendanceRulesAction", "permission": "ATTENDANCE_READ", "signature": "(classId?: string) => Promise>", "purpose": "获取班级考勤规则", "deps": ["requirePermission", "data-access.getAttendanceRules"], "usedBy": ["attendance-rules-form.tsx"] } + ], + "dataAccess": [ + { "name": "getAttendanceRecords", "signature": "(params: AttendanceQueryParams & { scope: DataScope; currentUserId?: string }) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "shared.db.schema.users", "shared.db.schema.classes", "types.DataScope"], "usedBy": ["getAttendanceAction"] }, + { "name": "getClassAttendanceForDate", "signature": "(classId: string, date: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "shared.db.schema.users", "shared.db.schema.classes"], "usedBy": ["getClassAttendanceForDateAction"] }, + { "name": "createAttendanceRecord", "signature": "(data: RecordAttendanceInput, recordedBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "@paralleldrive/cuid2"], "usedBy": ["recordAttendanceAction"] }, + { "name": "batchCreateAttendanceRecords", "signature": "(data: BatchRecordAttendanceInput, recordedBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "@paralleldrive/cuid2"], "usedBy": ["batchRecordAttendanceAction"] }, + { "name": "updateAttendanceRecord", "signature": "(id: string, data: UpdateAttendanceInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords"], "usedBy": ["updateAttendanceAction"] }, + { "name": "deleteAttendanceRecord", "signature": "(id: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords"], "usedBy": ["deleteAttendanceAction"] }, + { "name": "getClassStudentsForAttendance", "signature": "(classId: string) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classEnrollments", "shared.db.schema.users"], "usedBy": ["attendance-sheet.tsx"] }, + { "name": "getAttendanceRules", "signature": "(classId?: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRules"], "usedBy": ["getAttendanceRulesAction"] }, + { "name": "upsertAttendanceRules", "signature": "(data: AttendanceRuleInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.attendanceRules", "@paralleldrive/cuid2"], "usedBy": ["saveAttendanceRulesAction"] }, + { "name": "getStudentAttendanceSummary", "signature": "(studentId: string, startDate?: string, endDate?: string) => Promise", "file": "data-access-stats.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "shared.db.schema.classes", "shared.db.schema.users"], "usedBy": ["getStudentAttendanceAction", "student/attendance/page.tsx", "parent/attendance/page.tsx"] }, + { "name": "getClassAttendanceStats", "signature": "(classId: string, startDate?: string, endDate?: string) => Promise", "file": "data-access-stats.ts", "deps": ["shared.db", "shared.db.schema.attendanceRecords", "shared.db.schema.classes", "shared.db.schema.users"], "usedBy": ["getClassAttendanceStatsAction", "teacher/attendance/stats/page.tsx"] } + ], + "schemas": [ + { "name": "AttendanceStatusEnum", "type": "zod", "file": "schema.ts", "definition": "enum('present','absent','late','early_leave','excused')", "usedBy": ["RecordAttendanceSchema", "BatchRecordAttendanceSchema", "UpdateAttendanceSchema"] }, + { "name": "RecordAttendanceSchema", "type": "zod", "file": "schema.ts", "definition": "{ studentId, classId, scheduleId?, date, status, remark? }", "usedBy": ["recordAttendanceAction"] }, + { "name": "BatchRecordAttendanceSchema", "type": "zod", "file": "schema.ts", "definition": "{ records: [{ studentId, classId, scheduleId?, date, status, remark? }] }", "usedBy": ["batchRecordAttendanceAction"] }, + { "name": "UpdateAttendanceSchema", "type": "zod", "file": "schema.ts", "definition": "{ status?, remark?, scheduleId? }", "usedBy": ["updateAttendanceAction"] }, + { "name": "AttendanceRuleSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId, lateThresholdMinutes?, earlyLeaveThresholdMinutes?, enableAutoMark? }", "usedBy": ["saveAttendanceRulesAction"] } + ], + "types": [ + { "name": "AttendanceStatus", "type": "type", "file": "types.ts", "definition": "'present' | 'absent' | 'late' | 'early_leave' | 'excused'", "usedBy": ["attendance/data-access", "attendance/components"] }, + { "name": "AttendanceRecord", "type": "type", "file": "types.ts", "definition": "考勤记录完整类型", "usedBy": ["attendance/data-access"] }, + { "name": "AttendanceListItem", "type": "type", "file": "types.ts", "definition": "{ id, studentId, studentName, classId, className, scheduleId, date, status, remark, recordedBy, recorderName, createdAt }", "usedBy": ["attendance/components", "页面"] }, + { "name": "AttendanceStats", "type": "type", "file": "types.ts", "definition": "{ total, present, absent, late, earlyLeave, excused, presentRate, lateRate }", "usedBy": ["attendance-stats-card.tsx"] }, + { "name": "StudentAttendanceSummary", "type": "type", "file": "types.ts", "definition": "{ studentId, studentName, stats: AttendanceStats, recentRecords: AttendanceListItem[] }", "usedBy": ["student-attendance-view.tsx"] }, + { "name": "ClassAttendanceSummary", "type": "type", "file": "types.ts", "definition": "{ classId, className, date, stats: AttendanceStats, studentRecords: AttendanceListItem[] }", "usedBy": ["teacher/attendance/stats"] }, + { "name": "AttendanceRule", "type": "type", "file": "types.ts", "definition": "{ id, classId, lateThresholdMinutes, earlyLeaveThresholdMinutes, enableAutoMark, createdAt, updatedAt }", "usedBy": ["attendance-rules-form.tsx"] }, + { "name": "AttendanceQueryParams", "type": "type", "file": "types.ts", "definition": "{ classId?, studentId?, date?, startDate?, endDate?, status?, page?, pageSize? }", "usedBy": ["getAttendanceRecords", "getAttendanceAction"] }, + { "name": "PaginatedAttendanceResult", "type": "type", "file": "types.ts", "definition": "{ items: AttendanceListItem[], total, page, pageSize, totalPages }", "usedBy": ["getAttendanceRecords", "getAttendanceAction"] }, + { "name": "ATTENDANCE_STATUS_LABELS", "type": "const", "file": "types.ts", "definition": "状态中文标签常量", "usedBy": ["attendance/components"] }, + { "name": "ATTENDANCE_STATUS_COLORS", "type": "const", "file": "types.ts", "definition": "状态颜色常量(用于 Badge)", "usedBy": ["attendance/components"] } + ], + "components": [ + { "name": "AttendanceSheet", "file": "components/attendance-sheet.tsx", "purpose": "批量点名表单(班级/日期选择器 + 学生表格 + 每行状态 Select + 全部标记到场按钮)" }, + { "name": "AttendanceRecordList", "file": "components/attendance-record-list.tsx", "purpose": "考勤记录列表表格(含删除确认对话框)" }, + { "name": "AttendanceStatsCard", "file": "components/attendance-stats-card.tsx", "purpose": "统计卡片(总数、到场、缺勤、迟到、早退、请假、出勤率、迟到率)" }, + { "name": "AttendanceFilters", "file": "components/attendance-filters.tsx", "purpose": "URL 同步筛选器(班级、状态、日期)" }, + { "name": "StudentAttendanceView", "file": "components/student-attendance-view.tsx", "purpose": "学生/家长视图(统计卡片 + 最近记录表格)" }, + { "name": "AttendanceRulesForm", "file": "components/attendance-rules-form.tsx", "purpose": "考勤规则配置表单(班级选择器、迟到/早退阈值、自动标记勾选)" } + ] + } + }, + "scheduling": { + "path": "src/modules/scheduling", + "description": "排课与调课:管理员配置班级排课规则(每日课时、连续课时、午休、上下学时间、避免背靠背、科目均衡),自动排课引擎按规则生成周课表,调课/代课申请与审批流程,课表冲突检测。", + "exports": { + "actions": [ + { "name": "saveSchedulingRulesAction", "permission": "SCHEDULE_ADJUST", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "保存班级排课规则(upsert,classId 为空时为全局规则)", "deps": ["requirePermission", "data-access.upsertSchedulingRules", "revalidatePath"], "usedBy": ["scheduling-rules-form.tsx"] }, + { "name": "autoScheduleAction", "permission": "SCHEDULE_AUTO", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "根据规则与科目分配生成预览课表(不落库)", "deps": ["requirePermission", "data-access.getSchedulingRules", "data-access.getClassSubjectsForScheduling", "data-access.getClassroomsForScheduling", "auto-scheduler.autoSchedule", "auto-scheduler.buildDefaultTimeSlots", "revalidatePath"], "usedBy": ["auto-schedule-panel.tsx"] }, + { "name": "applyAutoScheduleAction", "permission": "SCHEDULE_AUTO", "signature": "(classId: string, schedules: Array<{ weekday, startTime, endTime, course, location }>) => Promise>", "purpose": "将生成的课表写入 classSchedule 表(先删除该班旧课表再插入新课表,事务)", "deps": ["requirePermission", "shared.db", "shared.db.schema.classSchedule", "@paralleldrive/cuid2", "revalidatePath"], "usedBy": ["auto-schedule-panel.tsx"] }, + { "name": "requestScheduleChangeAction", "permission": "SCHEDULE_ADJUST", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", "purpose": "提交调课/代课申请(status=pending)", "deps": ["requirePermission", "data-access.createScheduleChange", "revalidatePath"], "usedBy": ["schedule-change-form.tsx"] }, + { "name": "approveScheduleChangeAction", "permission": "SCHEDULE_AUTO", "signature": "(changeId: string) => Promise", "purpose": "审批通过调课申请(status=approved)", "deps": ["requirePermission", "data-access.updateScheduleChangeStatus", "revalidatePath"], "usedBy": ["schedule-change-list.tsx"] }, + { "name": "rejectScheduleChangeAction", "permission": "SCHEDULE_AUTO", "signature": "(changeId: string, reason?: string) => Promise", "purpose": "驳回调课申请(status=rejected)", "deps": ["requirePermission", "data-access.updateScheduleChangeStatus", "revalidatePath"], "usedBy": ["schedule-change-list.tsx"] }, + { "name": "getScheduleChangesAction", "permission": "SCHEDULE_ADJUST", "signature": "(params: ScheduleChangeQueryParams) => Promise>", "purpose": "查询调课申请列表(可按 classId/status/requesterId 过滤)", "deps": ["requirePermission", "data-access.getScheduleChanges"], "usedBy": ["admin/scheduling/changes/page.tsx", "teacher/schedule-changes/page.tsx"] }, + { "name": "getClassConflictsAction", "permission": "SCHEDULE_ADJUST", "signature": "(classId: string) => Promise>", "purpose": "检测班级课表时间重叠冲突", "deps": ["requirePermission", "data-access.getClassConflicts"], "usedBy": ["schedule-conflicts-view.tsx"] } + ], + "dataAccess": [ + { "name": "getSchedulingRules", "signature": "(classId?: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.schedulingRules"], "usedBy": ["saveSchedulingRulesAction", "autoScheduleAction", "admin/scheduling/rules/page.tsx"] }, + { "name": "upsertSchedulingRules", "signature": "(data: SchedulingRuleInput) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.schedulingRules", "@paralleldrive/cuid2"], "usedBy": ["saveSchedulingRulesAction"] }, + { "name": "getScheduleChanges", "signature": "(params: ScheduleChangeQueryParams) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.scheduleChanges", "shared.db.schema.classes", "shared.db.schema.users"], "usedBy": ["getScheduleChangesAction", "admin/scheduling/changes/page.tsx", "teacher/schedule-changes/page.tsx"] }, + { "name": "createScheduleChange", "signature": "(data: ScheduleChangeInput, requestedBy: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.scheduleChanges", "@paralleldrive/cuid2"], "usedBy": ["requestScheduleChangeAction"] }, + { "name": "updateScheduleChangeStatus", "signature": "(id: string, status: 'approved'|'rejected'|'completed', approverId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.scheduleChanges"], "usedBy": ["approveScheduleChangeAction", "rejectScheduleChangeAction"] }, + { "name": "getClassConflicts", "signature": "(classId: string) => Promise", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classSchedule"], "usedBy": ["getClassConflictsAction"] }, + { "name": "getAdminClassesForScheduling", "signature": "() => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classes"], "usedBy": ["admin/scheduling/rules/page.tsx", "admin/scheduling/auto/page.tsx", "admin/scheduling/changes/page.tsx", "teacher/schedule-changes/page.tsx"] }, + { "name": "getTeachersForScheduling", "signature": "() => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.users", "shared.db.schema.classSubjectTeachers"], "usedBy": ["teacher/schedule-changes/page.tsx"] }, + { "name": "getClassroomsForScheduling", "signature": "() => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classrooms"], "usedBy": ["autoScheduleAction"] }, + { "name": "getClassSubjectsForScheduling", "signature": "(classId: string) => Promise>", "file": "data-access.ts", "deps": ["shared.db", "shared.db.schema.classSubjectTeachers", "shared.db.schema.subjects"], "usedBy": ["autoScheduleAction"] } + ], + "autoScheduler": [ + { "name": "autoSchedule", "signature": "(params: AutoScheduleParams) => AutoScheduleResult", "file": "auto-scheduler.ts", "purpose": "贪心+冲突检测排课算法:按科目每周课时降序,为每节课选择第一个满足约束的时段(午休、每日窗口、班级/教师/教室冲突、每日最大课时、避免背靠背)", "deps": ["findOptimalSlot", "validateSchedule"], "usedBy": ["autoScheduleAction"] }, + { "name": "findOptimalSlot", "signature": "(args) => TimeSlot | null", "file": "auto-scheduler.ts", "purpose": "在候选时段中找到第一个满足所有约束的时段", "usedBy": ["autoSchedule"] }, + { "name": "validateSchedule", "signature": "(schedules: GeneratedSchedule[], rules: SchedulingRule) => ScheduleConflict[]", "file": "auto-scheduler.ts", "purpose": "校验生成的课表是否违反规则,返回冲突列表", "usedBy": ["autoSchedule"] }, + { "name": "buildDefaultTimeSlots", "signature": "(morningStart, afternoonEnd, lunchBreakStart, lunchBreakEnd) => TimeSlot[]", "file": "auto-scheduler.ts", "purpose": "根据上下学时间和午休时间构建默认时段(周一至周五,上午4节+下午4节)", "usedBy": ["autoScheduleAction"] } + ], + "schemas": [ + { "name": "SchedulingRuleSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId, maxDailyHours?, maxContinuousHours?, lunchBreakStart?, lunchBreakEnd?, morningStart?, afternoonEnd?, avoidBackToBack?, balancedSubjects? }", "usedBy": ["saveSchedulingRulesAction"] }, + { "name": "ScheduleChangeSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId, originalScheduleId?, originalTeacherId?, substituteTeacherId?, originalDate?, newDate?, newStartTime?, newEndTime?, reason }", "usedBy": ["requestScheduleChangeAction"] }, + { "name": "AutoScheduleParamsSchema", "type": "zod", "file": "schema.ts", "definition": "{ classId, rules: SchedulingRuleSchema, subjects: [{ subjectId, subjectName, weeklyHours, teacherId? }], teachers: [{ id, name }], classrooms: [{ id, name }], timeSlots: [{ weekday, startTime, endTime }] }", "usedBy": ["autoScheduleAction"] }, + { "name": "ScheduleChangeStatusEnum", "type": "zod", "file": "schema.ts", "definition": "enum('pending','approved','rejected','completed')", "usedBy": ["ScheduleChangeSchema"] }, + { "name": "ApproveScheduleChangeSchema", "type": "zod", "file": "schema.ts", "definition": "{ changeId, reason? }", "usedBy": ["approveScheduleChangeAction"] } + ], + "types": [ + { "name": "ScheduleChangeStatus", "type": "type", "file": "types.ts", "definition": "'pending' | 'approved' | 'rejected' | 'completed'", "usedBy": ["scheduling/data-access", "scheduling/components"] }, + { "name": "SchedulingRule", "type": "type", "file": "types.ts", "definition": "{ id, classId, maxDailyHours, maxContinuousHours, lunchBreakStart, lunchBreakEnd, morningStart, afternoonEnd, avoidBackToBack, balancedSubjects, createdAt, updatedAt }", "usedBy": ["scheduling-rules-form.tsx", "auto-scheduler.ts"] }, + { "name": "ScheduleChange", "type": "type", "file": "types.ts", "definition": "调课申请完整类型", "usedBy": ["scheduling/data-access"] }, + { "name": "ScheduleChangeListItem", "type": "type", "file": "types.ts", "definition": "{ ...ScheduleChange, className, originalTeacherName, substituteTeacherName, requesterName, approverName }", "usedBy": ["schedule-change-list.tsx", "页面"] }, + { "name": "TimeSlot", "type": "type", "file": "types.ts", "definition": "{ weekday, startTime, endTime }", "usedBy": ["auto-scheduler.ts"] }, + { "name": "ScheduleConflict", "type": "type", "file": "types.ts", "definition": "{ type: 'teacher_overlap'|'classroom_overlap'|'class_overlap'|'rule_violation', description, scheduleIds }", "usedBy": ["auto-schedule-result.tsx", "schedule-conflicts-view.tsx"] }, + { "name": "AutoScheduleResult", "type": "type", "file": "types.ts", "definition": "{ success, scheduledCount, conflictCount, conflicts, schedules: GeneratedSchedule[] }", "usedBy": ["auto-schedule-panel.tsx", "auto-schedule-result.tsx"] }, + { "name": "GeneratedSchedule", "type": "type", "file": "types.ts", "definition": "{ classId, weekday, startTime, endTime, course, location, teacherId, subjectId }", "usedBy": ["auto-scheduler.ts", "auto-schedule-result.tsx"] }, + { "name": "AutoScheduleParams", "type": "type", "file": "types.ts", "definition": "{ classId, rules, subjects, teachers, classrooms, timeSlots }", "usedBy": ["auto-scheduler.ts", "autoScheduleAction"] }, + { "name": "ScheduleChangeQueryParams", "type": "type", "file": "types.ts", "definition": "{ classId?, status?, requesterId? }", "usedBy": ["getScheduleChanges", "getScheduleChangesAction"] }, + { "name": "SCHEDULE_CHANGE_STATUS_LABELS", "type": "const", "file": "types.ts", "definition": "状态英文标签常量", "usedBy": ["schedule-change-list.tsx"] }, + { "name": "SCHEDULE_CHANGE_STATUS_COLORS", "type": "const", "file": "types.ts", "definition": "状态颜色常量(用于 Badge)", "usedBy": ["schedule-change-list.tsx"] } + ], + "components": [ + { "name": "SchedulingRulesForm", "file": "components/scheduling-rules-form.tsx", "purpose": "排课规则配置表单(班级选择器、每日最大课时、连续课时、午休时间、上下学时间、避免背靠背、科目均衡)" }, + { "name": "AutoSchedulePanel", "file": "components/auto-schedule-panel.tsx", "purpose": "自动排课面板(班级选择→预览→应用流程,调用 autoScheduleAction 和 applyAutoScheduleAction)" }, + { "name": "AutoScheduleResultView", "file": "components/auto-schedule-result.tsx", "purpose": "排课结果预览(课表表格 + 冲突/警告列表)" }, + { "name": "ScheduleChangeForm", "file": "components/schedule-change-form.tsx", "purpose": "调课/代课申请表单(班级、原任课教师、代课教师、原日期、新日期、新时间、原因)" }, + { "name": "ScheduleChangeList", "file": "components/schedule-change-list.tsx", "purpose": "调课申请列表表格(含审批/驳回对话框,canApprove 控制是否显示审批按钮)" }, + { "name": "ScheduleConflictsView", "file": "components/schedule-conflicts-view.tsx", "purpose": "冲突检测视图(班级选择器 + 检测按钮 + 冲突结果列表)" } ] } } @@ -521,8 +1442,18 @@ "classes": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}}, "school": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "types"], "auth": ["auth"]}}, "dashboard": {"dependsOn": ["shared", "auth", "homework", "classes"], "uses": {"shared": ["db", "types"], "auth": ["auth"], "homework": ["data-access.getTeacherGradeTrends", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getTeacherClasses", "data-access.getStudentClasses", "data-access.getStudentSchedule"]}}, - "layout": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["hooks.usePermission"], "auth": ["useSession"]}}, - "settings": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard", "ai", "types"], "auth": ["auth"]}} + "layout": {"dependsOn": ["shared", "auth", "messaging"], "uses": {"shared": ["hooks.usePermission", "components.global-search.GlobalSearch"], "auth": ["useSession"], "messaging": ["components.notification-dropdown"]}}, + "settings": {"dependsOn": ["shared", "auth", "messaging"], "uses": {"shared": ["db", "auth-guard", "ai", "types", "components.ui.switch"], "auth": ["auth"], "messaging": ["notification-preferences.getNotificationPreferences", "actions.getNotificationPreferencesAction", "actions.updateNotificationPreferencesAction"]}}, + "users": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requireAuth", "auth-guard.requirePermission", "db.schema.users", "db.schema.roles", "db.schema.usersToRoles", "db.schema.classes", "db.schema.classEnrollments", "types.permissions", "types.action-state", "lib.excel"], "auth": ["auth"]}}, + "audit": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.auditLogs", "db.schema.loginLogs", "db.schema.dataChangeLogs", "types.permissions", "lib.excel"], "auth": ["auth"]}}, + "announcements": {"dependsOn": ["shared", "auth", "school"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.announcements", "types.permissions"], "auth": ["auth"], "school": ["data-access.getGrades"]}}, + "files": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requireAuth", "auth-guard.requirePermission", "types.permissions", "lib.file-storage", "lib.storage-provider"], "auth": ["auth"]}}, + "course-plans": {"dependsOn": ["shared", "auth", "school", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.coursePlans", "db.schema.coursePlanItems", "db.schema.classes", "db.schema.subjects", "db.schema.users", "types.permissions", "types.action-state"], "auth": ["auth"], "school": ["data-access.getAcademicYears"], "classes": ["data-access.getAdminClasses", "data-access.getStaffOptions"]}}, + "grades": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "types.permissions", "types.action-state", "db.schema.gradeRecords", "db.schema.classes", "db.schema.classEnrollments", "db.schema.subjects", "db.schema.users", "lib.excel"], "auth": ["auth"]}}, + "parent": {"dependsOn": ["shared", "auth", "homework", "classes", "grades"], "uses": {"shared": ["db", "auth-guard.requireAuth", "db.schema.parentStudentRelations", "db.schema.users", "db.schema.grades", "db.schema.classEnrollments", "db.schema.classes", "types"], "auth": ["auth"], "homework": ["data-access.getStudentHomeworkAssignments", "data-access.getStudentDashboardGrades"], "classes": ["data-access.getStudentClasses", "data-access.getStudentSchedule"], "grades": ["data-access.getStudentGradeSummary"]}}, + "messaging": {"dependsOn": ["shared", "auth"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.requireAuth", "db.schema.messages", "db.schema.messageNotifications", "db.schema.notificationPreferences", "db.schema.users", "db.schema.classEnrollments", "db.schema.classes", "db.schema.grades", "types.permissions", "types.action-state"], "auth": ["auth"]}}, + "attendance": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "db.schema.attendanceRecords", "db.schema.attendanceRules", "db.schema.classEnrollments", "db.schema.users", "db.schema.classes", "types.permissions", "types.action-state", "types.DataScope"], "auth": ["auth"], "classes": ["data-access.getTeacherClasses", "data-access.getAdminClasses"]}}, + "scheduling": {"dependsOn": ["shared", "auth", "classes"], "uses": {"shared": ["db", "auth-guard.requirePermission", "auth-guard.getAuthContext", "db.schema.schedulingRules", "db.schema.scheduleChanges", "db.schema.classSchedule", "db.schema.classes", "db.schema.users", "db.schema.classSubjectTeachers", "db.schema.subjects", "db.schema.classrooms", "types.permissions", "types.action-state"], "auth": ["auth"], "classes": []}} }, "parameterFlowChains": { "userId": { @@ -536,7 +1467,9 @@ "auth-guard.ts → 查询 classSubjectTeachers/grades (获取DataScope)", "exams/actions.ts → 作为 creatorId 写入 exams 表", "homework/actions.ts → 作为 creatorId 写入 homeworkAssignments 表", - "classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)" + "classes/data-access.ts → getTeacherClasses(teacherId), getGradeManagedClasses(userId)", + "grades/actions.ts → 作为 recordedBy 写入 gradeRecords 表", + "attendance/actions.ts → 作为 recordedBy 写入 attendanceRecords 表" ] }, "examId": { @@ -558,18 +1491,25 @@ "classes/data-access.getClassSchedule(classId) → 课表", "classes/data-access.getClassHomeworkInsights(classId) → 作业洞察", "homework/data-access.getHomeworkAssignments({ classId }) → 过滤作业", - "auth-guard.ts → classSubjectTeachers 查询 → DataScope.class_taught.classIds" + "auth-guard.ts → classSubjectTeachers 查询 → DataScope.class_taught.classIds", + "grades/data-access.getGradeRecords({ classId }) → 过滤成绩列表", + "grades/data-access.getClassGradeStats(classId) → 班级成绩统计", + "grades/data-access.getClassRanking(classId) → 班级排名", + "attendance/data-access.getAttendanceRecords({ classId }) → 过滤考勤记录", + "attendance/data-access.getClassAttendanceForDate(classId, date) → 班级指定日期考勤", + "attendance/data-access-stats.getClassAttendanceStats(classId) → 班级考勤统计" ] }, "permission": { - "origin": "shared/types/permissions.ts Permissions 常量定义", + "origin": "shared/types/permissions.ts Permissions 常量定义(47 个权限点,含 FILE_UPLOAD/FILE_READ/FILE_DELETE、GRADE_RECORD_MANAGE/GRADE_RECORD_READ、ATTENDANCE_MANAGE/ATTENDANCE_READ、MESSAGE_SEND/MESSAGE_READ/MESSAGE_DELETE、SCHEDULE_AUTO/SCHEDULE_ADJUST)", "flow": [ - "shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射", + "shared/lib/permissions.ts ROLE_PERMISSIONS → 角色到权限映射(admin/teacher 拥有全部 FILE_* 及 GRADE_RECORD_MANAGE/READ,student/parent/grade_head/teaching_head 拥有 GRADE_RECORD_READ;admin/teacher 拥有 ATTENDANCE_MANAGE+ATTENDANCE_READ,student/parent/grade_head/teaching_head 拥有 ATTENDANCE_READ;admin/teacher/parent/grade_head/teaching_head 拥有 MESSAGE_SEND/READ/DELETE,student 拥有 MESSAGE_READ/DELETE 但无 MESSAGE_SEND;admin 拥有 SCHEDULE_AUTO+SCHEDULE_ADJUST,teacher 无排课权限)", "auth.ts JWT callback → resolvePermissions(roleNames) → token.permissions", "proxy.ts middleware → token.permissions → 路由权限检查", - "auth-guard.ts requirePermission(permission) → Server Action权限断言", - "use-permission.ts hasPermission(permission) → 客户端条件渲染", - "layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤" + "auth-guard.ts requirePermission(permission) → Server Action权限断言(如 /api/files/[id] DELETE 使用 FILE_DELETE;grades/actions.ts 使用 GRADE_RECORD_MANAGE/READ;messaging/actions.ts 使用 MESSAGE_SEND/READ/DELETE;attendance/actions.ts 使用 ATTENDANCE_MANAGE/READ;scheduling/actions.ts 使用 SCHEDULE_AUTO/SCHEDULE_ADJUST)", + "auth-guard.ts requireAuth() → 仅校验登录(如 /api/upload POST、/api/files/[id] GET、messaging 通知读取 actions)", + "use-permission.ts hasPermission(permission) → 客户端条件渲染(如 file-list.tsx 删除按钮可见性;message-list/detail.tsx 写消息/删除按钮可见性)", + "layout/config/navigation.ts NavItem.permission → 侧边栏菜单过滤(Grades 菜单项使用 GRADE_RECORD_READ;Messages 菜单项使用 MESSAGE_READ;Attendance 菜单项 teacher 使用 ATTENDANCE_MANAGE,student/parent 使用 ATTENDANCE_READ;Scheduling 菜单项 admin 使用 SCHEDULE_ADJUST/SCHEDULE_AUTO,teacher Schedule Changes 使用 SCHEDULE_ADJUST)" ] }, "dataScope": { @@ -579,17 +1519,43 @@ "exams/data-access.getExams({ scope }) → 行级过滤", "homework/data-access.getHomeworkAssignments({ scope }) → 行级过滤", "dashboard/data-access.getAdminDashboardData(scope) → 统计过滤", - "exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属" + "exams/actions.ts update/delete → scope.type !== 'all' 时校验资源归属", + "grades/data-access.getGradeRecords({ scope }) → 行级过滤(class_taught 限制所教班级,class_members 限制学生本人,children 限制子女)", + "attendance/data-access.getAttendanceRecords({ scope }) → 行级过滤(class_taught 按教师班级过滤,children 按子女过滤,class_members 仅查自己,all 查全部)", + "attendance/actions.ts getStudentAttendanceAction → 对 class_members/children 进行 DataScope 二次校验" ] } }, "routes": { "auth": { "/login": {"component": "LoginForm", "type": "client", "module": "auth"}, - "/register": {"component": "RegisterForm + registerAction", "type": "server", "module": "auth"} + "/register": {"component": "RegisterForm + registerAction", "type": "server", "module": "auth", "description": "注册页面(含未成年人信息保护、隐私政策/用户协议同意勾选)"}, + "/privacy": {"component": "PrivacyPage", "type": "server", "module": "auth", "description": "隐私政策页面(信息收集/使用/保护、用户权利、Cookie、未成年人保护条款、联系方式)"}, + "/terms": {"component": "TermsPage", "type": "server", "module": "auth", "description": "用户协议页面(服务说明、注册、行为规范、知识产权、免责、变更终止、法律适用)"} }, "admin": { - "/admin/dashboard": {"component": "AdminDashboardView", "type": "server", "dataAccess": ["dashboard/data-access.getAdminDashboardData"], "permission": "school:manage"} + "/admin/dashboard": {"component": "AdminDashboardView", "type": "server", "dataAccess": ["dashboard/data-access.getAdminDashboardData"], "permission": "school:manage"}, + "/admin/school": {"component": "重定向", "type": "server", "redirect": "/admin/school/classes", "permission": "school:manage"}, + "/admin/school/schools": {"component": "SchoolsClient", "type": "client", "module": "school", "permission": "school:manage"}, + "/admin/school/grades": {"component": "GradesClient", "type": "client", "module": "school", "permission": "grade:manage"}, + "/admin/school/grades/insights": {"component": "年级作业洞察", "type": "server", "dataAccess": ["classes/data-access.getGradeHomeworkInsights"], "permission": "grade:manage"}, + "/admin/school/departments": {"component": "DepartmentsClient", "type": "client", "module": "school", "permission": "school:manage"}, + "/admin/school/classes": {"component": "AdminClassesClient", "type": "client", "module": "classes", "permission": "school:manage"}, + "/admin/school/academic-year": {"component": "AcademicYearClient", "type": "client", "module": "school", "permission": "school:manage"}, + "/admin/audit-logs": {"component": "AuditLogView", "type": "server", "module": "audit", "dataAccess": ["audit/data-access.getAuditLogs", "audit/data-access.getAuditModuleOptions"], "permission": "audit_log:read"}, + "/admin/audit-logs/login-logs": {"component": "LoginLogView", "type": "server", "module": "audit", "dataAccess": ["audit/data-access.getLoginLogs"], "permission": "audit_log:read"}, + "/admin/announcements": {"component": "AdminAnnouncementsView", "type": "server", "module": "announcements", "dataAccess": ["announcements/data-access.getAnnouncements", "school/data-access.getGrades"], "actions": ["createAnnouncementAction"], "permission": "announcement:manage"}, + "/admin/announcements/[id]": {"component": "AnnouncementForm (edit)", "type": "server", "module": "announcements", "dataAccess": ["announcements/data-access.getAnnouncementById", "school/data-access.getGrades"], "actions": ["updateAnnouncementAction"], "permission": "announcement:manage"}, + "/admin/files": {"component": "AdminFilesView", "type": "server", "module": "files", "dataAccess": ["files/data-access.getAllFileAttachments"], "permission": "file:read"}, + "/admin/course-plans": {"component": "CoursePlanList", "type": "client", "module": "course-plans", "dataAccess": ["course-plans/data-access.getCoursePlans"], "permission": "course_plan:manage"}, + "/admin/course-plans/create": {"component": "CoursePlanForm (create)", "type": "client", "module": "course-plans", "actions": ["createCoursePlanAction"], "dataAccess": ["classes/data-access.getAdminClasses", "course-plans/data-access.getSubjectOptions", "classes/data-access.getStaffOptions", "school/data-access.getAcademicYears"], "permission": "course_plan:manage"}, + "/admin/course-plans/[id]": {"component": "CoursePlanDetail", "type": "client", "module": "course-plans", "dataAccess": ["course-plans/data-access.getCoursePlanById"], "actions": ["deleteCoursePlanAction", "createCoursePlanItemAction", "updateCoursePlanItemAction", "deleteCoursePlanItemAction", "toggleCoursePlanItemCompletedAction"], "permission": "course_plan:manage"}, + "/admin/course-plans/[id]/edit": {"component": "CoursePlanForm (edit)", "type": "client", "module": "course-plans", "actions": ["updateCoursePlanAction"], "dataAccess": ["course-plans/data-access.getCoursePlanById", "classes/data-access.getAdminClasses", "course-plans/data-access.getSubjectOptions", "classes/data-access.getStaffOptions", "school/data-access.getAcademicYears"], "permission": "course_plan:manage"}, + "/admin/attendance": {"component": "AttendanceRecordList", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access.getAttendanceRecords (scope=all)", "classes/data-access.getAdminClasses"], "permission": "attendance:manage", "description": "管理员考勤总览(权限:requirePermission(ATTENDANCE_MANAGE))"}, + "/admin/users/import": {"component": "UserImportPage (含 UserImportDialog)", "type": "server", "module": "users", "actions": ["users/actions.downloadUserTemplateAction", "users/actions.importUsersAction"], "permission": "user:manage", "description": "用户批量导入页面(说明卡片+字段文档表+导入对话框;权限:requirePermission(USER_MANAGE))"}, + "/admin/scheduling/rules": {"component": "SchedulingRulesForm", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getSchedulingRules"], "actions": ["saveSchedulingRulesAction"], "permission": "schedule:adjust", "description": "排课规则配置页面(权限:requirePermission(SCHEDULE_ADJUST))"}, + "/admin/scheduling/auto": {"component": "AutoSchedulePanel + AutoScheduleResultView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling"], "actions": ["autoScheduleAction", "applyAutoScheduleAction"], "permission": "schedule:auto", "description": "自动排课页面(预览+应用;权限:requirePermission(SCHEDULE_AUTO))"}, + "/admin/scheduling/changes": {"component": "ScheduleChangeList + ScheduleConflictsView", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getScheduleChanges"], "actions": ["approveScheduleChangeAction", "rejectScheduleChangeAction", "getClassConflictsAction"], "permission": "schedule:adjust", "description": "调课申请审批+冲突检测页面(权限:requirePermission(SCHEDULE_ADJUST);审批操作需 SCHEDULE_AUTO)"} }, "teacher": { "/teacher/dashboard": {"component": "TeacherDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (teacher)", "homework/data-access.getTeacherGradeTrends", "classes/data-access.getTeacherClasses"], "permission": "exam:read"}, @@ -600,24 +1566,169 @@ "/teacher/textbooks/[id]": {"component": "TextbookReader", "type": "client", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"}, "/teacher/classes/my": {"component": "ClassList", "type": "server", "dataAccess": ["classes/data-access.getTeacherClasses"], "permission": "class:read"}, "/teacher/classes/schedule": {"component": "ClassSchedule", "type": "server", "dataAccess": ["classes/data-access.getClassSchedule"], "permission": "class:read"}, - "/teacher/classes/students": {"component": "ClassStudents", "type": "server", "dataAccess": ["classes/data-access.getClassStudents"], "permission": "class:read"} + "/teacher/classes/students": {"component": "ClassStudents", "type": "server", "dataAccess": ["classes/data-access.getClassStudents"], "permission": "class:read"}, + "/teacher/classes": {"component": "重定向", "type": "server", "redirect": "/teacher/classes/my", "permission": "class:read"}, + "/teacher/classes/my/[id]": {"component": "班级详情", "type": "client", "module": "classes", "dataAccess": ["classes/data-access.getClassStudents", "classes/data-access.getClassSchedule", "classes/data-access.getClassHomeworkInsights"], "permission": "class:read"}, + "/teacher/homework": {"component": "重定向", "type": "server", "redirect": "/teacher/homework/assignments", "permission": "homework:create"}, + "/teacher/homework/assignments": {"component": "作业列表", "type": "server", "module": "homework", "dataAccess": ["homework/data-access.getHomeworkAssignments"], "permission": "homework:create"}, + "/teacher/homework/assignments/create": {"component": "HomeworkAssignmentForm", "type": "client", "module": "homework", "actions": ["createHomeworkAssignmentAction"], "permission": "homework:create"}, + "/teacher/homework/assignments/[id]": {"component": "作业详情+错误分析", "type": "client", "module": "homework", "dataAccess": ["homework/data-access.getHomeworkAssignmentById", "homework/data-access.getHomeworkAssignmentAnalytics"], "permission": "homework:create"}, + "/teacher/homework/assignments/[id]/submissions": {"component": "作业提交列表", "type": "server", "module": "homework", "dataAccess": ["homework/data-access.getHomeworkSubmissions"], "permission": "homework:create"}, + "/teacher/homework/submissions": {"component": "批改列表", "type": "server", "module": "homework", "dataAccess": ["homework/data-access.getHomeworkAssignmentReviewList"], "permission": "homework:grade"}, + "/teacher/homework/submissions/[submissionId]": {"component": "HomeworkGradingView", "type": "client", "module": "homework", "actions": ["gradeHomeworkSubmissionAction"], "dataAccess": ["homework/data-access.getHomeworkSubmissionDetails"], "permission": "homework:grade"}, + "/teacher/exams": {"component": "重定向", "type": "server", "redirect": "/teacher/exams/all", "permission": "exam:read"}, + "/teacher/exams/[id]/build": {"component": "ExamAssembly", "type": "client", "module": "exams", "permission": "exam:update"}, + "/teacher/exams/grading": {"component": "重定向", "type": "server", "redirect": "/teacher/homework/submissions", "permission": "homework:grade"}, + "/teacher/exams/grading/[submissionId]": {"component": "重定向", "type": "server", "redirect": "/teacher/homework/submissions", "permission": "homework:grade"}, + "/teacher/grades": {"component": "成绩管理首页", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getGradeRecordsAction"], "permission": "grade_record:read"}, + "/teacher/grades/entry": {"component": "批量成绩录入", "type": "server", "module": "grades", "actions": ["grades/actions.batchCreateGradeRecordsAction", "grades/actions.createGradeRecordAction"], "permission": "grade_record:manage"}, + "/teacher/grades/stats": {"component": "成绩统计报表", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getClassGradeStatsAction", "grades/actions.getClassRankingAction"], "permission": "grade_record:read"}, + "/teacher/course-plans": {"component": "CoursePlanList (teacher)", "type": "client", "module": "course-plans", "dataAccess": ["course-plans/data-access.getCoursePlans (filtered by teacherId)"], "permission": "course_plan:read"}, + "/teacher/course-plans/[id]": {"component": "CoursePlanDetail (teacher, read-only)", "type": "client", "module": "course-plans", "dataAccess": ["course-plans/data-access.getCoursePlanById"], "permission": "course_plan:read"}, + "/teacher/attendance": {"component": "AttendanceRecordList + AttendanceFilters", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access.getAttendanceRecords", "classes/data-access.getTeacherClasses"], "permission": "attendance:manage", "description": "教师考勤记录列表(权限:requirePermission(ATTENDANCE_MANAGE))"}, + "/teacher/attendance/sheet": {"component": "AttendanceSheet", "type": "client", "module": "attendance", "actions": ["batchRecordAttendanceAction", "getClassAttendanceForDateAction"], "dataAccess": ["attendance/data-access.getClassStudentsForAttendance"], "permission": "attendance:manage", "description": "批量点名页面(权限:requirePermission(ATTENDANCE_MANAGE))"}, + "/teacher/attendance/stats": {"component": "AttendanceStatsCard", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getClassAttendanceStats", "classes/data-access.getTeacherClasses"], "permission": "attendance:read", "description": "班级考勤统计(权限:requirePermission(ATTENDANCE_READ))"}, + "/teacher/schedule-changes": {"component": "ScheduleChangeForm + ScheduleChangeList", "type": "server", "module": "scheduling", "dataAccess": ["scheduling/actions.getAdminClassesForScheduling", "scheduling/actions.getTeachersForScheduling", "scheduling/actions.getScheduleChanges (requesterId=ctx.userId)"], "actions": ["requestScheduleChangeAction"], "permission": "schedule:adjust", "description": "教师调课/代课申请页面(提交申请+查看本人申请列表;权限:requirePermission(SCHEDULE_ADJUST);admin 角色查看全部申请)"} }, "student": { - "/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"} + "/student/dashboard": {"component": "StudentDashboardView", "type": "server", "dataAccess": ["dashboard/data-access (student)", "homework/data-access.getStudentDashboardGrades", "classes/data-access.getStudentClasses"], "permission": "homework:submit"}, + "/student/learning/assignments": {"component": "学生作业列表", "type": "server", "module": "homework", "dataAccess": ["homework/data-access.getStudentHomeworkAssignments"], "permission": "homework:submit"}, + "/student/learning/assignments/[assignmentId]": {"component": "学生作答/复习", "type": "client", "module": "homework", "actions": ["startHomeworkSubmissionAction", "saveHomeworkAnswerAction", "submitHomeworkAction"], "dataAccess": ["homework/data-access.getStudentHomeworkTakeData"], "permission": "homework:submit"}, + "/student/learning/courses": {"component": "StudentCoursesView", "type": "server", "permission": "homework:submit"}, + "/student/learning/textbooks": {"component": "学生教材列表(只读)", "type": "server", "module": "textbooks", "dataAccess": ["textbooks/data-access.getTextbooks"], "permission": "textbook:read"}, + "/student/learning/textbooks/[id]": {"component": "学生教材阅读(只读)", "type": "client", "module": "textbooks", "dataAccess": ["textbooks/data-access.getTextbookById", "getChaptersByTextbookId", "getKnowledgePointsByTextbookId"], "permission": "textbook:read"}, + "/student/schedule": {"component": "学生课表", "type": "server", "module": "classes", "dataAccess": ["classes/data-access.getStudentSchedule"], "permission": "homework:submit"}, + "/student/grades": {"component": "我的成绩", "type": "server", "module": "grades", "dataAccess": ["grades/actions.getStudentGradeSummaryAction"], "permission": "grade_record:read"}, + "/student/attendance": {"component": "StudentAttendanceView", "type": "server", "module": "attendance", "dataAccess": ["attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "学生考勤视图(统计卡片 + 最近记录;权限:requirePermission(ATTENDANCE_READ),DataScope.class_members 仅查自己)"} + }, + "management": { + "/management/grade/classes": {"component": "GradeClassesClient", "type": "client", "module": "classes", "permission": "grade:manage"}, + "/management/grade/insights": {"component": "年级作业洞察", "type": "server", "dataAccess": ["classes/data-access.getGradeHomeworkInsights"], "permission": "grade:manage"} }, "parent": { - "/parent/dashboard": {"component": "ParentDashboardView", "type": "client", "permission": "exam:read"} + "/parent/dashboard": {"component": "ParentDashboard", "type": "server", "module": "parent", "dataAccess": ["parent/data-access.getParentDashboardData"], "permission": "auth_required", "description": "家长仪表盘首页(问候语 + 子女卡片网格;权限:requireAuth())"}, + "/parent/children/[studentId]": {"component": "ChildDetailHeader + ChildDetailPanel", "type": "server", "module": "parent", "dataAccess": ["parent/data-access.getChildDashboardData"], "permission": "auth_required", "description": "子女详情页(头部 + 作业/成绩/课表面板;权限:requireAuth() + 二次校验 ctx.dataScope.childrenIds 包含 studentId)"}, + "/parent/grades": {"component": "子女成绩", "type": "server", "module": "grades", "dataAccess": ["grades/data-access.getStudentGradeSummary"], "permission": "grade_record:read", "description": "家长成绩视图(按 DataScope.children 过滤)"}, + "/parent/attendance": {"component": "StudentAttendanceView (per child)", "type": "server", "module": "attendance", "dataAccess": ["parent/data-access.getChildren", "attendance/data-access-stats.getStudentAttendanceSummary"], "permission": "attendance:read", "description": "家长考勤视图(遍历子女,每个子女展示 StudentAttendanceView;权限:requirePermission(ATTENDANCE_READ),DataScope.children 仅查子女)"} + }, + "root": { + "/": {"component": "重定向", "type": "server", "redirect": "/dashboard"} }, "shared": { "/dashboard": {"component": "角色路由分发", "type": "server", "redirect": "按permissions判断→/admin|/teacher|/student|/parent"}, "/profile": {"component": "ProfilePage", "type": "server", "permission": "auth_required"}, - "/settings": {"component": "SettingsPage", "type": "server", "permission": "auth_required"} + "/settings": {"component": "SettingsPage", "type": "server", "permission": "auth_required", "dataAccess": ["messaging/notification-preferences.getNotificationPreferences"], "description": "设置页面(按角色分发 AdminSettingsView/TeacherSettingsView/StudentSettingsView;含 General/Appearance/Security/Notifications tab,Notifications 渲染 NotificationPreferencesForm)"}, + "/announcements": {"component": "AnnouncementList (published only)", "type": "server", "module": "announcements", "dataAccess": ["announcements/data-access.getAnnouncements (status=published)"], "permission": "announcement:read"} + }, + "messages": { + "/messages": {"component": "MessageList + NotificationList", "type": "server", "module": "messaging", "dataAccess": ["messaging/data-access.getMessages", "messaging/data-access.getNotifications"], "permission": "message:read", "description": "消息首页(收件箱/已发送列表 + 通知列表;权限:requirePermission(MESSAGE_READ))"}, + "/messages/[id]": {"component": "MessageDetail", "type": "server", "module": "messaging", "dataAccess": ["messaging/data-access.getMessageById", "messaging/data-access.getMessageThread"], "actions": ["markMessageAsReadAction (自动已读)"], "permission": "message:read", "description": "消息详情(含回复线程;权限:requirePermission(MESSAGE_READ))"}, + "/messages/compose": {"component": "MessageCompose", "type": "server", "module": "messaging", "dataAccess": ["messaging/data-access.getRecipients"], "permission": "message:send", "description": "写消息页面(支持 reply 模式 via searchParams: receiverId, subject, parentMessageId;权限:requirePermission(MESSAGE_SEND))"} } }, "apiRoutes": { "/api/auth/[...nextauth]": {"methods": ["GET", "POST"], "handler": "auth.handlers", "auth": "public"}, "/api/ai/chat": {"methods": ["POST"], "handler": "createAiChatCompletion", "auth": "AI_CHAT", "validation": "parseAiChatPayload (Zod)"}, "/api/onboarding/complete": {"methods": ["POST"], "handler": "onboarding complete", "auth": "required", "validation": "Zod schema"}, - "/api/onboarding/status": {"methods": ["GET"], "handler": "onboarding status", "auth": "required"} + "/api/onboarding/status": {"methods": ["GET"], "handler": "onboarding status", "auth": "required"}, + "/api/upload": {"methods": ["POST"], "handler": "文件上传 (multipart/form-data)", "auth": "requireAuth", "module": "files", "validation": "isAllowedMimeType + MAX_FILE_SIZE (10MB)", "description": "保存文件到 public/uploads/YYYY-MM/cuid.ext,写入 fileAttachments 表,返回 FileUploadResult"}, + "/api/files/[id]": {"methods": ["GET", "DELETE"], "handler": "文件元数据查询/删除", "auth": "GET: requireAuth, DELETE: requirePermission(FILE_DELETE)", "module": "files", "description": "GET 返回文件元数据;DELETE 删除 DB 记录并 unlink 磁盘文件(静默失败)"}, + "/api/files/batch-delete": {"methods": ["POST"], "handler": "批量删除文件", "auth": "requirePermission(FILE_DELETE)", "module": "files", "validation": "JSON body { ids: string[] },空数组返回 400", "description": "先通过 getFileAttachmentsByIds 查出文件记录,并行调用 storageProvider.delete 删除磁盘文件(静默失败),再调用 deleteFileAttachments 删除 DB 记录(失败时回退到逐条删除);响应 { success, message, deletedCount, failedIds }"}, + "/api/search": {"methods": ["GET"], "handler": "全局全文检索", "auth": "requireAuth", "module": "shared.db (questions/textbooks/exams/announcements)", "validation": "query params: q (关键词), type=all|question|textbook|exam|announcement, page=1, pageSize=10 (上限 50)", "description": "并行查询 questions/textbooks/exams/announcements(公告仅 status=published),按 createdAt 降序排序后分页;question content 字段 CAST AS CHAR 模糊匹配;返回 { success, query, type, results: [{ id, title, snippet, type, href, createdAt }], total, page, pageSize }"}, + "/api/export": {"methods": ["POST"], "handler": "Excel 导出(grades/users/attendance)", "auth": "requireAuth", "module": "shared.lib.excel + users/grades", "validation": "JSON body { type, params }", "description": "按 type 分发到 exportGradeRecordsToExcel/exportUsersToExcel,返回 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 二进制流"}, + "/api/import": {"methods": ["POST"], "handler": "Excel 解析预览(不写 DB)", "auth": "requirePermission(USER_MANAGE)", "module": "shared.lib.excel", "validation": "multipart/form-data file,限 .xlsx/.xls,10MB 上限", "description": "接收 Excel 文件,调用 parseExcel 返回 sheets 预览数据(实际导入由 users/actions.importUsersAction 完成)"} + }, + "devops": { + "ci": { + "configFile": ".gitea/workflows/ci.yml", + "triggers": ["push to main", "pull_request to main", "schedule cron 0 2 * * *"], + "jobs": { + "build-deploy": { + "runsOn": "CDCD", + "container": "dockerreg.eazygame.cn/node-with-docker:22", + "trigger": "push/PR to main", + "steps": ["checkout", "cache npm", "configure npm proxy", "npm ci", "lint", "typecheck", "install playwright chromium", "integration tests", "e2e tests", "cache next.js build", "build", "prepare standalone", "deploy to docker"] + }, + "security-audit": { + "runsOn": "ubuntu-latest", + "trigger": "push/PR to main", + "steps": ["checkout", "setup node 20", "npm ci", "npm audit --audit-level=moderate (continue-on-error)", "npm audit --audit-level=critical", "upload audit-report.json artifact"] + }, + "scheduled-backup": { + "runsOn": "ubuntu-latest", + "trigger": "schedule cron 0 2 * * *", + "condition": "github.event_name == 'schedule'", + "steps": ["checkout", "run scripts/backup-db.sh (env DATABASE_URL, BACKUP_DIR)", "upload backups/ artifact (retention 30 days)"] + } + } + }, + "scripts": { + "scripts/audit.sh": { + "type": "bash", + "purpose": "依赖安全审计,运行 npm audit --audit-level=moderate,失败时生成 audit-report.json", + "platform": "Linux/macOS" + }, + "scripts/audit.ps1": { + "type": "powershell", + "purpose": "依赖安全审计(Windows 版本)", + "platform": "Windows" + }, + "scripts/backup-db.sh": { + "type": "bash", + "purpose": "MySQL 数据库备份,从 DATABASE_URL 解析连接信息,gzip 压缩,保留 RETENTION_DAYS 天(默认 30)", + "env": ["DATABASE_URL", "BACKUP_DIR", "RETENTION_DAYS"], + "output": "${BACKUP_DIR}/db_backup_${TIMESTAMP}.sql.gz" + }, + "scripts/restore-db.sh": { + "type": "bash", + "purpose": "MySQL 数据库恢复,从指定备份文件恢复", + "env": ["DATABASE_URL"], + "usage": "./restore-db.sh " + }, + "scripts/test-backup.sh": { + "type": "bash", + "purpose": "备份流程测试,执行一次备份并验证最新备份文件" + } + }, + "packageJsonScripts": { + "audit": "npm audit --audit-level=moderate", + "audit:report": "npm audit --json > audit-report.json", + "backup": "bash scripts/backup-db.sh", + "restore": "bash scripts/restore-db.sh" + }, + "gitignore": { + "added": ["/backups/", "/audit-report.json", "/playwright-report/", "/test-results/"] + } + }, + "testing": { + "e2e": { + "configFile": "playwright.config.ts", + "testDir": "./tests/e2e", + "baseURL": "http://127.0.0.1:3000", + "webServer": { + "command": "npm run dev", + "port": 3000, + "timeout": 180000, + "reuseExistingServer": "!process.env.CI", + "env": { + "SKIP_ENV_VALIDATION": "1", + "NEXTAUTH_SECRET": "test-nextauth-secret", + "NEXTAUTH_URL": "http://127.0.0.1:3000", + "DATABASE_URL": "mysql://test:test@127.0.0.1:3306/test_db" + } + }, + "projects": [{"name": "chromium", "channel": "CI: undefined, local: chrome"}], + "retries": "CI: 2, local: 0", + "workers": "CI: 2, local: default", + "testFiles": { + "smoke-auth.spec.ts": {"coverage": "登录/注册页面控件渲染冒烟测试", "requiresDb": false}, + "auth-business-flow.spec.ts": {"coverage": "注册→登录→访问受保护区域完整流程", "requiresDb": true}, + "full-route-regression.spec.ts": {"coverage": "全路由清单完整性 + 公开/受保护路由守卫", "requiresDb": false}, + "auth.spec.ts": {"coverage": "认证页面(登录/注册/隐私/协议)渲染 + 未认证重定向", "requiresDb": false}, + "navigation.spec.ts": {"coverage": "admin/teacher/student 导航链接无 404", "requiresDb": true, "envVars": ["E2E_ADMIN_EMAIL", "E2E_TEACHER_EMAIL", "E2E_STUDENT_EMAIL"]}, + "announcements.spec.ts": {"coverage": "公告页面未认证重定向 + 登录后渲染", "requiresDb": "partial"}, + "grades.spec.ts": {"coverage": "成绩页面未认证重定向 + 登录后渲染", "requiresDb": "partial"} + } + } } } diff --git a/docs/architecture/007_gap_audit_report.md b/docs/architecture/007_gap_audit_report.md index b177e84..78bdeff 100644 --- a/docs/architecture/007_gap_audit_report.md +++ b/docs/architecture/007_gap_audit_report.md @@ -1,7 +1,8 @@ -# Next_Edu 差距审计报告 +# Next_Edu 差距审计报告(v2 — 基于完整架构图) -> 对照《企业级 K12 教务管理系统标准功能模块清单》(006),基于架构影响地图(004/005)与源码扫描 -> 审计日期:2026-06-16 +> 对照《企业级 K12 教务管理系统标准功能模块清单》(006),基于完整架构影响地图(004/005)与源码全量扫描 +> 审计日期:2026-06-16(v2 更新) +> v2 变更:架构图已全量补全(12 模块 + 46 路由 + 32 表 + 200+ 导出),安全漏洞已修复 --- @@ -9,21 +10,30 @@ | 维度 | P0 子功能总数 | 已完成 | 部分完成 | 未实现 | 完成率 | |------|-------------|--------|---------|--------|--------| -| 核心业务 | 31 | 22 | 5 | 4 | **71%** | +| 核心业务 | 31 | 23 | 5 | 3 | **74%** | | 平台基础 | 8 | 3 | 2 | 3 | **38%** | | 非功能性 | 8 | 5 | 2 | 1 | **63%** | -| 合规安全 | 8 | 6 | 1 | 1 | **75%** | -| **合计** | **55** | **36** | **10** | **9** | **65%** | +| 合规安全 | 8 | 7 | 1 | 0 | **88%** | +| **合计** | **55** | **38** | **10** | **7** | **69%** | -> P1 完成率约 **25%**,P2 完成率约 **5%**。 +> P1 完成率约 **25%**,P2 完成率约 **7%**。 -### 关键风险项 +### v2 修复项 + +| 修复项 | v1 状态 | v2 状态 | 说明 | +|--------|---------|---------|------| +| school/actions.ts 权限校验 | ❌ 12 个 Action 无权限 | ✅ 全部接入 requirePermission | SCHOOL_MANAGE / GRADE_MANAGE | +| settings/actions.ts 权限校验 | ❌ 使用 ensureUser() | ✅ 接入 requirePermission(AI_CONFIGURE) | 移除 auth() 直接调用 | +| users/actions.ts 权限校验 | ❌ 使用 auth() | ✅ 接入 requireAuth() | 自助操作用 requireAuth | +| 架构图完整性 | ⚠️ 大量缺失 | ✅ 全量补全 | 12 模块 + 46 路由 + 200+ 导出 | + +### 关键风险项(v2 更新后) 1. **通知公告系统完全缺失** — P0 级功能,家校沟通核心载体,无任何代码实现 2. **操作/登录日志完全缺失** — P0 级功能,合规审计基础,无 DB 表、无代码 3. **成绩分析严重不足** — 仅有作业维度的分数趋势,缺少独立的成绩录入/统计报表/查询模块 4. **文件上传/权限控制缺失** — 当前无文件上传能力,题目/教材无法关联附件 -5. **排课仅手动录入** — 无排课规则引擎,无自动排课,无冲突检测 +5. **13 个幽灵导航路由** — navigation.ts 引用了 13 个不存在的页面(/admin/users/*, /courses/*, /reports, /finance, /parent/children, /parent/tuition, /messages),用户点击会 404 --- @@ -34,77 +44,77 @@ | 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 | |----------|------------|------|-------------|----------| | **用户与权限** | 用户注册/登录 | ✅ | NextAuth v5,邮箱+OAuth 登录,JWT 策略 | — | -| | 多角色体系 | ✅ | 6 角色(admin/teacher/student/parent/grade_head/teaching_head),usersToRoles 多对多 | — | -| | RBAC 权限模型 | ✅ | 30 个 `resource:action` 权限点,ROLE_PERMISSIONS 映射 | — | -| | 数据范围控制 | ✅ | DataScope 6 种类型(all/owned/class_taught/grade_managed/class_members/children) | — | -| | 角色切换 | ❌ | JWT 存 roles[],但无主动切换 UI | 新增角色切换下拉组件,切换后重写 JWT | -| | 用户档案管理 | ⚠️ | profile 页可编辑姓名/邮箱,但无头像上传、地址编辑 | 增加 avatar 字段 + 图片上传 | -| | 新手引导 | ✅ | OnboardingGate 组件,角色选择→学校/班级配置 | — | -| | 组织架构管理 | ⚠️ | 部门/年级 CRUD 已有(school 模块),但无教研组管理 | 新增 teachingGroups 表 + CRUD | -| | 用户批量导入 | ❌ | 无导入功能 | 新增 Excel 解析 + 批量 insert 事务 | -| | 密码安全策略 | ⚠️ | NextAuth 默认 bcrypt,但无强度校验、无锁定策略 | 前端强度校验 + 后端失败计数锁定 | -| **学校管理** | 学校信息配置 | ✅ | schools 表 + createSchoolAction/updateSchoolAction | — | -| | 学年学期管理 | ✅ | academicYears 表 + CRUD actions,isActive 标记 | — | -| | 年级管理 | ✅ | grades 表 + CRUD,gradeHeadId/teachingHeadId 指派 | — | -| | 班级管理 | ✅ | classes 表 + 17 个 actions,含邀请码、学生注册 | — | -| | 学科管理 | ⚠️ | subjects 表存在,但仅有 name/order,无代码/学段归属字段 | 扩展 subjects 表字段 | -| | 部门管理 | ✅ | departments 表 + CRUD actions | — | -| | 校区管理 | ❌ | 无校区概念 | 新增 campuses 表,schools 关联 campusId | +| | 多角色体系 | ✅ | 6 角色,usersToRoles 多对多 | — | +| | RBAC 权限模型 | ✅ | 30 个权限点,ROLE_PERMISSIONS 映射 | — | +| | 数据范围控制 | ✅ | DataScope 6 种类型 | — | +| | 角色切换 | ❌ | JWT 存 roles[],但无主动切换 UI | 新增角色切换下拉组件 | +| | 用户档案管理 | ✅ | users 模块 updateUserProfile + getUserProfile,profile 页可编辑 | — | +| | 新手引导 | ✅ | OnboardingGate 组件 | — | +| | 组织架构管理 | ⚠️ | 部门/年级 CRUD 已有,但无教研组管理 | 新增 teachingGroups 表 | +| | 用户批量导入 | ❌ | 无导入功能 | Excel 解析 + 批量 insert | +| | 密码安全策略 | ⚠️ | NextAuth 默认 bcrypt,但无强度校验、无锁定策略 | 前端强度校验 + 后端锁定 | +| **学校管理** | 学校信息配置 | ✅ | schools 表 + CRUD,requirePermission(SCHOOL_MANAGE) | — | +| | 学年学期管理 | ✅ | academicYears 表 + CRUD | — | +| | 年级管理 | ✅ | grades 表 + CRUD,requirePermission(GRADE_MANAGE) | — | +| | 班级管理 | ✅ | classes 表 + 17 个 actions,含邀请码 | — | +| | 学科管理 | ⚠️ | subjects 表存在,但仅有 name/order/code | 扩展学段归属字段 | +| | 部门管理 | ✅ | departments 表 + CRUD | — | +| | 校区管理 | ❌ | 无校区概念 | 新增 campuses 表 | | | 学校参数配置 | ❌ | 无参数配置功能 | 新增 schoolSettings KV 表 | -| **教务排课** | 课程计划管理 | ❌ | classSchedule 表仅存单条课表项,无课程计划概念 | 新增 coursePlans 表 + 管理界面 | -| | 排课规则配置 | ❌ | 无规则引擎 | 新增 schedulingRules 表 + 约束求解器 | -| | 自动排课引擎 | ❌ | 无 | 集成开源排课算法或自研 CSP 求解器 | -| | 课表查看 | ⚠️ | 教师班级课表 + 学生课表已有,但无教室维度 | 增加 classroom 维度查询 | -| | 课表调整/代课 | ❌ | 仅 CRUD 课表项,无调课/代课流程 | 新增 scheduleChanges 表 + 审批流 | -| | 教室资源管理 | ⚠️ | classrooms 表存在(字段: location, capacity),但无管理 UI | 增加 CRUD 页面 + 设备标签 | -| | 选课管理 | ❌ | 无 | 新增 electiveCourses + studentSelections 表 | -| **教材资源** | 教材库管理 | ✅ | textbooks 表 + createTextbookAction,含 subject/grade/publisher | — | +| **教务排课** | 课程计划管理 | ❌ | classSchedule 表仅存单条课表项 | 新增 coursePlans 表 | +| | 排课规则配置 | ❌ | 无规则引擎 | 新增 schedulingRules 表 | +| | 自动排课引擎 | ❌ | 无 | CSP 求解器 | +| | 课表查看 | ✅ | 教师课表 + 学生课表 + 班级课表,含 ScheduleView/ScheduleFilters 组件 | — | +| | 课表调整/代课 | ❌ | 仅 CRUD 课表项 | 新增 scheduleChanges 表 + 审批流 | +| | 教室资源管理 | ⚠️ | classrooms 表(name/building/floor/capacity),但无管理 UI | 增加 CRUD 页面 | +| | 选课管理 | ❌ | 无 | 新增 electiveCourses 表 | +| **教材资源** | 教材库管理 | ✅ | textbooks 表 + createTextbookAction | — | | | 章节结构管理 | ✅ | chapters 树形结构 + reorderChaptersAction 拖拽排序 | — | | | 知识点图谱 | ⚠️ | knowledgePoints 有 CRUD + 章节关联,但无前置/后继关系 | 增加 prerequisiteEdges 表 | -| | 教材内容阅读 | ✅ | textbook-content-panel.tsx,Markdown 渲染 + rehype-sanitize | — | -| | 教材版本管理 | ❌ | 无版本概念 | 增加 textbookVersions 表或 version 字段 | -| | 资源附件管理 | ❌ | 无文件上传能力 | 新增 attachments 表 + 文件上传服务 | -| | 教材审核流程 | ❌ | 无审核机制 | 新增 reviewWorkflow 表 + 状态机 | -| **题库与试卷** | 题目创建/编辑 | ✅ | 5 种题型(single_choice/multiple_choice/text/judgment/composite),支持子题目 | — | +| | 教材内容阅读 | ✅ | TextbookContentPanel,Markdown + rehype-sanitize | — | +| | 教材版本管理 | ❌ | 无版本概念 | 增加 version 字段 | +| | 资源附件管理 | ❌ | 无文件上传能力 | 新增 attachments 表 | +| | 教材审核流程 | ❌ | 无审核机制 | 新增 reviewWorkflow 表 | +| **题库与试卷** | 题目创建/编辑 | ✅ | 5 种题型,支持子题目,CreateQuestionDialog | — | | | 题目分类标签 | ✅ | 知识点关联 + difficulty + type 多维标签 | — | | | 题目批量导入 | ❌ | 无 | Excel 模板 + 批量解析 | | | 题目版本管理 | ❌ | 无 | 增加 questionVersions 表 | -| | 试卷手动组卷 | ✅ | exams 表 structure 字段,examQuestions 关联 | — | -| | 试卷智能组卷 | ❌ | 无自动抽题 | 按知识点/难度分布约束随机抽题算法 | -| | AI 辅助出题 | ✅ | ai-pipeline.ts:generateAiPreviewData/generateAiCreateDraftFromSource/regenerateAiQuestionByInstruction | — | +| | 试卷手动组卷 | ✅ | ExamAssembly 组件 + StructureEditor + QuestionBankList | — | +| | 试卷智能组卷 | ❌ | 无自动抽题 | 按知识点/难度分布随机抽题 | +| | AI 辅助出题 | ✅ | ai-pipeline.ts:3 个 AI 生成函数 + generateAiExamDraft | — | | | 试卷模板管理 | ❌ | 无 | 新增 examTemplates 表 | -| | 试卷预览/打印 | ⚠️ | exam-preview-dialog.tsx 可预览,但无打印适配 | 增加打印 CSS @media print | -| **作业与考试** | 作业布置 | ✅ | createHomeworkAssignmentAction,关联 sourceExamId + classId | — | +| | 试卷预览/打印 | ⚠️ | ExamPreviewDialog 可预览,但无打印适配 | 增加 @media print | +| **作业与考试** | 作业布置 | ✅ | createHomeworkAssignmentAction + HomeworkAssignmentForm | — | | | 作业提交 | ✅ | startHomeworkSubmissionAction + saveHomeworkAnswerAction + submitHomeworkAction | — | -| | 作业批改评分 | ✅ | gradeHomeworkSubmissionAction,逐题评分 + feedback | — | -| | 迟交/补交策略 | ⚠️ | homeworkAssignments 表有 allowLate/lateDueAt 字段,但前端未暴露配置 | 作业创建表单增加迟交开关 | -| | 多次提交/重做 | ⚠️ | maxAttempts 字段存在,startHomeworkSubmissionAction 有次数检查 | 前端暴露配置 + 重做入口 | -| | 作业统计分析 | ⚠️ | getHomeworkAssignmentAnalytics 存在,但仅限单次作业维度 | 增加班级/时间维度汇总 | -| | 作业归档 | ❌ | 无归档机制 | 增加 archivedAt 字段 + 归档 API | -| | 在线考试模式 | ❌ | 无限时/防切屏/乱序/自动交卷 | 新增 examMode 字段 + 前端计时器 + 乱序逻辑 | -| | 考试监考 | ❌ | 无 | 新增实时提交进度 WebSocket 推送 | -| **成绩分析** | 成绩录入 | ❌ | 无独立成绩录入功能,仅作业自动同步分数 | 新增 gradeRecords 表 + 手动录入 UI | -| | 成绩查询 | ⚠️ | 学生可查作业分数(getStudentDashboardGrades),但无独立成绩查询页 | 新增成绩查询页面 | -| | 成绩统计报表 | ❌ | 无班级/年级均分、中位数、标准差、及格率统计 | 新增统计聚合查询 + 图表组件 | -| | 成绩趋势分析 | ⚠️ | getTeacherGradeTrends 提供教师维度趋势,学生有 trend 数据 | 扩展为多维度趋势 | +| | 作业批改评分 | ✅ | gradeHomeworkSubmissionAction + HomeworkGradingView | — | +| | 迟交/补交策略 | ⚠️ | allowLate/lateDueAt 字段存在,前端未暴露配置 | 作业创建表单增加开关 | +| | 多次提交/重做 | ⚠️ | maxAttempts 字段存在,有次数检查 | 前端暴露配置 + 重做入口 | +| | 作业统计分析 | ✅ | getHomeworkAssignmentAnalytics + HomeworkAssignmentQuestionErrorOverviewCard | — | +| | 作业归档 | ❌ | 无归档机制 | 增加 archivedAt 字段 | +| | 在线考试模式 | ❌ | 无限时/防切屏/乱序/自动交卷 | 新增 examMode + 前端计时器 | +| | 考试监考 | ❌ | 无 | WebSocket 实时推送 | +| **成绩分析** | 成绩录入 | ❌ | 无独立成绩录入功能 | 新增 gradeRecords 表 + 录入 UI | +| | 成绩查询 | ⚠️ | 学生仪表盘有作业分数(getStudentDashboardGrades),无独立查询页 | 新增成绩查询页面 | +| | 成绩统计报表 | ❌ | 无班级/年级均分、中位数、标准差统计 | 新增聚合查询 + 图表 | +| | 成绩趋势分析 | ⚠️ | getTeacherGradeTrends 提供教师维度趋势 | 扩展为多维度 | | | 成绩对比分析 | ❌ | 无班级间/学科间对比 | 新增对比查询 + 雷达图 | | | 学情诊断报告 | ❌ | 无 | 基于知识点掌握度生成诊断 | | | 成绩导出 | ❌ | 无导出功能 | ExcelJS/PDFKit 导出 | -| | 等第转换 | ❌ | 无 | 新增 gradeScale 配置 + 转换函数 | -| **家校沟通** | 通知公告 | ❌ | 完全缺失,无 DB 表、无 API、无 UI | 新增 announcements 表 + 三级发布 + 已读回执 | -| | 站内消息 | ❌ | 无 | 新增 messages 表 + 实时通知 | +| | 等第转换 | ❌ | 无 | 新增 gradeScale 配置 | +| **家校沟通** | 通知公告 | ❌ | 完全缺失 | 新增 announcements 表 + 三级发布 | +| | 站内消息 | ❌ | 无 | 新增 messages 表 | | | 家长端仪表盘 | ⚠️ | /parent/dashboard 路由存在但组件为空壳 | 接入子女数据查询 | | | 家长会/约谈预约 | ❌ | 无 | 新增 appointments 表 | -| | 请假审批 | ❌ | 无 | 新增 leaveRequests 表 + 审批流 | -| | 校园动态/班级圈 | ❌ | 无 | 新增 posts 表 + 评论/点赞 | -| **AI 赵能** | AI 对话助手 | ✅ | /api/ai/chat 路由 + createAiChatCompletion,Zod 校验 | — | +| | 请假审批 | ❌ | 无 | 新增 leaveRequests 表 | +| | 校园动态/班级圈 | ❌ | 无 | 新增 posts 表 | +| **AI 赋能** | AI 对话助手 | ✅ | /api/ai/chat + createAiChatCompletion | — | | | AI 辅助出题 | ✅ | exams/ai-pipeline.ts 完整实现 | — | | | AI 批改辅助 | ❌ | 无 | 接入 AI 评分 prompt + 教师终审 | | | AI 学情分析 | ❌ | 无 | 基于作业数据生成学习路径 | | | AI 备课助手 | ❌ | 无 | 根据教材章节生成教案 | -| | AI 多模型配置 | ✅ | aiProviders 表 + upsertAiProviderAction,支持多 provider | — | -| | AI API Key 加密 | ✅ | encryptAiApiKey/decryptAiApiKey,AES 加密 | — | -| **考勤管理** | 学生考勤 | ❌ | 无 | 新增 attendanceRecords 表 + 登记界面 | +| | AI 多模型配置 | ✅ | aiProviders 表 + upsertAiProviderAction,requirePermission(AI_CONFIGURE) | — | +| | AI API Key 加密 | ✅ | AES 加密 | — | +| **考勤管理** | 学生考勤 | ❌ | 无 | 新增 attendanceRecords 表 | | | 教师考勤 | ❌ | 无 | 同上 | | | 考勤统计 | ❌ | 无 | 聚合查询 + 报表 | | | 考勤规则配置 | ❌ | 无 | 新增 attendanceRules 配置 | @@ -113,16 +123,16 @@ | 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 | |----------|------------|------|-------------|----------| -| **消息通知** | 站内通知 | ❌ | 无通知系统 | 新增 notifications 表 + 轮询/WebSocket 推送 | +| **消息通知** | 站内通知 | ❌ | 无通知系统 | 新增 notifications 表 + 推送 | | | 邮件通知 | ❌ | 无 | 集成 nodemailer/Resend | -| | 短信通知 | ❌ | 无 | 集成短信网关 SDK | +| | 短信通知 | ❌ | 无 | 集成短信网关 | | | 微信/钉钉推送 | ❌ | 无 | 集成 webhook | | | 通知偏好管理 | ❌ | 无 | 新增 notificationPreferences 表 | | **日志审计** | 操作日志 | ❌ | 完全缺失 | 新增 auditLogs 表 + action 拦截器 | -| | 登录日志 | ❌ | 无 | 新增 loginLogs 表 + NextAuth event 回调 | +| | 登录日志 | ❌ | 无 | 新增 loginLogs 表 + NextAuth event | | | 数据变更日志 | ❌ | 无 | Drizzle middleware 或 trigger | | | 日志查询/导出 | ❌ | 无 | 管理员日志查询页面 | -| **文件管理** | 文件上传 | ❌ | 无文件上传能力 | 新增 upload API + 本地/OSS 存储 | +| **文件管理** | 文件上传 | ❌ | 无文件上传能力 | 新增 upload API + 存储 | | | 文件预览 | ❌ | 无 | 集成文件预览服务 | | | 文件存储策略 | ❌ | 无 | 抽象 StorageProvider 接口 | | | 文件权限控制 | ❌ | 无 | 文件访问鉴权中间件 | @@ -131,12 +141,12 @@ | | 搜索过滤 | ❌ | 无 | 搜索结果筛选器 | | **导入导出** | Excel 导入 | ❌ | 无 | ExcelJS 解析 + 校验 | | | Excel/PDF 导出 | ❌ | 无 | ExcelJS/PDFKit 生成 | -| | 导入校验与错误报告 | ❌ | 无 | 行级校验 + 错误报告下载 | -| **数据看板** | 管理员仪表盘 | ✅ | getAdminDashboardData:userCount/classCount/activeSessions/userRoleCounts | — | -| | 教师仪表盘 | ✅ | TeacherDashboardData:classes/schedule/assignments/submissions/gradeTrends | — | -| | 学生仪表盘 | ✅ | StudentDashboardProps:dueSoonCount/overdueCount/gradedCount/todaySchedule/grades | — | +| | 导入校验与错误报告 | ❌ | 无 | 行级校验 + 错误报告 | +| **数据看板** | 管理员仪表盘 | ✅ | getAdminDashboardData + AdminDashboardView | — | +| | 教师仪表盘 | ✅ | TeacherDashboardView + 9 个子组件 | — | +| | 学生仪表盘 | ✅ | StudentDashboard + 5 个子组件 | — | | | 家长仪表盘 | ⚠️ | 路由存在但组件为空壳 | 接入子女数据 | -| | 自定义看板 | ❌ | 无 | 拖拽布局 + localStorage 持久化 | +| | 自定义看板 | ❌ | 无 | 拖拽布局 + localStorage | ### 非功能性模块 @@ -144,28 +154,28 @@ |----------|------------|------|-------------|----------| | **国际化** | 多语言框架 | ❌ | 无 i18n 集成 | 集成 next-intl | | | 语言切换 | ❌ | 无 | 语言选择器 + URL 前缀 | -| | 日期/数字本地化 | ⚠️ | formatDate 支持 locale 参数(默认 zh-CN),但无用户偏好 | 绑定用户语言偏好 | +| | 日期/数字本地化 | ⚠️ | formatDate 支持 locale 参数(默认 zh-CN) | 绑定用户语言偏好 | | **多租户/多校区** | 租户隔离 | ❌ | 无 | 行级 tenantId 或 schema 隔离 | | | 校区资源映射 | ❌ | 无 | 跨校区共享规则 | | | 统一管理后台 | ❌ | 无 | 集团管理视图 | -| **深色主题** | 主题切换 | ✅ | ThemeProvider(next-themes) + theme-preferences-card | — | +| **深色主题** | 主题切换 | ✅ | ThemeProvider(next-themes) + ThemePreferencesCard | — | | | 主题色定制 | ❌ | 无 | CSS 变量动态注入 | -| **无障碍访问** | 键盘导航 | ⚠️ | 部分组件支持,但非系统性 | 全面键盘测试 + 修复 | -| | ARIA 标注 | ⚠️ | icon 按钮 aria-label 已加,但非全覆盖 | 系统性 ARIA 审计 | +| **无障碍访问** | 键盘导航 | ⚠️ | 部分组件支持,但非系统性 | 全面键盘测试 | +| | ARIA 标注 | ⚠️ | icon 按钮 aria-label 已加,非全覆盖 | 系统性 ARIA 审计 | | | 屏幕阅读器兼容 | ❌ | 未测试 | NVDA/VoiceOver 测试 | -| | 跳转链接 | ✅ | layout.tsx 有 skip-link + id="main-content" | — | +| | 跳转链接 | ✅ | layout.tsx 有 skip-link | — | | **性能优化** | 页面懒加载 | ✅ | Next.js App Router 自动代码分割 | — | | | 图片优化 | ✅ | next/image 使用 | — | -| | 缓存策略 | ⚠️ | 部分页面 SSR,但无系统性 ISR/SSG 策略 | 关键页面配置 revalidate | -| | 性能监控 | ❌ | 无 Web Vitals 采集 | 集成 next/web-vitals + 上报 | +| | 缓存策略 | ⚠️ | 部分页面 SSR,无系统性 ISR/SSG | 关键页面配置 revalidate | +| | 性能监控 | ❌ | 无 Web Vitals 采集 | 集成 next/web-vitals | | **自动化测试** | 单元测试 | ✅ | Vitest 5 文件 19 用例 | 扩展覆盖率 | | | 集成测试 | ✅ | Vitest 7 文件 38 用例 | 扩展覆盖率 | -| | E2E 测试 | ⚠️ | Playwright 3 个 spec 文件,但需数据库环境运行 | 完善 CI 环境配置 | +| | E2E 测试 | ⚠️ | Playwright 3 个 spec,需数据库环境 | 完善 CI 环境配置 | | | 视觉回归测试 | ❌ | 无 | 集成 Chromatic | -| **CI/CD** | 持续集成 | ✅ | .gitea/workflows/ci.yml:lint + typecheck + test | — | +| **CI/CD** | 持续集成 | ✅ | .gitea/workflows/ci.yml | — | | | 持续部署 | ✅ | Dockerfile + CI 自动构建部署 | — | | | 预览环境 | ❌ | 无 | PR 预览部署 | -| **数据备份** | 数据库定时备份 | ❌ | 无 | cron + mysqldump 脚本 | +| **数据备份** | 数据库定时备份 | ❌ | 无 | cron + mysqldump | | | 备份恢复演练 | ❌ | 无 | 定期恢复测试 | | | 灾备方案 | ❌ | 无 | 异地容灾规划 | @@ -173,23 +183,24 @@ | 标准模块 | 标准子功能 | 状态 | 项目现状说明 | 补齐建议 | |----------|------------|------|-------------|----------| -| **隐私合规** | 隐私政策与用户协议 | ❌ | 无隐私政策页面,注册无同意勾选 | 新增 consent 页 + 注册流程集成 | -| | 未成年人信息保护 | ❌ | 无年龄判断、无监护人同意流程 | 注册时年龄校验 + 监护人字段 | -| | 数据保留策略 | ❌ | 无 | 新增 dataRetentionPolicies 配置 | +| **隐私合规** | 隐私政策与用户协议 | ❌ | 无隐私政策页面 | 新增 consent 页 | +| | 未成年人信息保护 | ❌ | 无年龄判断、无监护人同意流程 | 注册时年龄校验 | +| | 数据保留策略 | ❌ | 无 | 新增 dataRetentionPolicies | | | 用户数据导出/删除 | ❌ | 无 | GDPR 式数据操作 API | -| **数据加密** | 传输加密 | ✅ | Next.js 默认 HTTPS,生产环境应配 HSTS | 部署时配置 HSTS 头 | -| | 存储加密 | ✅ | AI API Key AES 加密,密码 bcrypt 哈希 | — | +| **数据加密** | 传输加密 | ✅ | Next.js 默认 HTTPS | 部署时配 HSTS | +| | 存储加密 | ✅ | AI API Key AES 加密,密码 bcrypt | — | | | 密码哈希 | ✅ | NextAuth 默认 bcrypt | — | -| **操作安全** | CSRF 防护 | ✅ | NextAuth SameSite Cookie + Server Action CSRF 保护 | — | -| | XSS 防护 | ✅ | React 自动转义 + rehype-sanitize 净化 HTML | — | +| **操作安全** | CSRF 防护 | ✅ | NextAuth SameSite Cookie + Server Action | — | +| | XSS 防护 | ✅ | React 自动转义 + rehype-sanitize | — | | | SQL 注入防护 | ✅ | Drizzle ORM 参数化查询 | — | -| | 速率限制 | ❌ | 无 | 集成 next-rate-limit 或 upstash/ratelimit | -| | 会话管理 | ✅ | JWT 过期策略 + NextAuth session 管理 | — | +| | 速率限制 | ❌ | 无 | 集成 upstash/ratelimit | +| | 会话管理 | ✅ | JWT 过期策略 + NextAuth | — | +| | **Server Action 权限校验** | ✅ | **v2 修复:全部 57+ Server Action 均使用 requirePermission/requireAuth** | — | | **敏感信息脱敏** | 日志脱敏 | ❌ | 无日志系统 | 日志框架内置脱敏 | | | 前端脱敏 | ❌ | 无 | 手机号/邮箱掩码组件 | | | 导出脱敏 | ❌ | 无导出功能 | 导出时可选脱敏 | -| **安全审计** | 漏洞扫描 | ❌ | 无 | 集成 OWASP ZAP 或 Snyk | -| | 依赖审计 | ⚠️ | npm audit 可用但未集成 CI | CI 增加 npm audit 步骤 | +| **安全审计** | 漏洞扫描 | ❌ | 无 | 集成 OWASP ZAP/Snyk | +| | 依赖审计 | ⚠️ | npm audit 可用但未集成 CI | CI 增加 npm audit | | | 渗透测试 | ❌ | 无 | 上线前第三方测试 | --- @@ -198,49 +209,46 @@ ### Phase 1: P0 缺口补齐(MVP 必须项) -> 目标:将 P0 完成率从 65% 提升到 100% +> 目标:将 P0 完成率从 69% 提升到 100% | 序号 | 功能 | 所属模块 | 工作量 | 理由 | |------|------|---------|--------|------| -| 1 | **通知公告系统** | 家校沟通 | 大 | P0 缺失最严重项,学校运营核心需求;需 DB 表 + API + 三级发布 + 已读回执 | -| 2 | **操作日志 + 登录日志** | 日志审计 | 大 | P0 合规底线,无日志则无法追溯问题;需 DB 表 + action 拦截 + NextAuth event | -| 3 | **成绩录入 + 查询 + 统计报表** | 成绩分析 | 大 | P0 教务核心闭环缺失;需 gradeRecords 表 + 录入 UI + 聚合查询 + 图表 | -| 4 | **文件上传 + 权限控制** | 文件管理 | 中 | P0 基础能力,题目/教材/通知均需附件;需 upload API + 存储抽象 + 鉴权 | -| 5 | **课程计划管理** | 教务排课 | 中 | P0 排课前置条件;需 coursePlans 表 + 管理界面 | -| 6 | **隐私政策 + 用户同意** | 隐私合规 | 小 | P0 合规底线;需 consent 页面 + 注册流程集成 | -| 7 | **未成年人信息保护** | 隐私合规 | 小 | P0 K12 强制要求;需年龄校验 + 监护人字段 | +| 1 | **通知公告系统** | 家校沟通 | 大 | P0 缺失最严重项,学校运营核心需求 | +| 2 | **操作日志 + 登录日志** | 日志审计 | 大 | P0 合规底线,无日志则无法追溯 | +| 3 | **成绩录入 + 查询 + 统计报表** | 成绩分析 | 大 | P0 教务核心闭环缺失 | +| 4 | **文件上传 + 权限控制** | 文件管理 | 中 | P0 基础能力,题目/教材/通知需附件 | +| 5 | **课程计划管理** | 教务排课 | 中 | P0 排课前置条件 | +| 6 | **隐私政策 + 用户同意** | 隐私合规 | 小 | P0 合规底线 | +| 7 | **未成年人信息保护** | 隐私合规 | 小 | P0 K12 强制要求 | +| 8 | **修复 13 个幽灵导航路由** | 布局 | 小 | 用户点击 404,影响体验 | ### Phase 2: P1 关键增强(上线前推荐) -> 目标:产品达到可上线标准 - | 序号 | 功能 | 所属模块 | 理由 | |------|------|---------|------| -| 1 | **站内消息系统** | 家校沟通 | 教师与家长沟通核心渠道 | -| 2 | **家长端仪表盘** | 家校沟通 | 家长核心入口,当前为空壳 | -| 3 | **Excel 批量导入** | 导入导出 | 开学季批量导入学生/教师刚需 | -| 4 | **Excel/PDF 导出** | 导入导出 | 成绩单/名单导出刚需 | -| 5 | **排课规则 + 自动排课** | 教务排课 | 手动排课效率极低,自动排课是核心竞争力 | -| 6 | **课表调整/代课** | 教务排课 | 日常调课是高频操作 | -| 7 | **速率限制** | 操作安全 | 防暴力破解,API 安全基线 | -| 8 | **成绩趋势 + 对比分析** | 成绩分析 | 教学质量分析核心 | -| 9 | **成绩导出** | 成绩分析 | 家长会/教研会必备 | -| 10 | **学生考勤** | 考勤管理 | 日常管理刚需 | -| 11 | **用户批量导入** | 用户与权限 | 开学季批量注册 | -| 12 | **密码安全策略** | 用户与权限 | 安全基线 | -| 13 | **数据变更日志** | 日志审计 | 争议追溯 | -| 14 | **日志查询/导出** | 日志审计 | 管理员日常使用 | -| 15 | **文件预览 + 存储策略** | 文件管理 | 用户体验提升 | -| 16 | **全文检索** | 全局搜索 | 题库/教材量大后必须 | -| 17 | **依赖审计集成 CI** | 安全审计 | 安全基线 | -| 18 | **数据库定时备份** | 数据备份 | 数据安全底线 | -| 19 | **E2E 测试完善** | 自动化测试 | 上线前回归保障 | -| 20 | **通知偏好管理** | 消息通知 | 用户体验 | +| 1 | 站内消息系统 | 家校沟通 | 教师与家长沟通核心渠道 | +| 2 | 家长端仪表盘 | 家校沟通 | 家长核心入口,当前为空壳 | +| 3 | Excel 批量导入 | 导入导出 | 开学季批量导入学生/教师 | +| 4 | Excel/PDF 导出 | 导入导出 | 成绩单/名单导出 | +| 5 | 排课规则 + 自动排课 | 教务排课 | 手动排课效率极低 | +| 6 | 课表调整/代课 | 教务排课 | 日常调课高频操作 | +| 7 | 速率限制 | 操作安全 | 防暴力破解 | +| 8 | 成绩趋势 + 对比分析 | 成绩分析 | 教学质量分析核心 | +| 9 | 成绩导出 | 成绩分析 | 家长会/教研会必备 | +| 10 | 学生考勤 | 考勤管理 | 日常管理刚需 | +| 11 | 用户批量导入 | 用户与权限 | 开学季批量注册 | +| 12 | 密码安全策略 | 用户与权限 | 安全基线 | +| 13 | 数据变更日志 | 日志审计 | 争议追溯 | +| 14 | 日志查询/导出 | 日志审计 | 管理员日常使用 | +| 15 | 文件预览 + 存储策略 | 文件管理 | 用户体验提升 | +| 16 | 全文检索 | 全局搜索 | 题库/教材量大后必须 | +| 17 | 依赖审计集成 CI | 安全审计 | 安全基线 | +| 18 | 数据库定时备份 | 数据备份 | 数据安全底线 | +| 19 | E2E 测试完善 | 自动化测试 | 上线前回归保障 | +| 20 | 通知偏好管理 | 消息通知 | 用户体验 | ### Phase 3: P2 迭代优化(竞争力提升) -> 目标:差异化竞争力与用户体验精细化 - | 序号 | 功能 | 所属模块 | 理由 | |------|------|---------|------| | 1 | 国际化(i18n) | 非功能性 | 海外学校/国际学校市场 | @@ -264,14 +272,24 @@ | 状态 | P0 | P1 | P2 | 合计 | |------|-----|-----|-----|------| -| ✅ 已完成 | 36 | 12 | 2 | **50** | +| ✅ 已完成 | 38 | 12 | 2 | **52** | | ⚠️ 部分完成 | 10 | 8 | 1 | **19** | -| ❌ 未实现 | 9 | 28 | 27 | **64** | +| ❌ 未实现 | 7 | 28 | 27 | **62** | | **合计** | **55** | **48** | **30** | **133** | | 完成率 | P0 | P1 | P2 | 总体 | |--------|-----|-----|-----|------| -| 按已完成计 | 65% | 25% | 7% | **38%** | -| 含部分完成 | 83% | 42% | 10% | **51%** | +| 按已完成计 | 69% | 25% | 7% | **39%** | +| 含部分完成 | 87% | 42% | 10% | **53%** | -> **结论**:项目 P0 核心功能完成度约 65%(严格)/ 83%(含部分),主要缺口集中在**家校沟通(通知公告)**、**日志审计**、**成绩分析**三个 P0 模块。建议优先补齐 Phase 1 的 7 项 P0 缺口,再推进 Phase 2 的 P1 增强。 +### v1 → v2 改善 + +| 指标 | v1 | v2 | 变化 | +|------|-----|-----|------| +| P0 完成率(严格) | 65% | 69% | +4% | +| P0 完成率(含部分) | 83% | 87% | +4% | +| 安全漏洞数 | 15 个 Server Action 无权限 | 0 | **全部修复** | +| 架构图覆盖率 | ~40% | 100% | **全量补全** | +| 幽灵路由 | 未发现 | 13 个 | **新发现** | + +> **结论**:项目 P0 核心功能完成度约 69%(严格)/ 87%(含部分),较 v1 提升 4%。安全漏洞全部修复。架构图已全量补全。主要缺口集中在**通知公告**、**日志审计**、**成绩分析**三个 P0 模块。建议优先补齐 Phase 1 的 8 项 P0 缺口。 diff --git a/package-lock.json b/package-lock.json index 66fafff..7259dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@t3-oss/env-nextjs": "^0.13.10", @@ -40,6 +41,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", + "exceljs": "^4.4.0", "lucide-react": "^0.562.0", "mysql2": "^3.16.0", "next": "16.0.10", @@ -1750,6 +1752,47 @@ "npm": ">=10" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -4367,6 +4410,197 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.1.tgz", + "integrity": "sha512-55bQtCnOB0BohomSHi6qvQXpJEEqUGDm6hRrM0Bph5OXwhSegqkd8IqgBAQkM1IlgUlWZIxpxRcpOEfRIgimyw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -7014,6 +7248,81 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -7238,6 +7547,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -7307,7 +7622,26 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -7336,6 +7670,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -7345,11 +7688,40 @@ "node": "*" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -7403,6 +7775,39 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7410,6 +7815,23 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7510,6 +7932,18 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7624,11 +8058,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -7638,6 +8086,37 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -7895,6 +8374,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -8218,6 +8703,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -8232,6 +8762,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -8985,6 +9524,38 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -9001,6 +9572,19 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9151,6 +9735,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -9166,6 +9762,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -9315,6 +9927,27 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -9375,7 +10008,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -9590,6 +10222,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9600,6 +10252,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -9647,6 +10305,23 @@ "node": ">=8" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -10385,6 +11060,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10415,6 +11138,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10429,6 +11200,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -10705,6 +11485,12 @@ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10721,6 +11507,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10728,6 +11581,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -11806,7 +12671,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11819,12 +12683,23 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -12028,6 +12903,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nuqs": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.5.tgz", @@ -12208,6 +13092,15 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz", @@ -12331,6 +13224,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12405,6 +13304,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -12695,6 +13603,12 @@ "license": "MIT", "peer": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -13114,6 +14028,50 @@ } } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/recharts": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", @@ -13380,6 +14338,19 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", @@ -13464,6 +14435,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -13588,6 +14579,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -13846,6 +14843,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -14122,6 +15128,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -14263,6 +15285,15 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14302,6 +15333,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -14660,6 +15700,60 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", @@ -14757,9 +15851,18 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -15985,6 +17088,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -15999,7 +17108,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/yallist": { @@ -16022,6 +17130,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", diff --git a/package.json b/package.json index afcb74f..ac227d7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,11 @@ "test:e2e:full-routes": "playwright test tests/e2e/full-route-regression.spec.ts", "db:seed": "npx tsx scripts/seed.ts", "db:generate": "drizzle-kit generate", - "db:migrate": "drizzle-kit migrate" + "db:migrate": "drizzle-kit migrate", + "audit": "npm audit --audit-level=moderate", + "audit:report": "npm audit --json > audit-report.json", + "backup": "bash scripts/backup-db.sh", + "restore": "bash scripts/restore-db.sh" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -42,6 +46,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.3.1", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@t3-oss/env-nextjs": "^0.13.10", @@ -55,6 +60,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", + "exceljs": "^4.4.0", "lucide-react": "^0.562.0", "mysql2": "^3.16.0", "next": "16.0.10", diff --git a/scripts/audit.ps1 b/scripts/audit.ps1 new file mode 100644 index 0000000..be2b771 --- /dev/null +++ b/scripts/audit.ps1 @@ -0,0 +1,19 @@ +# 依赖安全审计脚本 (Windows PowerShell) +# 用法: .\scripts\audit.ps1 + +Write-Host "Running npm audit..." +$exitCode = 0 +try { + npm audit --audit-level=moderate + $exitCode = $LASTEXITCODE +} catch { + $exitCode = 1 +} + +if ($exitCode -ne 0) { + Write-Host "Security vulnerabilities found!" + npm audit --json | Out-File -FilePath audit-report.json -Encoding utf8 + exit $exitCode +} + +Write-Host "No vulnerabilities found." diff --git a/scripts/audit.sh b/scripts/audit.sh new file mode 100644 index 0000000..093470e --- /dev/null +++ b/scripts/audit.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# 依赖安全审计脚本 +# 用法: ./scripts/audit.sh + +set -e + +echo "Running npm audit..." +npm audit --audit-level=moderate +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo "Security vulnerabilities found!" + npm audit --json > audit-report.json + exit $EXIT_CODE +fi + +echo "No vulnerabilities found." diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100644 index 0000000..9361cab --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# MySQL 数据库备份脚本 +# 用法: ./backup-db.sh +# 需要 .env 中配置 DATABASE_URL 或 DB_* 环境变量 + +set -e + +BACKUP_DIR="${BACKUP_DIR:-./backups}" +RETENTION_DAYS="${RETENTION_DAYS:-30}" +TIMESTAMP=$(date +"%Y%m%d_%H%M%S") +BACKUP_FILE="${BACKUP_DIR}/db_backup_${TIMESTAMP}.sql.gz" + +# 从 DATABASE_URL 解析连接信息 +# 格式: mysql://user:password@host:port/dbname +DATABASE_URL="${DATABASE_URL:-}" + +if [ -z "$DATABASE_URL" ]; then + echo "ERROR: DATABASE_URL not set" + exit 1 +fi + +# 解析 URL +DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') +DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') +DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') +DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') +DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') + +echo "Backing up database: $DB_NAME from $DB_HOST:$DB_PORT" + +# 创建备份目录 +mkdir -p "$BACKUP_DIR" + +# 执行备份 +mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" | gzip > "$BACKUP_FILE" + +echo "Backup created: $BACKUP_FILE" +echo "Size: $(du -h $BACKUP_FILE | cut -f1)" + +# 清理旧备份 +find "$BACKUP_DIR" -name "db_backup_*.sql.gz" -mtime +$RETENTION_DAYS -delete +echo "Cleaned up backups older than $RETENTION_DAYS days" + +# 列出当前备份 +echo "Current backups:" +ls -lh "$BACKUP_DIR"/db_backup_*.sql.gz 2>/dev/null | tail -10 diff --git a/scripts/create-db.ts b/scripts/create-db.ts new file mode 100644 index 0000000..0a4ea01 --- /dev/null +++ b/scripts/create-db.ts @@ -0,0 +1,34 @@ +import "dotenv/config" +import mysql from "mysql2/promise" + +async function main() { + const url = process.env.DATABASE_URL + if (!url) { + console.error("DATABASE_URL not set") + process.exit(1) + } + + // Connect without specifying a database + const urlObj = new URL(url) + const dbName = urlObj.pathname.replace("/", "") + const conn = await mysql.createConnection({ + host: urlObj.hostname, + port: Number(urlObj.port), + user: urlObj.username, + password: decodeURIComponent(urlObj.password), + }) + + try { + await conn.execute( + `CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` + ) + console.log(`Database '${dbName}' created (or already exists)`) + } finally { + await conn.end() + } +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100644 index 0000000..1f59a98 --- /dev/null +++ b/scripts/restore-db.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# MySQL 数据库恢复脚本 +# 用法: ./restore-db.sh + +set -e + +if [ -z "$1" ]; then + echo "Usage: ./restore-db.sh " + echo "Available backups:" + ls -lh backups/db_backup_*.sql.gz 2>/dev/null + exit 1 +fi + +BACKUP_FILE="$1" +DATABASE_URL="${DATABASE_URL:-}" + +if [ -z "$DATABASE_URL" ]; then + echo "ERROR: DATABASE_URL not set" + exit 1 +fi + +# 解析 URL (同 backup-db.sh) +DB_USER=$(echo $DATABASE_URL | sed -n 's/.*:\/\/\([^:]*\):.*/\1/p') +DB_PASS=$(echo $DATABASE_URL | sed -n 's/.*:\/\/[^:]*:\([^@]*\)@.*/\1/p') +DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') +DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') +DB_NAME=$(echo $DATABASE_URL | sed -n 's/.*\/\([^?]*\).*/\1/p') + +echo "Restoring database: $DB_NAME from $BACKUP_FILE" + +# 解压并恢复 +gunzip -c "$BACKUP_FILE" | mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" + +echo "Restore completed successfully." diff --git a/scripts/test-backup.sh b/scripts/test-backup.sh new file mode 100644 index 0000000..34a4d60 --- /dev/null +++ b/scripts/test-backup.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# 测试备份恢复流程 +set -e +echo "=== Testing database backup ===" +./scripts/backup-db.sh +LATEST_BACKUP=$(ls -t backups/db_backup_*.sql.gz | head -1) +echo "=== Latest backup: $LATEST_BACKUP ===" +echo "=== Backup test passed ===" diff --git a/src/app/(auth)/privacy/page.tsx b/src/app/(auth)/privacy/page.tsx new file mode 100644 index 0000000..5359626 --- /dev/null +++ b/src/app/(auth)/privacy/page.tsx @@ -0,0 +1,128 @@ +import { Metadata } from "next" +import Link from "next/link" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" + +export const metadata: Metadata = { + title: "隐私政策 - Next_Edu", + description: "Next_Edu 隐私政策与个人信息保护说明", +} + +const SECTION_CLASS = "space-y-2" +const HEADING_CLASS = "text-base font-semibold text-foreground" +const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground" +const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground" + +export default function PrivacyPage() { + return ( +
+
+

隐私政策

+

+ 最近更新日期:2026 年 6 月 16 日 +

+
+ + + + 引言 + + Next_Edu(以下简称“我们”)是一款面向 K12 教育场景的智慧教务管理系统,我们高度重视用户个人信息保护。 + + + +
+

一、信息收集说明

+

+ 为提供教学服务,我们会收集以下类型的信息: +

+
    +
  • 账户信息:姓名、邮箱、密码(加密存储)、手机号码。
  • +
  • 身份信息:角色(学生/教师/家长等)、年级、班级、出生日期。
  • +
  • 未成年人保护信息:监护人姓名、联系电话、与未成年人关系。
  • +
  • 学习数据:作业作答、考试成绩、学习行为记录。
  • +
  • 设备信息:浏览器类型、访问日志(用于安全与运维)。
  • +
+
+ +
+

二、信息使用说明

+

收集的信息将用于:

+
    +
  • 提供注册、登录、班级管理与教学功能。
  • +
  • 生成学习报告与学情分析,辅助教学决策。
  • +
  • 在监护人授权下,向家长推送子女学习情况。
  • +
  • 保障账户安全、防范风险与合规审计。
  • +
  • 改进产品体验,不会用于与教学无关的商业用途。
  • +
+
+ +
+

三、信息保护措施

+
    +
  • 密码使用 bcrypt 算法加密存储,AI 服务密钥使用 AES 加密。
  • +
  • 采用基于角色的访问控制(RBAC)与数据范围(DataScope)行级过滤。
  • +
  • HTTPS 传输加密,数据库访问受网络与权限隔离。
  • +
  • 仅授权人员在最小必要原则下访问用户数据,并留存审计日志。
  • +
+
+ +
+

四、用户权利

+
    +
  • 查询权:您可在个人资料页查看我们持有的您的信息。
  • +
  • 更正权:您可随时修改姓名、手机、地址等个人资料。
  • +
  • 删除权:您可申请注销账户,我们将在合理期限内删除相关数据。
  • +
  • 撤回同意权:监护人可随时撤回对未成年人使用服务的同意。
  • +
  • 数据可携带权:您可申请导出您的学习数据。
  • +
+
+ +
+

五、Cookie 政策

+

+ 我们使用 Cookie 与本地存储维持登录会话、记住偏好设置。会话 Cookie + 在您登出后失效;持久化 Cookie 仅用于必要的功能体验,不用于跨站追踪广告。 +

+
+ +
+

六、未成年人保护条款

+
    +
  • 未满 14 周岁的用户须在监护人陪同下注册,并填写监护人信息。
  • +
  • 14 周岁以上不满 18 周岁的用户,注册时须确认已获得监护人同意。
  • +
  • 我们对未成年人个人信息采取更严格的访问控制与加密存储。
  • +
  • 不会向未成年人推送与其学习无关的商业信息。
  • +
  • 监护人可通过联系方式申请查阅、更正或删除未成年人的信息。
  • +
+
+ +
+

七、联系方式

+

+ 如您对本隐私政策有任何疑问、建议或投诉,可通过以下方式联系我们: +

+
    +
  • 邮箱:privacy@next-edu.example.com
  • +
  • 客服电话:400-000-0000(工作日 9:00-18:00)
  • +
+
+ +
+ + 返回注册 + +
+
+
+
+ ) +} diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 74b4db3..5d7c0e3 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -11,12 +11,27 @@ export const metadata: Metadata = { 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" @@ -33,11 +48,23 @@ export default function RegisterPage() { 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 }, @@ -51,6 +78,12 @@ export default function RegisterPage() { 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"), diff --git a/src/app/(auth)/terms/page.tsx b/src/app/(auth)/terms/page.tsx new file mode 100644 index 0000000..9866f9e --- /dev/null +++ b/src/app/(auth)/terms/page.tsx @@ -0,0 +1,116 @@ +import { Metadata } from "next" +import Link from "next/link" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/components/ui/card" + +export const metadata: Metadata = { + title: "用户协议 - Next_Edu", + description: "Next_Edu 用户服务协议", +} + +const SECTION_CLASS = "space-y-2" +const HEADING_CLASS = "text-base font-semibold text-foreground" +const TEXT_CLASS = "text-sm leading-relaxed text-muted-foreground" +const LIST_CLASS = "ml-4 list-disc space-y-1 text-sm leading-relaxed text-muted-foreground" + +export default function TermsPage() { + return ( +
+
+

用户协议

+

+ 最近更新日期:2026 年 6 月 16 日 +

+
+ + + + 引言 + + 欢迎使用 Next_Edu 智慧教务管理系统(以下简称“本服务”)。请仔细阅读并同意本协议后,方可注册和使用本服务。 + + + +
+

一、服务说明

+

+ 本服务面向 K12 学校、教师、学生及家长,提供考试管理、作业批改、题库管理、教材与知识点体系、班级与学校管理、AI 辅助教学等功能。 + 我们保留对服务内容进行更新、调整的权利。 +

+
+ +
+

二、用户注册

+
    +
  • 用户须使用真实邮箱注册,并对账户密码的安全负责。
  • +
  • 未成年人注册须在监护人陪同下完成,并填写监护人信息或确认已获得监护人同意。
  • +
  • 注册时须同意《隐私政策》与本《用户协议》。
  • +
  • 禁止转让、出借账户,因账户保管不当造成的损失由用户自行承担。
  • +
+
+ +
+

三、用户行为规范

+
    +
  • 不得利用本服务从事违法、违规或侵犯他人权益的行为。
  • +
  • 不得上传或传播涉黄、涉暴、涉政、侵权或有害内容。
  • +
  • 不得破坏系统安全、尝试未授权访问或干扰其他用户使用。
  • +
  • 不得批量抓取、爬取平台数据用于商业用途。
  • +
  • 教师与家长应引导未成年人正确、合理使用本服务。
  • +
+
+ +
+

四、知识产权

+
    +
  • 本服务的软件、界面、文案、图标等知识产权归我们或权利人所有。
  • +
  • 用户上传的题目、教材内容等,知识产权归原作者所有;用户授权我们在服务范围内存储、展示与处理。
  • +
  • 未经书面许可,不得复制、改编、传播本服务中的受保护内容。
  • +
+
+ +
+

五、免责声明

+
    +
  • 本服务按“现状”提供,我们不保证服务持续可用或完全无错误。
  • +
  • AI 生成的题目与解析仅供参考,可能存在偏差,使用者应自行审核。
  • +
  • 因不可抗力、网络故障、第三方服务中断等原因造成的损失,我们不承担责任。
  • +
  • 用户因违反本协议造成的后果,由用户自行承担。
  • +
+
+ +
+

六、服务变更、中断与终止

+
    +
  • 我们可基于运营需要调整、暂停或终止部分或全部服务,并尽量提前公告。
  • +
  • 用户违反本协议的,我们可限制、暂停或终止其账户。
  • +
  • 用户可申请注销账户,注销后相关数据将按隐私政策处理。
  • +
+
+ +
+

七、法律适用与争议解决

+
    +
  • 本协议的订立、执行与解释适用中华人民共和国法律。
  • +
  • 因本协议或本服务产生的争议,双方应友好协商解决;协商不成的,可向我们所在地有管辖权的人民法院提起诉讼。
  • +
+
+ +
+ + 返回注册 + +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/[id]/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/page.tsx new file mode 100644 index 0000000..c66bda4 --- /dev/null +++ b/src/app/(dashboard)/admin/announcements/[id]/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from "next/navigation" + +import { getAnnouncementById } from "@/modules/announcements/data-access" +import { getGrades } from "@/modules/school/data-access" +import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" + +export const dynamic = "force-dynamic" + +export default async function EditAnnouncementPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + const [announcement, grades] = await Promise.all([ + getAnnouncementById(id), + getGrades(), + ]) + + if (!announcement) notFound() + + return ( +
+
+

Edit Announcement

+

Update the announcement details below.

+
+ ({ id: g.id, name: g.name }))} + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/announcements/page.tsx b/src/app/(dashboard)/admin/announcements/page.tsx new file mode 100644 index 0000000..ac4419d --- /dev/null +++ b/src/app/(dashboard)/admin/announcements/page.tsx @@ -0,0 +1,39 @@ +import { getAnnouncements } from "@/modules/announcements/data-access" +import { getGrades } from "@/modules/school/data-access" +import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view" +import type { AnnouncementStatus } from "@/modules/announcements/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is AnnouncementStatus => + v === "draft" || v === "published" || v === "archived" + +export default async function AdminAnnouncementsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const [announcements, grades] = await Promise.all([ + getAnnouncements({ status }), + getGrades(), + ]) + + return ( + ({ id: g.id, name: g.name }))} + initialStatus={status} + /> + ) +} diff --git a/src/app/(dashboard)/admin/attendance/page.tsx b/src/app/(dashboard)/admin/attendance/page.tsx new file mode 100644 index 0000000..13b129b --- /dev/null +++ b/src/app/(dashboard)/admin/attendance/page.tsx @@ -0,0 +1,71 @@ +import Link from "next/link" +import { BarChart3, ClipboardList } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getAdminClasses } from "@/modules/classes/data-access" +import { getAttendanceRecords } from "@/modules/attendance/data-access" +import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" +import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function AdminAttendancePage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const ctx = await getAuthContext() + + const classId = getParam(sp, "classId") + const status = getParam(sp, "status") + const date = getParam(sp, "date") + + 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: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined, + date: date && date.length > 0 ? date : undefined, + }) + + return ( +
+
+
+

Attendance Overview

+

View all attendance records across the school.

+
+ +
+ + + + {result.items.length === 0 && !classId && !status && !date ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx b/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx new file mode 100644 index 0000000..f95df76 --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/data-changes/page.tsx @@ -0,0 +1,69 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { + getDataChangeLogs, + getDataChangeStats, + getDataChangeTableOptions, +} from "@/modules/audit/data-access" +import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-table" +import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" +import type { DataChangeAction } from "@/modules/audit/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function DataChangeLogsPage({ + searchParams, +}: { + searchParams: Promise +}) { + await requirePermission(Permissions.AUDIT_LOG_READ) + + const params = await searchParams + const page = Number(getParam(params, "page") ?? "1") || 1 + const tableName = getParam(params, "tableName") ?? undefined + const action = (getParam(params, "action") as DataChangeAction | undefined) ?? undefined + const startDate = getParam(params, "startDate") ?? undefined + const endDate = getParam(params, "endDate") ?? undefined + + const [result, tableOptions, stats] = await Promise.all([ + getDataChangeLogs({ page, tableName, action, startDate, endDate }), + getDataChangeTableOptions(), + getDataChangeStats(), + ]) + + const exportParams: Record = {} + if (tableName) exportParams.tableName = tableName + if (action) exportParams.action = action + if (startDate) exportParams.startDate = startDate + if (endDate) exportParams.endDate = endDate + + return ( +
+
+
+

Data Change Logs

+

+ Track all data mutations (create/update/delete) across system tables for compliance. +

+
+ +
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx b/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx new file mode 100644 index 0000000..2d25788 --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/login-logs/page.tsx @@ -0,0 +1,59 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getLoginLogs } from "@/modules/audit/data-access" +import { LoginLogView } from "@/modules/audit/components/login-log-view" +import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" +import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function LoginLogsPage({ + searchParams, +}: { + searchParams: Promise +}) { + await requirePermission(Permissions.AUDIT_LOG_READ) + + const params = await searchParams + const page = Number(getParam(params, "page") ?? "1") || 1 + const action = (getParam(params, "action") as LoginLogAction | undefined) ?? undefined + const status = (getParam(params, "status") as LoginLogStatus | undefined) ?? undefined + const startDate = getParam(params, "startDate") ?? undefined + const endDate = getParam(params, "endDate") ?? undefined + + const result = await getLoginLogs({ page, action, status, startDate, endDate }) + + const exportParams: Record = {} + if (action) exportParams.action = action + if (status) exportParams.status = status + if (startDate) exportParams.startDate = startDate + if (endDate) exportParams.endDate = endDate + + return ( +
+
+
+

Login Logs

+

+ Monitor all authentication events including sign in, sign out, and sign up. +

+
+ +
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/audit-logs/page.tsx b/src/app/(dashboard)/admin/audit-logs/page.tsx new file mode 100644 index 0000000..211a6b5 --- /dev/null +++ b/src/app/(dashboard)/admin/audit-logs/page.tsx @@ -0,0 +1,65 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access" +import { AuditLogView } from "@/modules/audit/components/audit-log-view" +import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button" +import type { AuditLogStatus } from "@/modules/audit/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function AuditLogsPage({ + searchParams, +}: { + searchParams: Promise +}) { + await requirePermission(Permissions.AUDIT_LOG_READ) + + const params = await searchParams + const page = Number(getParam(params, "page") ?? "1") || 1 + const moduleFilter = getParam(params, "module") ?? undefined + const action = getParam(params, "action") ?? undefined + const status = (getParam(params, "status") as AuditLogStatus | undefined) ?? undefined + const startDate = getParam(params, "startDate") ?? undefined + const endDate = getParam(params, "endDate") ?? undefined + + const [result, moduleOptions] = await Promise.all([ + getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }), + getAuditModuleOptions(), + ]) + + const exportParams: Record = {} + if (moduleFilter) exportParams.module = moduleFilter + if (action) exportParams.action = action + if (status) exportParams.status = status + if (startDate) exportParams.startDate = startDate + if (endDate) exportParams.endDate = endDate + + return ( +
+
+
+

Audit Logs

+

+ Track all user operations across the system for security and compliance. +

+
+ +
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx new file mode 100644 index 0000000..0dcb9be --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx @@ -0,0 +1,45 @@ +import { notFound } from "next/navigation" + +import { getCoursePlanById } from "@/modules/course-plans/data-access" +import { getSubjectOptions } from "@/modules/course-plans/data-access" +import { getAdminClasses } from "@/modules/classes/data-access" +import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" +import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" + +export const dynamic = "force-dynamic" + +export default async function EditCoursePlanPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + const [plan, classes, subjects, teachers, academicYears] = await Promise.all([ + getCoursePlanById(id), + getAdminClasses(), + getSubjectOptions(), + getStaffOptions(), + getAcademicYears(), + ]) + + if (!plan) notFound() + + return ( +
+
+

Edit Course Plan

+

Update the course plan details below.

+
+ ({ id: c.id, name: c.name }))} + subjects={subjects} + teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))} + backHref={`/admin/course-plans/${plan.id}`} + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/[id]/page.tsx b/src/app/(dashboard)/admin/course-plans/[id]/page.tsx new file mode 100644 index 0000000..63e1a49 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/[id]/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation" + +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 CoursePlanDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const plan = await getCoursePlanById(id) + + if (!plan) notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/create/page.tsx b/src/app/(dashboard)/admin/course-plans/create/page.tsx new file mode 100644 index 0000000..8c048b8 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/create/page.tsx @@ -0,0 +1,32 @@ +import { getAdminClasses } from "@/modules/classes/data-access" +import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access" +import { getSubjectOptions } from "@/modules/course-plans/data-access" +import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form" + +export const dynamic = "force-dynamic" + +export default async function CreateCoursePlanPage() { + const [classes, subjects, teachers, academicYears] = await Promise.all([ + getAdminClasses(), + getSubjectOptions(), + getStaffOptions(), + getAcademicYears(), + ]) + + return ( +
+
+

New Course Plan

+

Create a new course teaching plan.

+
+ ({ id: c.id, name: c.name }))} + subjects={subjects} + teachers={teachers.map((t) => ({ id: t.id, name: t.name }))} + academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))} + backHref="/admin/course-plans" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/course-plans/page.tsx b/src/app/(dashboard)/admin/course-plans/page.tsx new file mode 100644 index 0000000..98dc4a6 --- /dev/null +++ b/src/app/(dashboard)/admin/course-plans/page.tsx @@ -0,0 +1,45 @@ +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" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is CoursePlanStatus => + v === "planning" || v === "active" || v === "completed" || v === "paused" + +export default async function AdminCoursePlansPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const plans = await getCoursePlans({ status }) + + return ( +
+
+

Course Plans

+

+ Manage course teaching plans and weekly schedules. +

+
+ `/admin/course-plans/${id}`} + initialStatus={status} + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/files/page.tsx b/src/app/(dashboard)/admin/files/page.tsx new file mode 100644 index 0000000..51cab66 --- /dev/null +++ b/src/app/(dashboard)/admin/files/page.tsx @@ -0,0 +1,19 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { + getFileAttachmentsWithFilters, + getFileStats, +} from "@/modules/files/data-access" +import { AdminFilesView } from "@/modules/files/components/admin-files-view" + +export const dynamic = "force-dynamic" + +export default async function AdminFilesPage() { + await requirePermission(Permissions.FILE_READ) + const [files, stats] = await Promise.all([ + getFileAttachmentsWithFilters({ limit: 200 }), + getFileStats(), + ]) + + return +} diff --git a/src/app/(dashboard)/admin/scheduling/auto/page.tsx b/src/app/(dashboard)/admin/scheduling/auto/page.tsx new file mode 100644 index 0000000..f9fbb24 --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/auto/page.tsx @@ -0,0 +1,51 @@ +import Link from "next/link" +import { CalendarClock, ClipboardList, Settings2 } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getAdminClassesForScheduling } from "@/modules/scheduling/actions" +import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel" + +export const dynamic = "force-dynamic" + +export default async function AdminSchedulingAutoPage() { + const classes = await getAdminClassesForScheduling() + const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) + + return ( +
+
+
+

Auto Schedule

+

+ Generate a weekly schedule automatically based on configured rules and subject + assignments. +

+
+ +
+ + {classOptions.length === 0 ? ( + + ) : ( + + )} + +
+ + + Applying a new schedule will replace the existing schedule for the selected class. + +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/changes/page.tsx b/src/app/(dashboard)/admin/scheduling/changes/page.tsx new file mode 100644 index 0000000..7e095ef --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/changes/page.tsx @@ -0,0 +1,91 @@ +import Link from "next/link" +import { PlusCircle, ClipboardList } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { + getAdminClassesForScheduling, + getScheduleChanges, +} from "@/modules/scheduling/actions" +import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list" +import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-conflicts-view" +import type { ScheduleChangeStatus } from "@/modules/scheduling/types" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is ScheduleChangeStatus => + v === "pending" || v === "approved" || v === "rejected" || v === "completed" + +export default async function AdminSchedulingChangesPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + const classIdParam = getParam(sp, "classId") + const classId = classIdParam && classIdParam !== "all" ? classIdParam : undefined + + const [classes, items] = await Promise.all([ + getAdminClassesForScheduling(), + getScheduleChanges({ status, classId }), + ]) + const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) + + return ( +
+
+
+

Schedule Change Requests

+

+ Review, approve, or reject schedule change and substitute teacher requests. +

+
+ +
+ + {items.length === 0 && !status && !classId ? ( + + ) : ( + + )} + +
+

Conflict Detection

+

+ Detect time overlaps in an existing class schedule. +

+ {classOptions.length === 0 ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/scheduling/rules/page.tsx b/src/app/(dashboard)/admin/scheduling/rules/page.tsx new file mode 100644 index 0000000..7cd7cf1 --- /dev/null +++ b/src/app/(dashboard)/admin/scheduling/rules/page.tsx @@ -0,0 +1,47 @@ +import { CalendarCog, ClipboardList } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" +import { + getAdminClassesForScheduling, + getSchedulingRules, +} from "@/modules/scheduling/actions" +import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form" + +export const dynamic = "force-dynamic" + +export default async function AdminSchedulingRulesPage() { + const [classes, existingRules] = await Promise.all([ + getAdminClassesForScheduling(), + getSchedulingRules(), + ]) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) + + return ( +
+
+

Scheduling Rules

+

+ Configure daily hour limits, break windows, and balancing preferences for each class. +

+
+ + {classOptions.length === 0 ? ( + + ) : ( + + )} + +
+ + + Tip: rules saved without selecting a specific class become the global default. + +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/users/import/page.tsx b/src/app/(dashboard)/admin/users/import/page.tsx new file mode 100644 index 0000000..360f6b3 --- /dev/null +++ b/src/app/(dashboard)/admin/users/import/page.tsx @@ -0,0 +1,134 @@ +import { Metadata } from "next" +import Link from "next/link" +import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { UserImportDialog } from "@/modules/users/components/user-import-dialog" + +export const metadata: Metadata = { + title: "批量导入用户 - Next_Edu", + description: "通过 Excel 批量导入用户", +} + +export default function UserImportPage() { + return ( +
+
+
+
+ +

批量导入用户

+
+

+ 通过 Excel 文件批量创建用户账号,支持学生自动加入班级。 +

+
+ +
+ +
+ + +
+ + 导入说明 +
+ 使用 Excel 批量导入用户的步骤 +
+ +
+ 1 +

点击「批量导入用户」按钮,下载导入模板。

+
+
+ 2 +

按模板格式填写用户信息(姓名、邮箱、角色、手机、班级邀请码)。

+
+
+ 3 +

上传填写好的 Excel 文件,系统将解析并预览数据。

+
+
+ 4 +

确认预览数据无误后,点击「确认导入」完成批量创建。

+
+
+
+ + + +
+ + 注意事项 +
+ 导入前请仔细阅读 +
+ +

• 默认密码为 123456,请提示用户首次登录后修改。

+

• 邮箱必须唯一,重复邮箱将被跳过并记录在错误报告中。

+

• 角色可选:admin / teacher / student / parent / grade_head / teaching_head。

+

• 班级邀请码仅对 student 角色有效,填写后学生将自动加入对应班级。

+

• 单次最多导入 10MB 的文件,建议单次不超过 500 条记录。

+

• 导入完成后将显示成功数、失败数及详细错误信息。

+
+
+
+ + + +
+ + 模板字段说明 +
+ Excel 模板各列含义与要求 +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
列名是否必填说明
姓名必填用户姓名
邮箱必填登录账号,需符合邮箱格式且唯一
角色必填admin / teacher / student / parent / grade_head / teaching_head
手机选填联系电话
班级邀请码选填仅 student 角色有效,6 位邀请码
+
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/announcements/page.tsx b/src/app/(dashboard)/announcements/page.tsx new file mode 100644 index 0000000..3bfa868 --- /dev/null +++ b/src/app/(dashboard)/announcements/page.tsx @@ -0,0 +1,20 @@ +import { getAnnouncements } from "@/modules/announcements/data-access" +import { AnnouncementList } from "@/modules/announcements/components/announcement-list" + +export const dynamic = "force-dynamic" + +export default async function AnnouncementsPage() { + const announcements = await getAnnouncements({ status: "published" }) + + return ( +
+
+

Announcements

+

+ Stay up to date with the latest school announcements. +

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/messages/[id]/page.tsx b/src/app/(dashboard)/messages/[id]/page.tsx new file mode 100644 index 0000000..1d1b7e2 --- /dev/null +++ b/src/app/(dashboard)/messages/[id]/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from "next/navigation" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getMessageById, markMessageAsRead } from "@/modules/messaging/data-access" +import { MessageDetail } from "@/modules/messaging/components/message-detail" + +export const dynamic = "force-dynamic" + +export default async function MessageDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const { id } = await params + + const message = await getMessageById(id, ctx.userId) + if (!message) notFound() + + // Auto-mark as read when viewed by the receiver + if (!message.isRead && message.receiverId === ctx.userId) { + await markMessageAsRead(id, ctx.userId) + } + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/messages/compose/page.tsx b/src/app/(dashboard)/messages/compose/page.tsx new file mode 100644 index 0000000..c3eefbc --- /dev/null +++ b/src/app/(dashboard)/messages/compose/page.tsx @@ -0,0 +1,34 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getRecipients } from "@/modules/messaging/data-access" +import { MessageCompose } from "@/modules/messaging/components/message-compose" + +export const dynamic = "force-dynamic" + +export default async function ComposeMessagePage({ + searchParams, +}: { + searchParams: Promise<{ parentId?: string; receiverId?: string; subject?: string }> +}) { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const sp = await searchParams + + const recipients = await getRecipients(ctx.userId, ctx.dataScope) + + return ( +
+
+
+

Compose Message

+

Send a message to another user.

+
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/messages/page.tsx b/src/app/(dashboard)/messages/page.tsx new file mode 100644 index 0000000..0dc9b3c --- /dev/null +++ b/src/app/(dashboard)/messages/page.tsx @@ -0,0 +1,31 @@ +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { getMessages, getNotifications } from "@/modules/messaging/data-access" +import { MessageList } from "@/modules/messaging/components/message-list" +import { NotificationList } from "@/modules/messaging/components/notification-list" + +export const dynamic = "force-dynamic" + +export default async function MessagesPage() { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const [messagesResult, notificationsResult] = await Promise.all([ + getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }), + getNotifications(ctx.userId, { page: 1, pageSize: 20 }), + ]) + + return ( +
+
+

Messages

+

+ Manage your inbox and stay updated with notifications. +

+
+ + + + +
+ ) +} diff --git a/src/app/(dashboard)/parent/attendance/page.tsx b/src/app/(dashboard)/parent/attendance/page.tsx new file mode 100644 index 0000000..b5c31df --- /dev/null +++ b/src/app/(dashboard)/parent/attendance/page.tsx @@ -0,0 +1,61 @@ +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 { EmptyState } from "@/shared/components/ui/empty-state" +import { CalendarCheck } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function ParentAttendancePage() { + const ctx = await getAuthContext() + + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( +
+
+

Children Attendance

+

View your children's attendance records.

+
+ +
+ ) + } + + const summaries = await Promise.all( + ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id)) + ) + + const validSummaries = summaries.filter((s): s is NonNullable => s !== null) + + return ( +
+
+

Children Attendance

+

View your children's attendance records.

+
+ + {validSummaries.length === 0 ? ( + + ) : ( +
+ {validSummaries.map((summary) => ( +
+

{summary.studentName}

+ +
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/parent/children/[studentId]/page.tsx b/src/app/(dashboard)/parent/children/[studentId]/page.tsx new file mode 100644 index 0000000..f851557 --- /dev/null +++ b/src/app/(dashboard)/parent/children/[studentId]/page.tsx @@ -0,0 +1,71 @@ +import { notFound } from "next/navigation" +import { eq } from "drizzle-orm" + +import { requireAuth } from "@/shared/lib/auth-guard" +import { db } from "@/shared/db" +import { parentStudentRelations } from "@/shared/db/schema" +import { getChildDashboardData } from "@/modules/parent/data-access" +import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header" +import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { ShieldAlert } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function ChildDetailPage({ + params, +}: { + params: Promise<{ studentId: string }> +}) { + const { studentId } = await params + const ctx = await requireAuth() + + // Verify the student is linked to the current parent + const [relation] = await db + .select({ + id: parentStudentRelations.id, + relation: parentStudentRelations.relation, + }) + .from(parentStudentRelations) + .where(eq(parentStudentRelations.studentId, studentId)) + .limit(1) + + if (!relation) { + return ( +
+ +
+ ) + } + + // Double-check the parent owns this relation + if (ctx.dataScope.type === "children" && !ctx.dataScope.childrenIds.includes(studentId)) { + return ( +
+ +
+ ) + } + + const child = await getChildDashboardData(studentId, relation.relation) + if (!child) { + notFound() + } + + return ( +
+ + +
+ ) +} diff --git a/src/app/(dashboard)/parent/dashboard/page.tsx b/src/app/(dashboard)/parent/dashboard/page.tsx index 8480dce..58c79b5 100644 --- a/src/app/(dashboard)/parent/dashboard/page.tsx +++ b/src/app/(dashboard)/parent/dashboard/page.tsx @@ -1,8 +1,16 @@ -export default function ParentDashboardPage() { +import { requireAuth } from "@/shared/lib/auth-guard" +import { getParentDashboardData } from "@/modules/parent/data-access" +import { ParentDashboard } from "@/modules/parent/components/parent-dashboard" + +export const dynamic = "force-dynamic" + +export default async function ParentDashboardPage() { + const ctx = await requireAuth() + const data = await getParentDashboardData(ctx.userId) + return ( -
-

Parent Dashboard

-

Welcome, Parent!

+
+
) } diff --git a/src/app/(dashboard)/parent/grades/page.tsx b/src/app/(dashboard)/parent/grades/page.tsx new file mode 100644 index 0000000..052e91e --- /dev/null +++ b/src/app/(dashboard)/parent/grades/page.tsx @@ -0,0 +1,61 @@ +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getStudentGradeSummary } from "@/modules/grades/data-access" +import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { GraduationCap } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function ParentGradesPage() { + const ctx = await getAuthContext() + + if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { + return ( +
+
+

Children Grades

+

View your children's grade records.

+
+ +
+ ) + } + + const summaries = await Promise.all( + ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)) + ) + + const validSummaries = summaries.filter((s): s is NonNullable => s !== null) + + return ( +
+
+

Children Grades

+

View your children's grade records.

+
+ + {validSummaries.length === 0 ? ( + + ) : ( +
+ {validSummaries.map((summary) => ( +
+

{summary.studentName}

+ +
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 5ad06a3..c181de2 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -5,6 +5,7 @@ import { AdminSettingsView } from "@/modules/settings/components/admin-settings- import { StudentSettingsView } from "@/modules/settings/components/student-settings-view" import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view" import { getUserProfile } from "@/modules/users/data-access" +import { getNotificationPreferences } from "@/modules/messaging/notification-preferences" import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -19,8 +20,13 @@ export default async function SettingsPage() { if (!userProfile) redirect("/login") const permissions = session.user.permissions ?? [] + const notificationPrefs = await getNotificationPreferences(userId) - if (permissions.includes(Permissions.SETTINGS_ADMIN)) return - if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) return - return + if (permissions.includes(Permissions.SETTINGS_ADMIN)) { + return + } + if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) { + return + } + return } diff --git a/src/app/(dashboard)/settings/security/page.tsx b/src/app/(dashboard)/settings/security/page.tsx new file mode 100644 index 0000000..84160eb --- /dev/null +++ b/src/app/(dashboard)/settings/security/page.tsx @@ -0,0 +1,50 @@ +import { redirect } from "next/navigation" +import { Lock } from "lucide-react" + +import { auth } from "@/auth" +import { PasswordChangeForm } from "@/modules/settings/components/password-change-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" + +export const dynamic = "force-dynamic" + +export const metadata = { + title: "Security Settings", +} + +export default async function SecuritySettingsPage() { + const session = await auth() + if (!session?.user) redirect("/login") + + return ( +
+
+
+ +

Security

+
+
+ Manage your password and account security settings. +
+
+ +
+ + + + + Security Tips + Best practices to keep your account safe. + + +
    +
  • Use a unique password that you don't reuse across other sites.
  • +
  • Avoid common words, names, or sequential patterns.
  • +
  • Change your password periodically.
  • +
  • Your account will be temporarily locked after multiple failed login attempts.
  • +
+
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/student/attendance/page.tsx b/src/app/(dashboard)/student/attendance/page.tsx new file mode 100644 index 0000000..4047225 --- /dev/null +++ b/src/app/(dashboard)/student/attendance/page.tsx @@ -0,0 +1,40 @@ +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 { EmptyState } from "@/shared/components/ui/empty-state" +import { CalendarCheck } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function StudentAttendancePage() { + const ctx = await getAuthContext() + + const summary = await getStudentAttendanceSummary(ctx.userId) + + if (!summary) { + return ( +
+
+

My Attendance

+

View your attendance records.

+
+ +
+ ) + } + + return ( +
+
+

My Attendance

+

View your attendance records and statistics.

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/student/grades/page.tsx b/src/app/(dashboard)/student/grades/page.tsx new file mode 100644 index 0000000..ab060f8 --- /dev/null +++ b/src/app/(dashboard)/student/grades/page.tsx @@ -0,0 +1,40 @@ +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getStudentGradeSummary } from "@/modules/grades/data-access" +import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { GraduationCap } from "lucide-react" + +export const dynamic = "force-dynamic" + +export default async function StudentGradesPage() { + const ctx = await getAuthContext() + + const summary = await getStudentGradeSummary(ctx.userId) + + if (!summary) { + return ( +
+
+

My Grades

+

View your grade records.

+
+ +
+ ) + } + + return ( +
+
+

My Grades

+

View your grade records.

+
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/page.tsx b/src/app/(dashboard)/teacher/attendance/page.tsx new file mode 100644 index 0000000..2f0eda3 --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/page.tsx @@ -0,0 +1,83 @@ +import Link from "next/link" +import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getAttendanceRecords } from "@/modules/attendance/data-access" +import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" +import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function TeacherAttendancePage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const ctx = await getAuthContext() + + const classId = getParam(sp, "classId") + const status = getParam(sp, "status") + const date = getParam(sp, "date") + + const classes = await getTeacherClasses() + 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: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined, + date: date && date.length > 0 ? date : undefined, + }) + + return ( +
+
+
+

Attendance

+

Manage student attendance records.

+
+
+ + +
+
+ + + + {result.items.length === 0 && !classId && !status && !date ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/sheet/page.tsx b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx new file mode 100644 index 0000000..13bc62f --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/sheet/page.tsx @@ -0,0 +1,49 @@ +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getClassStudentsForAttendance } from "@/modules/attendance/data-access" +import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function AttendanceSheetPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + + const defaultClassId = getParam(sp, "classId") + const defaultDate = getParam(sp, "date") + + const classes = await getTeacherClasses() + const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + + let students: Array<{ id: string; name: string; email: string }> = [] + if (defaultClassId) { + students = await getClassStudentsForAttendance(defaultClassId) + } + + return ( +
+
+

Take Attendance

+

+ Select a class and date, then mark attendance for each student. +

+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/attendance/stats/page.tsx b/src/app/(dashboard)/teacher/attendance/stats/page.tsx new file mode 100644 index 0000000..661b67e --- /dev/null +++ b/src/app/(dashboard)/teacher/attendance/stats/page.tsx @@ -0,0 +1,120 @@ +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getClassAttendanceStats } 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 { EmptyState } from "@/shared/components/ui/empty-state" +import { BarChart3 } from "lucide-react" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function AttendanceStatsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + + const classId = getParam(sp, "classId") + const startDate = getParam(sp, "startDate") + const endDate = getParam(sp, "endDate") + + const classes = await getTeacherClasses() + + if (classes.length === 0) { + return ( +
+
+

Attendance Statistics

+

View class attendance statistics.

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

Attendance Statistics

+

View class attendance statistics and trends.

+
+ + + + {summary ? ( + <> + +
+

Student Records

+ +
+ + ) : ( + + )} +
+ ) +} + +function StatsClassSelector({ + classes, + currentClassId, + startDate, + endDate, +}: { + classes: Array<{ id: string; name: string }> + currentClassId: string + startDate: string + endDate: string +}) { + const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}` + return ( +
+ {classes.map((c) => ( + + {c.name} + + ))} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx new file mode 100644 index 0000000..ce6c7ef --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx @@ -0,0 +1,26 @@ +import { notFound } from "next/navigation" + +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 TeacherCoursePlanDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const plan = await getCoursePlanById(id) + + if (!plan) notFound() + + return ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/course-plans/page.tsx b/src/app/(dashboard)/teacher/course-plans/page.tsx new file mode 100644 index 0000000..d3159c0 --- /dev/null +++ b/src/app/(dashboard)/teacher/course-plans/page.tsx @@ -0,0 +1,49 @@ +import { auth } from "@/auth" +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" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +const isValidStatus = (v?: string): v is CoursePlanStatus => + v === "planning" || v === "active" || v === "completed" || v === "paused" + +export default async function TeacherCoursePlansPage({ + searchParams, +}: { + searchParams: Promise +}) { + const session = await auth() + const teacherId = String(session?.user?.id ?? "") + + const sp = await searchParams + const statusParam = getParam(sp, "status") + const status = isValidStatus(statusParam) ? statusParam : undefined + + const plans = teacherId + ? await getCoursePlans({ teacherId, status }) + : [] + + return ( +
+
+

My Course Plans

+

+ View your course teaching plans and weekly schedules. +

+
+ `/teacher/course-plans/${id}`} + initialStatus={status} + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/analytics/page.tsx b/src/app/(dashboard)/teacher/grades/analytics/page.tsx new file mode 100644 index 0000000..3e74d7c --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/analytics/page.tsx @@ -0,0 +1,259 @@ +import Link from "next/link" +import { BarChart3, ArrowLeft } from "lucide-react" +import { asc } from "drizzle-orm" + +import { db } from "@/shared/db" +import { subjects } from "@/shared/db/schema" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getGrades } from "@/modules/school/data-access" + +import { + getClassComparison, + getGradeDistribution, + getGradeTrend, + getSubjectComparison, +} from "@/modules/grades/data-access-analytics" +import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart" +import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart" +import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart" +import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function GradeAnalyticsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const sp = await searchParams + const ctx = await getAuthContext() + + const classId = getParam(sp, "classId") + const subjectId = getParam(sp, "subjectId") + const gradeId = getParam(sp, "gradeId") + + const [classes, allGrades, allSubjects] = await Promise.all([ + getTeacherClasses(), + getGrades(), + db.query.subjects.findMany({ + orderBy: [asc(subjects.order), asc(subjects.name)], + }), + ]) + + if (classes.length === 0) { + return ( +
+
+

Grade Analytics

+

+ Trend analysis, class comparisons, and score distributions. +

+
+ +
+ ) + } + + const targetClassId = classId ?? classes[0].id + const targetSubjectId = + subjectId && subjectId !== "all" ? subjectId : undefined + const targetGradeId = gradeId ?? allGrades[0]?.id + + // Run analytics queries in parallel + const [trend, distribution, subjectComparison, classComparison] = + await Promise.all([ + getGradeTrend({ + classId: targetClassId, + subjectId: targetSubjectId, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }), + getGradeDistribution({ + classId: targetClassId, + subjectId: targetSubjectId, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }), + getSubjectComparison({ + classId: targetClassId, + scope: ctx.dataScope, + }), + targetGradeId + ? getClassComparison({ + gradeId: targetGradeId, + subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "", + scope: ctx.dataScope, + }) + : Promise.resolve([]), + ]) + + return ( +
+
+
+

Grade Analytics

+

+ Trend analysis, class comparisons, and score distributions. +

+
+ +
+ + ({ id: c.id, name: c.name }))} + grades={allGrades.map((g) => ({ id: g.id, name: g.name }))} + subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))} + currentClassId={targetClassId} + currentSubjectId={subjectId ?? "all"} + currentGradeId={targetGradeId ?? ""} + /> + +
+ + + + +
+
+ ) +} + +interface AnalyticsFiltersProps { + classes: Array<{ id: string; name: string }> + grades: Array<{ id: string; name: string }> + subjects: Array<{ id: string; name: string }> + currentClassId: string + currentSubjectId: string + currentGradeId: string +} + +function AnalyticsFilters({ + classes, + grades, + subjects, + currentClassId, + currentSubjectId, + currentGradeId, +}: AnalyticsFiltersProps) { + const buildHref = (overrides: { + classId?: string + subjectId?: string + gradeId?: string + }) => { + const params = new URLSearchParams() + params.set( + "classId", + overrides.classId !== undefined ? overrides.classId : currentClassId + ) + params.set( + "subjectId", + overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId + ) + if ( + overrides.gradeId !== undefined + ? overrides.gradeId + : currentGradeId + ) { + params.set( + "gradeId", + overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId + ) + } + return `/teacher/grades/analytics?${params.toString()}` + } + + return ( +
+
+
+
Class
+
+ {classes.map((c) => ( + + {c.name} + + ))} +
+
+ +
+
Subject
+
+ + All + + {subjects.map((s) => ( + + {s.name} + + ))} +
+
+ +
+
+ Grade (for class comparison) +
+
+ {grades.map((g) => ( + + {g.name} + + ))} +
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/entry/page.tsx b/src/app/(dashboard)/teacher/grades/entry/page.tsx new file mode 100644 index 0000000..5a166ab --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/entry/page.tsx @@ -0,0 +1,52 @@ +import { db } from "@/shared/db" +import { subjects } from "@/shared/db/schema" +import { asc } from "drizzle-orm" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getClassStudentsForEntry } from "@/modules/grades/data-access" +import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function BatchEntryPage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams + + const defaultClassId = getParam(sp, "classId") + const defaultSubjectId = getParam(sp, "subjectId") + + const [classes, allSubjects] = await Promise.all([ + getTeacherClasses(), + db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }), + ]) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) + + let students: Array<{ id: string; name: string; email: string }> = [] + if (defaultClassId) { + students = await getClassStudentsForEntry(defaultClassId) + } + + return ( +
+
+

Batch Grade Entry

+

Enter grades for all students in a class at once.

+
+ + +
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/page.tsx b/src/app/(dashboard)/teacher/grades/page.tsx new file mode 100644 index 0000000..9fbc537 --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/page.tsx @@ -0,0 +1,101 @@ +import Link from "next/link" +import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { db } from "@/shared/db" +import { subjects } from "@/shared/db/schema" +import { asc } from "drizzle-orm" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getGradeRecords } from "@/modules/grades/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" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams + const ctx = await getAuthContext() + + const classId = getParam(sp, "classId") + const subjectId = getParam(sp, "subjectId") + const type = getParam(sp, "type") + const semester = getParam(sp, "semester") + + const [classes, allSubjects] = await Promise.all([ + getTeacherClasses(), + db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }), + ]) + + const records = await getGradeRecords({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + classId: classId && classId !== "all" ? classId : undefined, + subjectId: subjectId && subjectId !== "all" ? subjectId : undefined, + type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined, + semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined, + }) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) + + return ( +
+
+
+

Grades

+

Manage student grade records.

+
+
+ + + + +
+
+ + + + {records.length === 0 && !classId && !subjectId ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/stats/page.tsx b/src/app/(dashboard)/teacher/grades/stats/page.tsx new file mode 100644 index 0000000..b7578d8 --- /dev/null +++ b/src/app/(dashboard)/teacher/grades/stats/page.tsx @@ -0,0 +1,139 @@ +import { db } from "@/shared/db" +import { subjects } from "@/shared/db/schema" +import { asc } from "drizzle-orm" +import { getTeacherClasses } from "@/modules/classes/data-access" +import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access" +import { ClassGradeReport } from "@/modules/grades/components/class-grade-report" +import { ExportButton } from "@/modules/grades/components/export-button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { BarChart3 } from "lucide-react" + +export const dynamic = "force-dynamic" + +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v +} + +export default async function StatsPage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams + + const classId = getParam(sp, "classId") + const subjectId = getParam(sp, "subjectId") + + const [classes, allSubjects] = await Promise.all([ + getTeacherClasses(), + db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }), + ]) + + if (classes.length === 0) { + return ( +
+
+

Grade Statistics

+

View class grade statistics and rankings.

+
+ +
+ ) + } + + const targetClassId = classId ?? classes[0].id + const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined + + const [stats, ranking] = await Promise.all([ + getClassGradeStatsWithMeta(targetClassId, targetSubjectId), + getClassRanking(targetClassId, targetSubjectId), + ]) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) + + return ( +
+
+
+

Grade Statistics

+

View class grade statistics and rankings.

+
+ +
+ + + + +
+ ) +} + +function StatsClassSelector({ + classes, + subjects, + currentClassId, + currentSubjectId, +}: { + classes: Array<{ id: string; name: string }> + subjects: Array<{ id: string; name: string }> + currentClassId: string + currentSubjectId: string +}) { + return ( +
+ {classes.map((c) => ( + + {c.name} + + ))} +
+ + All Subjects + + {subjects.map((s) => ( + + {s.name} + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/schedule-changes/page.tsx b/src/app/(dashboard)/teacher/schedule-changes/page.tsx new file mode 100644 index 0000000..587ee02 --- /dev/null +++ b/src/app/(dashboard)/teacher/schedule-changes/page.tsx @@ -0,0 +1,69 @@ +import { ClipboardList } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" +import { getAuthContext } from "@/shared/lib/auth-guard" +import { + getAdminClassesForScheduling, + getScheduleChanges, + getTeachersForScheduling, +} from "@/modules/scheduling/actions" +import { ScheduleChangeForm } from "@/modules/scheduling/components/schedule-change-form" +import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list" + +export const dynamic = "force-dynamic" + +export default async function TeacherScheduleChangesPage() { + const ctx = await getAuthContext() + + // Teachers see only their own requests; admins landing here see all. + const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId + + const [classes, teachers, items] = await Promise.all([ + getAdminClassesForScheduling(), + getTeachersForScheduling(), + getScheduleChanges({ requesterId }), + ]) + + const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade })) + const teacherOptions = teachers.map((t) => ({ + id: t.id, + name: t.name ?? "Unknown", + email: t.email ?? "", + })) + + return ( +
+
+

Schedule Change Requests

+

+ Submit a schedule change or substitute teacher request, and track its status. +

+
+ + {classOptions.length === 0 ? ( + + ) : ( +
+ + +
+

My Requests

+ {items.length === 0 ? ( + + ) : ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index f2134ff..18863f8 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { createAiChatCompletion, getAiErrorMessage, parseAiChatPayload } from "@/shared/lib/ai" +import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" export const dynamic = "force-dynamic" @@ -16,11 +17,24 @@ export async function POST(req: Request) { const userId = String(session?.user?.id ?? "").trim() if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 }) + // Rate limit AI chat per user + const limitKey = rateLimitKey("ai-chat", userId) + const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.AI_CHAT }) + if (!limit.success) { + return NextResponse.json( + { success: false, message: "Rate limit exceeded. Please slow down." }, + { status: 429, headers: rateLimitHeaders(limit) } + ) + } + try { const body = await req.json().catch(() => null) const input = parseAiChatPayload(body) const result = await createAiChatCompletion(input) - return NextResponse.json({ success: true, content: result.content, usage: result.usage }) + return NextResponse.json( + { success: true, content: result.content, usage: result.usage }, + { headers: rateLimitHeaders(limit) } + ) } catch (e) { const message = getAiErrorMessage(e) return NextResponse.json({ success: false, message }, { status: getStatusFromError(message) }) diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts new file mode 100644 index 0000000..5b6b18e --- /dev/null +++ b/src/app/api/export/route.ts @@ -0,0 +1,135 @@ +import { NextResponse } from "next/server" + +import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { exportUsersToExcel } from "@/modules/users/import-export" +import { exportGradeRecordsToExcel } from "@/modules/grades/export" +import { + exportAuditLogsAction, + exportDataChangeLogsAction, + exportLoginLogsAction, +} from "@/modules/audit/actions" + +export const dynamic = "force-dynamic" + +type ExportType = "grades" | "users" | "attendance" | "audit" | "login" | "dataChange" + +const isExportType = (v: string): v is ExportType => + v === "grades" || v === "users" || v === "attendance" || v === "audit" || v === "login" || v === "dataChange" + +export async function POST(req: Request) { + try { + const ctx = await requireAuth() + + const body = (await req.json()) as { + type?: string + params?: Record + } + + const type = body.type ?? "" + if (!isExportType(type)) { + return NextResponse.json( + { success: false, message: "Invalid export type" }, + { status: 400 } + ) + } + + const params = body.params ?? {} + let buffer: Buffer + let filename: string + + if (type === "grades") { + buffer = await exportGradeRecordsToExcel({ + classId: String(params.classId ?? ""), + subjectId: params.subjectId ? String(params.subjectId) : undefined, + examId: params.examId ? String(params.examId) : undefined, + scope: ctx.dataScope, + }) + filename = `grades_export_${formatDateForFile()}.xlsx` + } else if (type === "users") { + buffer = await exportUsersToExcel({ + scope: ctx.dataScope, + role: params.role ? String(params.role) : undefined, + }) + filename = `users_export_${formatDateForFile()}.xlsx` + } else if (type === "audit" || type === "login" || type === "dataChange") { + // Audit-related exports require AUDIT_LOG_READ permission + try { + await requirePermission(Permissions.AUDIT_LOG_READ) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + throw e + } + + const stringParams = Object.fromEntries( + Object.entries(params).filter(([, v]) => typeof v === "string") + ) as Record + + if (type === "audit") { + const result = await exportAuditLogsAction(stringParams) + if (!result.success || !result.data) { + return NextResponse.json( + { success: false, message: result.message ?? "Export failed" }, + { status: 500 } + ) + } + buffer = result.data.buffer + filename = result.data.filename + } else if (type === "login") { + const result = await exportLoginLogsAction(stringParams) + if (!result.success || !result.data) { + return NextResponse.json( + { success: false, message: result.message ?? "Export failed" }, + { status: 500 } + ) + } + buffer = result.data.buffer + filename = result.data.filename + } else { + const result = await exportDataChangeLogsAction(stringParams) + if (!result.success || !result.data) { + return NextResponse.json( + { success: false, message: result.message ?? "Export failed" }, + { status: 500 } + ) + } + buffer = result.data.buffer + filename = result.data.filename + } + } else { + return NextResponse.json( + { success: false, message: "Attendance export not implemented" }, + { status: 400 } + ) + } + + return new NextResponse(new Uint8Array(buffer), { + status: 200, + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Length": String(buffer.length), + }, + }) + } catch (e) { + const message = e instanceof Error ? e.message : "Export failed" + const status = message.includes("Permission denied") || message.includes("auth_required") + ? 401 + : 500 + return NextResponse.json({ success: false, message }, { status }) + } +} + +function formatDateForFile(): string { + const now = new Date() + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, "0") + const d = String(now.getDate()).padStart(2, "0") + return `${y}-${m}-${d}` +} diff --git a/src/app/api/files/[id]/route.ts b/src/app/api/files/[id]/route.ts new file mode 100644 index 0000000..98a7754 --- /dev/null +++ b/src/app/api/files/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server" +import { unlink } from "fs/promises" +import path from "path" + +import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { + deleteFileAttachment, + getFileAttachment, +} from "@/modules/files/data-access" + +export const dynamic = "force-dynamic" + +interface RouteContext { + params: Promise<{ id: string }> +} + +/** + * GET /api/files/[id] + * 获取文件信息(需要登录) + */ +export async function GET(_req: Request, ctx: RouteContext) { + try { + await requireAuth() + const { id } = await ctx.params + + const file = await getFileAttachment(id) + if (!file) { + return NextResponse.json( + { success: false, message: "File not found" }, + { status: 404 } + ) + } + + return NextResponse.json({ success: true, file }) + } catch (e) { + const message = e instanceof Error ? e.message : "Failed to fetch file" + const status = message.includes("auth_required") ? 401 : 500 + return NextResponse.json({ success: false, message }, { status }) + } +} + +/** + * DELETE /api/files/[id] + * 删除文件(需要 FILE_DELETE 权限) + */ +export async function DELETE(_req: Request, ctx: RouteContext) { + try { + await requirePermission(Permissions.FILE_DELETE) + const { id } = await ctx.params + + const file = await getFileAttachment(id) + if (!file) { + return NextResponse.json( + { success: false, message: "File not found" }, + { status: 404 } + ) + } + + // 删除磁盘文件(静默失败,记录仍会被删除) + const absolutePath = path.join(process.cwd(), "public", file.storagePath) + try { + await unlink(absolutePath) + } catch { + // 文件可能已不存在,忽略错误 + } + + const ok = await deleteFileAttachment(id) + if (!ok) { + return NextResponse.json( + { success: false, message: "Failed to delete file record" }, + { status: 500 } + ) + } + + return NextResponse.json({ success: true, message: "File deleted" }) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + const message = e instanceof Error ? e.message : "Failed to delete file" + return NextResponse.json({ success: false, message }, { status: 500 }) + } +} diff --git a/src/app/api/files/batch-delete/route.ts b/src/app/api/files/batch-delete/route.ts new file mode 100644 index 0000000..8743489 --- /dev/null +++ b/src/app/api/files/batch-delete/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server" + +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { storageProvider } from "@/shared/lib/storage-provider" +import { + deleteFileAttachments, + getFileAttachmentsByIds, +} from "@/modules/files/data-access" + +export const dynamic = "force-dynamic" + +/** + * POST /api/files/batch-delete + * 批量删除文件(需要 FILE_DELETE 权限) + * Body: { ids: string[] } + */ +export async function POST(req: Request) { + try { + await requirePermission(Permissions.FILE_DELETE) + + const body = (await req.json().catch(() => null)) as { ids?: unknown } | null + const ids = Array.isArray(body?.ids) ? body!.ids.filter((x): x is string => typeof x === "string") : [] + + if (ids.length === 0) { + return NextResponse.json( + { success: false, message: "No file ids provided" }, + { status: 400 } + ) + } + + // 先查出文件记录,以便删除磁盘文件 + const files = await getFileAttachmentsByIds(ids) + + // 删除磁盘文件(静默失败) + await Promise.all( + files.map((f) => storageProvider.delete(f.storagePath).catch(() => undefined)) + ) + + const result = await deleteFileAttachments(ids) + + return NextResponse.json({ + success: result.success, + message: `Deleted ${result.deletedCount} file(s)`, + deletedCount: result.deletedCount, + failedIds: result.failedIds, + }) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + const message = e instanceof Error ? e.message : "Failed to batch delete files" + return NextResponse.json({ success: false, message }, { status: 500 }) + } +} diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts new file mode 100644 index 0000000..a8273ef --- /dev/null +++ b/src/app/api/import/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { parseExcel } from "@/shared/lib/excel" + +export const dynamic = "force-dynamic" + +const MAX_FILE_SIZE = 10 * 1024 * 1024 + +export async function POST(req: Request) { + try { + await requirePermission(Permissions.USER_MANAGE) + + const formData = await req.formData() + const file = formData.get("file") + + if (!(file instanceof File)) { + return NextResponse.json( + { success: false, message: "No file provided" }, + { status: 400 } + ) + } + + if (file.size === 0) { + return NextResponse.json( + { success: false, message: "File is empty" }, + { status: 400 } + ) + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { success: false, message: "File size exceeds 10MB limit" }, + { status: 413 } + ) + } + + const lowerName = file.name.toLowerCase() + if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) { + return NextResponse.json( + { success: false, message: "Only .xlsx and .xls files are supported" }, + { status: 415 } + ) + } + + const bytes = Buffer.from(await file.arrayBuffer()) + const sheets = await parseExcel(bytes) + + return NextResponse.json({ + success: true, + sheets, + fileName: file.name, + }) + } catch (e) { + const message = e instanceof Error ? e.message : "Import failed" + const status = message.includes("Permission denied") || message.includes("auth_required") + ? 401 + : 500 + return NextResponse.json({ success: false, message }, { status }) + } +} diff --git a/src/app/api/rate-limit-test/route.ts b/src/app/api/rate-limit-test/route.ts new file mode 100644 index 0000000..b40c983 --- /dev/null +++ b/src/app/api/rate-limit-test/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" + +import { auth } from "@/auth" +import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" + +export const dynamic = "force-dynamic" + +/** + * Test endpoint for the in-memory rate limiter. + * Applies a strict 5-requests-per-minute limit per user so you can + * observe 429 responses quickly during manual testing. + * + * Usage: + * curl -X GET http://localhost:3000/api/rate-limit-test \ + * -H "Cookie: next-auth.session-token=" + */ +export async function GET() { + const session = await auth() + const userId = String(session?.user?.id ?? "").trim() + if (!userId) { + return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 }) + } + + const limitKey = rateLimitKey("test", userId) + const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE }) + + if (!limit.success) { + return NextResponse.json( + { + success: false, + message: "Rate limit exceeded", + remaining: limit.remaining, + retryAfterMs: limit.retryAfterMs, + }, + { status: 429, headers: rateLimitHeaders(limit) } + ) + } + + return NextResponse.json( + { + success: true, + message: "Request allowed", + remaining: limit.remaining, + resetTime: new Date(limit.resetTime).toISOString(), + }, + { headers: rateLimitHeaders(limit) } + ) +} diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..1e93d40 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,275 @@ +import { NextResponse } from "next/server" +import { and, desc, eq, like, or, sql } from "drizzle-orm" + +import { requireAuth } from "@/shared/lib/auth-guard" +import { db } from "@/shared/db" +import { + announcements, + exams, + questions, + textbooks, +} from "@/shared/db/schema" + +export const dynamic = "force-dynamic" + +type SearchType = "all" | "question" | "textbook" | "exam" | "announcement" + +const isSearchType = (v: string): v is SearchType => + v === "all" || v === "question" || v === "textbook" || v === "exam" || v === "announcement" + +const DEFAULT_PAGE_SIZE = 10 + +interface SearchResultItem { + id: string + title: string + snippet: string + type: "question" | "textbook" | "exam" | "announcement" + href: string + createdAt: string +} + +interface SearchResponse { + success: boolean + query: string + type: SearchType + results: SearchResultItem[] + total: number + page: number + pageSize: number +} + +/** + * GET /api/search?q=keyword&type=all&page=1 + * 全文检索:questions / textbooks / exams / announcements + */ +export async function GET(req: Request) { + try { + await requireAuth() + + const { searchParams } = new URL(req.url) + const q = (searchParams.get("q") ?? "").trim() + const typeRaw = (searchParams.get("type") ?? "all").trim() + const type: SearchType = isSearchType(typeRaw) ? typeRaw : "all" + const page = Math.max(1, Number(searchParams.get("page") ?? "1") || 1) + const pageSize = Math.min( + 50, + Math.max(1, Number(searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE)) || DEFAULT_PAGE_SIZE) + ) + + if (!q) { + return NextResponse.json({ + success: true, + query: q, + type, + results: [], + total: 0, + page, + pageSize, + }) + } + + const kw = `%${q}%` + const offset = (page - 1) * pageSize + const results: SearchResultItem[] = [] + + // 并行查询各类型 + const tasks: Promise[] = [] + + if (type === "all" || type === "question") { + tasks.push(searchQuestions(kw, pageSize)) + } + if (type === "all" || type === "textbook") { + tasks.push(searchTextbooks(kw, pageSize)) + } + if (type === "all" || type === "exam") { + tasks.push(searchExams(kw, pageSize)) + } + if (type === "all" || type === "announcement") { + tasks.push(searchAnnouncements(kw, pageSize)) + } + + const grouped = await Promise.all(tasks) + for (const group of grouped) { + results.push(...group) + } + + // 按 createdAt 降序排序后分页 + results.sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + const total = results.length + const paged = results.slice(offset, offset + pageSize) + + return NextResponse.json({ + success: true, + query: q, + type, + results: paged, + total, + page, + pageSize, + }) + } catch (e) { + const message = e instanceof Error ? e.message : "Search failed" + const status = message.includes("auth_required") ? 401 : 500 + return NextResponse.json({ success: false, message }, { status }) + } +} + +async function searchQuestions(kw: string, limit: number): Promise { + try { + const rows = await db + .select({ + id: questions.id, + content: questions.content, + type: questions.type, + createdAt: questions.createdAt, + }) + .from(questions) + .where( + or( + // JSON 内容字段转换为文本进行模糊匹配 + sql`CAST(${questions.content} AS CHAR) LIKE ${kw}`, + like(sql`CAST(${questions.content} AS CHAR)`, kw) + )! + ) + .orderBy(desc(questions.createdAt)) + .limit(limit) + + return rows.map((r) => { + const text = extractTextFromJson(r.content) + return { + id: r.id, + title: truncate(text, 80) || `Question (${r.type})`, + snippet: truncate(text, 200), + type: "question" as const, + href: `/admin/questions?id=${r.id}`, + createdAt: r.createdAt.toISOString(), + } + }) + } catch { + return [] + } +} + +async function searchTextbooks(kw: string, limit: number): Promise { + try { + const rows = await db + .select({ + id: textbooks.id, + title: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + publisher: textbooks.publisher, + createdAt: textbooks.createdAt, + }) + .from(textbooks) + .where( + or( + like(textbooks.title, kw), + like(textbooks.subject, kw), + like(textbooks.publisher, kw) + )! + ) + .orderBy(desc(textbooks.createdAt)) + .limit(limit) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + snippet: [r.subject, r.grade, r.publisher].filter(Boolean).join(" · "), + type: "textbook" as const, + href: `/admin/textbooks?id=${r.id}`, + createdAt: r.createdAt.toISOString(), + })) + } catch { + return [] + } +} + +async function searchExams(kw: string, limit: number): Promise { + try { + const rows = await db + .select({ + id: exams.id, + title: exams.title, + description: exams.description, + status: exams.status, + createdAt: exams.createdAt, + }) + .from(exams) + .where( + or( + like(exams.title, kw), + like(exams.description, kw) + )! + ) + .orderBy(desc(exams.createdAt)) + .limit(limit) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + snippet: r.description ?? `Status: ${r.status ?? "draft"}`, + type: "exam" as const, + href: `/admin/exams?id=${r.id}`, + createdAt: r.createdAt.toISOString(), + })) + } catch { + return [] + } +} + +async function searchAnnouncements(kw: string, limit: number): Promise { + try { + const rows = await db + .select({ + id: announcements.id, + title: announcements.title, + content: announcements.content, + type: announcements.type, + status: announcements.status, + createdAt: announcements.createdAt, + }) + .from(announcements) + .where( + and( + eq(announcements.status, "published"), + or( + like(announcements.title, kw), + like(announcements.content, kw) + )! + ) + ) + .orderBy(desc(announcements.createdAt)) + .limit(limit) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + snippet: truncate(stripHtml(r.content), 200), + type: "announcement" as const, + href: `/announcements/${r.id}`, + createdAt: r.createdAt.toISOString(), + })) + } catch { + return [] + } +} + +function extractTextFromJson(content: unknown): string { + if (typeof content === "string") return content + try { + return JSON.stringify(content) + } catch { + return "" + } +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim() +} + +function truncate(s: string, max: number): string { + const t = s.trim() + if (t.length <= max) return t + return t.slice(0, max) + "..." +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..e043580 --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,128 @@ +import { NextResponse } from "next/server" +import { createId } from "@paralleldrive/cuid2" +import { mkdir, writeFile } from "fs/promises" +import path from "path" + +import { requireAuth } from "@/shared/lib/auth-guard" +import { + generateStoragePath, + isAllowedMimeType, + MAX_FILE_SIZE, +} from "@/shared/lib/file-storage" +import { rateLimit, rateLimitKey, rateLimitHeaders, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" +import { createFileAttachment } from "@/modules/files/data-access" +import type { FileTargetType } from "@/modules/files/types" + +export const dynamic = "force-dynamic" + +const VALID_TARGET_TYPES: FileTargetType[] = [ + "exam", + "textbook", + "question", + "announcement", +] + +const isTargetType = (v: string | null): v is FileTargetType => + v !== null && (VALID_TARGET_TYPES as readonly string[]).includes(v) + +export async function POST(req: Request) { + try { + const ctx = await requireAuth() + const uploaderId = ctx.userId + + // Rate limit uploads per user + const limitKey = rateLimitKey("upload", uploaderId) + const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.UPLOAD }) + if (!limit.success) { + return NextResponse.json( + { success: false, message: "Upload rate limit exceeded. Please try again later." }, + { status: 429, headers: rateLimitHeaders(limit) } + ) + } + + const formData = await req.formData() + const file = formData.get("file") + const targetType = formData.get("targetType") + const targetId = formData.get("targetId") + + if (!(file instanceof File)) { + return NextResponse.json( + { success: false, message: "No file provided" }, + { status: 400 } + ) + } + + if (file.size === 0) { + return NextResponse.json( + { success: false, message: "File is empty" }, + { status: 400 } + ) + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { success: false, message: "File size exceeds 10MB limit" }, + { status: 413 } + ) + } + + const mimeType = file.type || "application/octet-stream" + if (!isAllowedMimeType(mimeType)) { + return NextResponse.json( + { success: false, message: `File type ${mimeType} is not allowed` }, + { status: 415 } + ) + } + + const originalName = file.name || "unnamed" + const storagePath = generateStoragePath(originalName) + const absoluteDir = path.join(process.cwd(), "public", path.dirname(storagePath)) + await mkdir(absoluteDir, { recursive: true }) + + const bytes = Buffer.from(await file.arrayBuffer()) + const absolutePath = path.join(process.cwd(), "public", storagePath) + await writeFile(absolutePath, bytes) + + const url = `/${storagePath}` + const id = createId() + const filename = path.basename(storagePath) + + const created = await createFileAttachment({ + id, + filename, + originalName, + mimeType, + size: file.size, + storagePath, + url, + uploaderId, + targetType: isTargetType(typeof targetType === "string" ? targetType : null) + ? (targetType as string) + : null, + targetId: typeof targetId === "string" && targetId.length > 0 ? targetId : null, + }) + + if (!created) { + return NextResponse.json( + { success: false, message: "Failed to persist file record" }, + { status: 500 } + ) + } + + return NextResponse.json({ + success: true, + id: created.id, + url: created.url, + filename: created.filename, + originalName: created.originalName, + size: created.size, + mimeType: created.mimeType, + }) + } catch (e) { + const message = e instanceof Error ? e.message : "Upload failed" + const status = message.includes("Permission denied") || message.includes("auth_required") + ? 401 + : 500 + return NextResponse.json({ success: false, message }, { status }) + } +} diff --git a/src/auth.ts b/src/auth.ts index f9ceafc..0de4b24 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,14 @@ import { compare } from "bcryptjs" import NextAuth from "next-auth" import Credentials from "next-auth/providers/credentials" +import { eq } from "drizzle-orm" import { resolvePermissions } from "@/shared/lib/permissions" +import { logLoginEvent } from "@/shared/lib/login-logger" +import { + PASSWORD_RULES, + isAccountLocked, +} from "@/shared/lib/password-policy" +import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit" const normalizeRole = (value: unknown) => { const role = String(value ?? "").trim().toLowerCase() @@ -25,6 +32,103 @@ const normalizeBcryptHash = (value: string) => { return `$2b$${value}` } +/** + * Resolve the client IP from request headers (best-effort, used for + * rate-limit keying only — not stored). + */ +const resolveClientIp = async (): Promise => { + try { + const { headers } = await import("next/headers") + const headerList = await headers() + return ( + headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + headerList.get("x-real-ip") ?? + "unknown" + ) + } catch { + return "unknown" + } +} + +/** + * Get or create a password_security row for a user. + */ +const getOrCreatePasswordSecurity = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +) => { + const [existing] = await db + .select() + .from(passwordSecurity) + .where(eq(passwordSecurity.userId, userId)) + .limit(1) + + if (existing) return existing + + const { createId } = await import("@paralleldrive/cuid2") + const id = createId() + await db.insert(passwordSecurity).values({ + id, + userId, + failedLoginAttempts: 0, + }) + const [created] = await db + .select() + .from(passwordSecurity) + .where(eq(passwordSecurity.userId, userId)) + .limit(1) + return created +} + +/** + * Increment failed login attempts and lock the account if the threshold + * is reached. + */ +const recordFailedLogin = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +): Promise<{ locked: boolean; lockedUntil: Date | null }> => { + const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) + const nextAttempts = current.failedLoginAttempts + 1 + const shouldLock = nextAttempts >= PASSWORD_RULES.maxLoginAttempts + const lockedUntil = shouldLock + ? new Date(Date.now() + PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000) + : null + + await db + .update(passwordSecurity) + .set({ + failedLoginAttempts: nextAttempts, + lockedUntil, + updatedAt: new Date(), + }) + .where(eq(passwordSecurity.userId, userId)) + + return { locked: shouldLock, lockedUntil } +} + +/** + * Reset failed login attempts on successful login. + */ +const resetFailedLogin = async ( + db: typeof import("@/shared/db")["db"], + passwordSecurity: typeof import("@/shared/db/schema")["passwordSecurity"], + userId: string +): Promise => { + const current = await getOrCreatePasswordSecurity(db, passwordSecurity, userId) + if (current.failedLoginAttempts === 0 && !current.lockedUntil) return + await db + .update(passwordSecurity) + .set({ + failedLoginAttempts: 0, + lockedUntil: null, + updatedAt: new Date(), + }) + .where(eq(passwordSecurity.userId, userId)) +} + export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, secret: process.env.NEXTAUTH_SECRET, @@ -41,8 +145,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ const password = String(credentials?.password ?? "") if (!email || !password) return null - const [{ eq }, { db }, { roles, users, usersToRoles }] = await Promise.all([ - import("drizzle-orm"), + // Rate limit by IP + email to slow brute-force attempts + const clientIp = await resolveClientIp() + const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`) + const limit = rateLimit({ + key: loginLimitKey, + ...RATE_LIMIT_RULES.LOGIN, + }) + if (!limit.success) { + await logLoginEvent({ + userEmail: email, + action: "signin", + status: "failure", + errorMessage: "Rate limit exceeded", + }) + return null + } + + const [{ db }, { users, roles, usersToRoles, passwordSecurity }] = await Promise.all([ import("@/shared/db"), import("@/shared/db/schema"), ]) @@ -52,12 +172,43 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }) if (!user) return null + // Account lockout check + const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id) + const lastFailedAt = security.lockedUntil + ? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000) + : null + if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) { + await logLoginEvent({ + userId: user.id, + userEmail: email, + action: "signin", + status: "failure", + errorMessage: "Account locked", + }) + return null + } + const storedPassword = user.password ?? null if (!storedPassword) return null const normalizedPassword = normalizeBcryptHash(storedPassword) if (!normalizedPassword.startsWith("$2")) return null const ok = await compare(password, normalizedPassword) - if (!ok) return null + + if (!ok) { + await recordFailedLogin(db, passwordSecurity, user.id) + await logLoginEvent({ + userId: user.id, + userEmail: email, + action: "signin", + status: "failure", + errorMessage: "Invalid credentials", + }) + return null + } + + // Successful login: reset counters and rate limit + await resetFailedLogin(db, passwordSecurity, user.id) + resetRateLimit(loginLimitKey) const roleRows = await db .select({ name: roles.name }) @@ -136,4 +287,33 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ return session }, }, + events: { + async signIn({ user }) { + await logLoginEvent({ + userId: user.id, + userEmail: user.email ?? "", + action: "signin", + status: "success", + }) + }, + async signOut(message) { + // NextAuth v5 signOut event receives the session/token info + const userId = + (message as { userId?: string })?.userId ?? + (message as { token?: { id?: string } })?.token?.id ?? + "" + const userEmail = + (message as { token?: { email?: string } })?.token?.email ?? + (message as { session?: { user?: { email?: string } } })?.session?.user?.email ?? + "" + if (userEmail) { + await logLoginEvent({ + userId: userId || undefined, + userEmail, + action: "signout", + status: "success", + }) + } + }, + }, }) diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts new file mode 100644 index 0000000..6f6bc36 --- /dev/null +++ b/src/modules/announcements/actions.ts @@ -0,0 +1,242 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { createId } from "@paralleldrive/cuid2" +import { eq } from "drizzle-orm" + +import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" +import { db } from "@/shared/db" +import { announcements } from "@/shared/db/schema" +import type { ActionState } from "@/shared/types/action-state" + +import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema" +import { getAnnouncements, getAnnouncementById } from "./data-access" +import type { GetAnnouncementsParams, Announcement } from "./types" + +export async function createAnnouncementAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + const parsed = CreateAnnouncementSchema.safeParse({ + title: formData.get("title"), + content: formData.get("content"), + type: formData.get("type") || undefined, + status: formData.get("status") || undefined, + targetGradeId: formData.get("targetGradeId") || undefined, + targetClassId: formData.get("targetClassId") || undefined, + publishedAt: formData.get("publishedAt") || undefined, + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const input = parsed.data + const isPublished = input.status === "published" + const publishedAt = isPublished + ? input.publishedAt + ? new Date(input.publishedAt) + : new Date() + : input.publishedAt + ? new Date(input.publishedAt) + : null + + const id = createId() + await db.insert(announcements).values({ + id, + title: input.title, + content: input.content, + type: input.type, + status: input.status, + targetGradeId: input.targetGradeId, + targetClassId: input.targetClassId, + authorId: ctx.userId, + publishedAt, + }) + + revalidatePath("/admin/announcements") + revalidatePath("/announcements") + + return { success: true, message: "Announcement created", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function updateAnnouncementAction( + id: string, + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + const existing = await getAnnouncementById(id) + if (!existing) return { success: false, message: "Announcement not found" } + + const parsed = UpdateAnnouncementSchema.safeParse({ + title: formData.get("title"), + content: formData.get("content"), + type: formData.get("type") || undefined, + status: formData.get("status") || undefined, + targetGradeId: formData.get("targetGradeId") || undefined, + targetClassId: formData.get("targetClassId") || undefined, + publishedAt: formData.get("publishedAt") || undefined, + }) + + if (!parsed.success) { + return { + success: false, + message: "Invalid form data", + errors: parsed.error.flatten().fieldErrors, + } + } + + const input = parsed.data + const wasPublished = existing.status === "published" + const isPublished = input.status === "published" + const publishedAt = isPublished + ? existing.publishedAt + ? new Date(existing.publishedAt) + : new Date() + : input.publishedAt + ? new Date(input.publishedAt) + : null + + await db + .update(announcements) + .set({ + title: input.title, + content: input.content, + type: input.type, + status: input.status, + targetGradeId: input.targetGradeId, + targetClassId: input.targetClassId, + publishedAt, + updatedAt: new Date(), + }) + .where(eq(announcements.id, id)) + + revalidatePath("/admin/announcements") + revalidatePath(`/admin/announcements/${id}`) + revalidatePath("/announcements") + void wasPublished + + return { success: true, message: "Announcement updated", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function deleteAnnouncementAction(id: string): Promise> { + try { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + const existing = await getAnnouncementById(id) + if (!existing) return { success: false, message: "Announcement not found" } + + await db.delete(announcements).where(eq(announcements.id, id)) + + revalidatePath("/admin/announcements") + revalidatePath("/announcements") + + return { success: true, message: "Announcement deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function publishAnnouncementAction(id: string): Promise> { + try { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + const existing = await getAnnouncementById(id) + if (!existing) return { success: false, message: "Announcement not found" } + + await db + .update(announcements) + .set({ + status: "published", + publishedAt: existing.publishedAt ? new Date(existing.publishedAt) : new Date(), + updatedAt: new Date(), + }) + .where(eq(announcements.id, id)) + + revalidatePath("/admin/announcements") + revalidatePath(`/admin/announcements/${id}`) + revalidatePath("/announcements") + + return { success: true, message: "Announcement published" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function archiveAnnouncementAction(id: string): Promise> { + try { + await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + + const existing = await getAnnouncementById(id) + if (!existing) return { success: false, message: "Announcement not found" } + + await db + .update(announcements) + .set({ + status: "archived", + updatedAt: new Date(), + }) + .where(eq(announcements.id, id)) + + revalidatePath("/admin/announcements") + revalidatePath(`/admin/announcements/${id}`) + revalidatePath("/announcements") + + return { success: true, message: "Announcement archived" } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function getAnnouncementsAction( + params?: GetAnnouncementsParams +): Promise> { + try { + await requireAuth() + const data = await getAnnouncements(params) + return { success: true, data } + } catch (e) { + if (e instanceof PermissionDeniedError) { + return { success: false, message: e.message } + } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} diff --git a/src/modules/announcements/components/admin-announcements-view.tsx b/src/modules/announcements/components/admin-announcements-view.tsx new file mode 100644 index 0000000..9c32de4 --- /dev/null +++ b/src/modules/announcements/components/admin-announcements-view.tsx @@ -0,0 +1,65 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { PlusCircle } from "lucide-react" + +import { Button } from "@/shared/components/ui/button" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" + +import { AnnouncementForm } from "./announcement-form" +import { AnnouncementList } from "./announcement-list" +import type { Announcement, AnnouncementStatus } from "../types" + +export function AdminAnnouncementsView({ + announcements, + grades = [], + classes = [], + initialStatus, +}: { + announcements: Announcement[] + grades?: { id: string; name: string }[] + classes?: { id: string; name: string }[] + initialStatus?: AnnouncementStatus +}) { + const router = useRouter() + const [createOpen, setCreateOpen] = useState(false) + + const handleOpenChange = (open: boolean) => { + setCreateOpen(open) + if (!open) router.refresh() + } + + return ( +
+
+
+

Announcements

+

+ Create and manage school-wide announcements. +

+
+ +
+ + `/admin/announcements/${id}`} + /> + + + + + New Announcement + + + + +
+ ) +} diff --git a/src/modules/announcements/components/announcement-card.tsx b/src/modules/announcements/components/announcement-card.tsx new file mode 100644 index 0000000..5abe686 --- /dev/null +++ b/src/modules/announcements/components/announcement-card.tsx @@ -0,0 +1,79 @@ +"use client" + +import Link from "next/link" +import { useMemo } from "react" +import { Badge } from "@/shared/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { formatDate } from "@/shared/lib/utils" +import type { Announcement } from "../types" + +const STATUS_LABEL: Record = { + draft: "Draft", + published: "Published", + archived: "Archived", +} + +const STATUS_VARIANT: Record< + Announcement["status"], + "default" | "secondary" | "outline" +> = { + draft: "secondary", + published: "default", + archived: "outline", +} + +const TYPE_LABEL: Record = { + school: "School", + grade: "Grade", + class: "Class", +} + +export function AnnouncementCard({ + announcement, + href, +}: { + announcement: Announcement + href?: string +}) { + const card = useMemo( + () => ( + + + {announcement.title} + + {STATUS_LABEL[announcement.status]} + + + +

+ {announcement.content} +

+
+ + {TYPE_LABEL[announcement.type]} + + + {announcement.publishedAt + ? `Published ${formatDate(announcement.publishedAt)}` + : `Updated ${formatDate(announcement.updatedAt)}`} + + {announcement.authorName ? ( + by {announcement.authorName} + ) : null} +
+
+
+ ), + [announcement] + ) + + if (href) { + return ( + + {card} + + ) + } + + return card +} diff --git a/src/modules/announcements/components/announcement-detail.tsx b/src/modules/announcements/components/announcement-detail.tsx new file mode 100644 index 0000000..51dcc50 --- /dev/null +++ b/src/modules/announcements/components/announcement-detail.tsx @@ -0,0 +1,206 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" +import { + Archive, + ArrowLeft, + Megaphone, + Pencil, + Send, + Trash2, +} from "lucide-react" + +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/components/ui/alert-dialog" +import { formatDate } from "@/shared/lib/utils" + +import { + archiveAnnouncementAction, + deleteAnnouncementAction, + publishAnnouncementAction, +} from "../actions" +import type { Announcement } from "../types" + +const STATUS_LABEL: Record = { + draft: "Draft", + published: "Published", + archived: "Archived", +} + +const TYPE_LABEL: Record = { + school: "School", + grade: "Grade", + class: "Class", +} + +export function AnnouncementDetail({ + announcement, + canManage, + editHref, + backHref, +}: { + announcement: Announcement + canManage?: boolean + editHref?: string + backHref?: string +}) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + const handlePublish = async () => { + setIsWorking(true) + try { + const res = await publishAnnouncementAction(announcement.id) + if (res.success) { + toast.success(res.message) + router.refresh() + } else { + toast.error(res.message || "Failed to publish") + } + } catch { + toast.error("Failed to publish") + } finally { + setIsWorking(false) + } + } + + const handleArchive = async () => { + setIsWorking(true) + try { + const res = await archiveAnnouncementAction(announcement.id) + if (res.success) { + toast.success(res.message) + router.refresh() + } else { + toast.error(res.message || "Failed to archive") + } + } catch { + toast.error("Failed to archive") + } finally { + setIsWorking(false) + } + } + + const handleDelete = async () => { + setIsWorking(true) + try { + const res = await deleteAnnouncementAction(announcement.id) + if (res.success) { + toast.success(res.message) + router.push("/admin/announcements") + router.refresh() + } else { + toast.error(res.message || "Failed to delete") + } + } catch { + toast.error("Failed to delete") + } finally { + setIsWorking(false) + setDeleteOpen(false) + } + } + + return ( +
+
+
+ {backHref ? ( + + ) : null} +

Announcement

+
+ {canManage ? ( +
+ {announcement.status !== "published" ? ( + + ) : null} + {announcement.status !== "archived" ? ( + + ) : null} + {editHref ? ( + + ) : null} + +
+ ) : null} +
+ + + +
+ + {TYPE_LABEL[announcement.type]} + + {STATUS_LABEL[announcement.status]} +
+ {announcement.title} +
+ + + {announcement.publishedAt + ? `Published ${formatDate(announcement.publishedAt)}` + : `Created ${formatDate(announcement.createdAt)}`} + + {announcement.authorName ? by {announcement.authorName} : null} +
+
+ +

{announcement.content}

+
+
+ + + + + Delete announcement + + This will permanently delete "{announcement.title}". + + + + Cancel + + Delete + + + + +
+ ) +} diff --git a/src/modules/announcements/components/announcement-form.tsx b/src/modules/announcements/components/announcement-form.tsx new file mode 100644 index 0000000..163f5a8 --- /dev/null +++ b/src/modules/announcements/components/announcement-form.tsx @@ -0,0 +1,201 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Textarea } from "@/shared/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" + +import { createAnnouncementAction, updateAnnouncementAction } from "../actions" +import type { Announcement } from "../types" + +type Mode = "create" | "edit" + +export function AnnouncementForm({ + mode, + announcement, + grades = [], + classes = [], +}: { + mode: Mode + announcement?: Announcement + grades?: { id: string; name: string }[] + classes?: { id: string; name: string }[] +}) { + const router = useRouter() + const [isWorking, setIsWorking] = useState(false) + + const [type, setType] = useState(announcement?.type ?? "school") + const [status, setStatus] = useState(announcement?.status ?? "draft") + const [targetGradeId, setTargetGradeId] = useState(announcement?.targetGradeId ?? "") + const [targetClassId, setTargetClassId] = useState(announcement?.targetClassId ?? "") + + const handleSubmit = async (formData: FormData) => { + setIsWorking(true) + try { + formData.set("type", type) + formData.set("status", status) + if (type === "grade" && targetGradeId) { + formData.set("targetGradeId", targetGradeId) + } + if (type === "class" && targetClassId) { + formData.set("targetClassId", targetClassId) + } + + const res = + mode === "create" + ? await createAnnouncementAction(null, formData) + : announcement + ? await updateAnnouncementAction(announcement.id, null, formData) + : null + + if (!res) { + toast.error("Invalid form state") + return + } + + if (res.success) { + toast.success(res.message) + router.push("/admin/announcements") + router.refresh() + } else { + toast.error(res.message || "Failed to save announcement") + } + } catch { + toast.error("Failed to save announcement") + } finally { + setIsWorking(false) + } + } + + return ( + + + + {mode === "create" ? "New Announcement" : "Edit Announcement"} + + + +
+
+ + +
+ +
+ +