Compare commits

...

5 Commits

Author SHA1 Message Date
SpecialX
284c7939b8 test(mocks): update exam mock data
Some checks failed
CI / scheduled-backup (push) Failing after 1m32s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Update src/mocks/exam-data.ts with new mock entries
2026-07-03 10:32:29 +08:00
SpecialX
6a22922ddd docs(architecture): sync architecture docs with code changes
- Update 001_project_overview.md

- Update 004_architecture_impact_map.md

- Update 005_architecture_data.json

- Add 008_module_role_mapping.md

- Add _update_004.cjs helper script
2026-07-03 10:32:15 +08:00
SpecialX
93eacccdbf feat(users,layout): update users module and navigation config
- Update users/actions.ts with new server actions

- Update users/data-access.ts with new data access functions

- Update users/components/admin-users-view.tsx

- Update layout/config/navigation.ts
2026-07-03 10:31:57 +08:00
SpecialX
2dd8c2197c feat(error-book): update components, actions, data-access, and add analytics
- Add data-access-analytics for error book analytics queries

- Update actions, data-access for improved error book operations

- Update components: add-error-book-dialog, analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart, class-filter, error-book-detail-dialog, error-book-filters, error-book-item-card, error-book-list, error-book-stats-cards, grouped-student-error-table, knowledge-point-weakness-chart, review-buttons, subject-distribution-chart, subject-tabs, top-wrong-questions

- Remove class-error-overview (replaced by new components)
2026-07-03 10:30:41 +08:00
SpecialX
56c3f32e2d chore(config): update auth, env, i18n, proxy, and dependencies
- Update src/auth.ts auth configuration

- Update src/env.mjs environment variable validation

- Update src/i18n/request.ts locale handling

- Update src/proxy.ts middleware

- Update src/next-auth.d.ts type declarations

- Update package.json and package-lock.json dependencies
2026-07-03 10:30:28 +08:00
37 changed files with 10846 additions and 2679 deletions

View File

@@ -368,7 +368,7 @@ AI 模式: 选择 AI Provider → 粘贴试卷源文本 → AI 解析生成 →
| API 路由 | 9 |
| Server Actions | 80+ |
| 用户角色 | 6 (admin/teacher/student/parent/grade_head/teaching_head) |
| 权限点 | 54 |
| 权限点 | 67 |
---
@@ -380,3 +380,4 @@ AI 模式: 选择 AI Provider → 粘贴试卷源文本 → AI 解析生成 →
| [005 架构数据](./005_architecture_data.json) | AI 友好的结构化架构数据 |
| [006 功能清单](./006_k12_feature_checklist.md) | 企业级 K12 标准功能模块清单 |
| [007 差距审计报告](./007_gap_audit_report.md) | 功能差距审计与补齐路线图 |
| [008 模块角色映射](./008_module_role_mapping.md) | 模块-角色-功能映射总览 |

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,299 @@
# 模块-角色-功能映射总览
> 生成日期2026-06-22
> 数据来源:`src/modules/` 全量扫描 + `src/app/(dashboard)/` 路由权限校验 + `src/shared/types/permissions.ts` 权限点定义
> 角色体系admin / teacher / student / parent / grade_head / teaching_head含 management 路由组)
---
## 一、模块-角色速查表
| # | 模块 | 目录 | admin | teacher | student | parent | mgmt | 核心功能 |
|---|------|------|:-----:|:-------:|:-------:|:------:|:----:|----------|
| 1 | adaptive-practice | `adaptive-practice/` | | ✅ | ✅ | ✅ | ✅ | 专项练习:自适应出题、答题、练习历史、成绩分析 |
| 2 | ai | `ai/` | ✅ | ✅ | ✅ | ✅ | ✅ | AI 赋能:对话助手、出题辅助、批改辅助、学情分析、多模型配置 |
| 3 | announcements | `announcements/` | ✅ | ✅ | ✅ | ✅ | ✅ | 通知公告:学校/年级/班级三级公告发布与查看 |
| 4 | attendance | `attendance/` | ✅ | ✅ | ✅ | ✅ | | 考勤管理:学生/教师考勤登记、统计、规则配置 |
| 5 | audit | `audit/` | ✅ | | | | | 日志审计:操作日志、登录日志、数据变更日志、导出 |
| 6 | auth | `auth/` | ✅ | ✅ | ✅ | ✅ | ✅ | 认证系统:登录/注册/JWT/双因素认证 |
| 7 | classes | `classes/` | ✅ | ✅ | ✅ | | | 班级管理:班级 CRUD、课表、学生管理、邀请码 |
| 8 | course-plans | `course-plans/` | ✅ | ✅ | ✅ | ✅ | | 课程计划:教学计划创建、进度跟踪、日历视图 |
| 9 | dashboard | `dashboard/` | ✅ | ✅ | ✅ | ✅ | ✅ | 仪表盘:角色独立看板(统计卡片/图表/快捷操作) |
| 10 | diagnostic | `diagnostic/` | | ✅ | ✅ | ✅ | | 学情诊断:知识点掌握度雷达图、班级/个人诊断报告 |
| 11 | elective | `elective/` | ✅ | ✅ | ✅ | ✅ | | 选课管理:选修课创建/选课/退选/抽签 |
| 12 | error-book | `error-book/` | ✅ | ✅ | ✅ | ✅ | | 错题本错题自动采集、SM-2 间隔复习、统计分析 |
| 13 | exams | `exams/` | | ✅ | | | | 考试管理AI 出题/组卷/批改/监考/分析 |
| 14 | files | `files/` | ✅ | ✅ | ✅ | ✅ | ✅ | 文件管理:上传/预览/存储/权限控制 |
| 15 | grades | `grades/` | ✅ | ✅ | ✅ | ✅ | ✅ | 成绩管理:录入/查询/统计/趋势/导出/排名 |
| 16 | homework | `homework/` | | ✅ | ✅ | | | 作业管理:布置/提交/批改/评分/统计分析 |
| 17 | layout | `layout/` | ✅ | ✅ | ✅ | ✅ | ✅ | 布局框架:导航配置/侧边栏/顶部栏(所有角色共用) |
| 18 | lesson-preparation | `lesson-preparation/` | ✅ | ✅ | ✅ | ✅ | | 备课系统:教案创建/编辑/发布/查看 |
| 19 | messaging | `messaging/` | ✅ | ✅ | ✅ | ✅ | ✅ | 站内消息:私信/群发/草稿/已读状态 |
| 20 | notifications | `notifications/` | ✅ | ✅ | ✅ | ✅ | ✅ | 通知系统:站内/邮件/短信/微信多渠道推送、偏好管理 |
| 21 | onboarding | `onboarding/` | ✅ | ✅ | ✅ | ✅ | ✅ | 新手引导:首次登录角色选择、资料填写、班级加入 |
| 22 | parent | `parent/` | | | | ✅ | | 家长聚合:子女学习概览、成绩/作业/考勤详情 |
| 23 | proctoring | `proctoring/` | | ✅ | | | | 考试监考:实时提交进度、异常行为标记 |
| 24 | questions | `questions/` | ✅ | ✅ | | | | 题库管理:题目 CRUD、分类标签、批量导入导出 |
| 25 | rbac | `rbac/` | ✅ | | | | | 角色权限:动态角色管理、权限目录、角色-权限分配 |
| 26 | scheduling | `scheduling/` | ✅ | ✅ | ✅ | | | 排课系统:自动排课引擎、课表查看、调课/代课 |
| 27 | school | `school/` | ✅ | | | | | 学校管理:学校信息/学年学期/年级/班级/部门/学科 |
| 28 | settings | `settings/` | ✅ | ✅ | ✅ | ✅ | ✅ | 用户设置:个人信息/通知偏好/外观/安全/登出 |
| 29 | standards | `standards/` | (via) | (via) | | | | 课标库:国家/校标/自定义课标,通过 lesson-preparation 使用 |
| 30 | student | `student/` | | | ✅ | | | 学生聚合:课程查看/课表/学习路径 |
| 31 | textbooks | `textbooks/` | (via) | ✅ | ✅ | | | 教材资源:教材库/章节结构/知识点图谱/内容阅读 |
| 32 | users | `users/` | ✅ | | | | | 用户管理:用户 CRUD、批量导入、角色分配 |
> 注:`✅` = 有独立页面路由;`(via)` = 通过其他模块间接使用,无独立路由页面
---
## 二、按角色展开的模块清单
### 2.1 admin系统管理员
| 模块 | 页面路由 | 权限点 | 功能说明 |
|------|----------|--------|----------|
| **school** | `/admin/school` | `SCHOOL_MANAGE` | 学校基础信息配置 |
| | `/admin/school/schools` | `SCHOOL_MANAGE` | 多校区管理 |
| | `/admin/school/academic-year` | `SCHOOL_MANAGE` | 学年学期管理 |
| | `/admin/school/classes` | `SCHOOL_MANAGE` | 班级创建与管理 |
| | `/admin/school/departments` | `SCHOOL_MANAGE` | 部门管理 |
| | `/admin/school/grades` | `SCHOOL_MANAGE` | 年级管理与组长指派 |
| | `/admin/school/grades/insights` | `SCHOOL_MANAGE` | 年级洞察分析 |
| **users** | `/admin/users` | `USER_MANAGE` | 用户账号管理 |
| | `/admin/users/import` | `USER_MANAGE` | 批量导入用户 |
| **rbac** | `/admin/roles` | `ROLE_READ` | 角色列表查看 |
| | `/admin/roles/[id]` | `ROLE_READ` | 角色详情与权限编辑 |
| | `/admin/permissions` | `PERMISSION_READ` | 权限点目录查看 |
| **announcements** | `/admin/announcements` | `ANNOUNCEMENT_MANAGE` | 公告管理(创建/编辑/删除) |
| | `/admin/announcements/[id]` | `ANNOUNCEMENT_MANAGE` | 公告详情 |
| | `/admin/announcements/[id]/edit` | `ANNOUNCEMENT_MANAGE` | 编辑公告 |
| **audit** | `/admin/audit-logs` | `AUDIT_LOG_READ` | 操作日志查看 |
| | `/admin/audit-logs/overview` | `AUDIT_LOG_READ` | 审计概览统计 |
| | `/admin/audit-logs/data-changes` | `AUDIT_LOG_READ` | 数据变更日志 |
| | `/admin/audit-logs/login-logs` | `AUDIT_LOG_READ` | 登录日志 |
| **dashboard** | `/admin/dashboard` | `DASHBOARD_ADMIN_READ` | 管理员仪表盘 |
| **scheduling** | `/admin/scheduling/auto` | `SCHEDULE_AUTO` | 自动排课 |
| | `/admin/scheduling/changes` | `SCHEDULE_ADJUST` | 调课管理 |
| | `/admin/scheduling/rules` | `SCHEDULE_ADJUST` | 排课规则配置 |
| **course-plans** | `/admin/course-plans` | `COURSE_PLAN_READ` | 课程计划查看 |
| | `/admin/course-plans/create` | `COURSE_PLAN_MANAGE` | 创建课程计划 |
| | `/admin/course-plans/[id]` | `COURSE_PLAN_READ` | 课程计划详情 |
| | `/admin/course-plans/[id]/edit` | `COURSE_PLAN_MANAGE` | 编辑课程计划 |
| **elective** | `/admin/elective` | `ELECTIVE_READ` | 选课管理 |
| | `/admin/elective/create` | `ELECTIVE_MANAGE` | 创建选修课 |
| | `/admin/elective/[id]` | `ELECTIVE_READ` | 选修课详情 |
| | `/admin/elective/[id]/edit` | `ELECTIVE_MANAGE` | 编辑选修课 |
| **attendance** | `/admin/attendance` | `ATTENDANCE_READ` | 考勤数据查看 |
| **error-book** | `/admin/error-book` | `ERROR_BOOK_ANALYTICS_READ` | 全校错题统计分析 |
| **questions** | `/admin/questions` | `QUESTION_READ` | 题库管理 |
| **files** | `/admin/files` | `FILE_READ` | 文件管理 |
| **ai** | `/admin/ai-settings` | `AI_CHAT` | AI 多模型配置 |
| **lesson-plans** | `/admin/lesson-plans` | `LESSON_PLAN_READ` | 教案查看 |
| | `/admin/lesson-plans/[planId]/view` | `LESSON_PLAN_READ` | 教案详情 |
| **settings** | `/admin/settings` | `SETTINGS_ADMIN` | 管理员设置 |
---
### 2.2 teacher教师
| 模块 | 页面路由 | 权限点 | 功能说明 |
|------|----------|--------|----------|
| **dashboard** | `/teacher/dashboard` | `DASHBOARD_TEACHER_READ` | 教师仪表盘(待批改/今日课表/班级动态) |
| **classes** | `/teacher/classes/my` | `CLASS_READ` | 我的班级列表 |
| | `/teacher/classes/my/[id]` | `CLASS_READ` | 班级详情 |
| | `/teacher/classes/schedule` | `CLASS_READ` | 班级课表 |
| | `/teacher/classes/students` | `CLASS_READ` | 班级学生管理 |
| **exams** | `/teacher/exams` | `EXAM_READ` | 考试列表 |
| | `/teacher/exams/all` | `EXAM_READ` | 全部考试 |
| | `/teacher/exams/create` | `EXAM_CREATE` | 创建考试 |
| | `/teacher/exams/new` | `EXAM_CREATE` | AI 出题 |
| | `/teacher/exams/[id]/build` | `EXAM_READ` | 组卷 |
| | `/teacher/exams/[id]/edit-rich` | `EXAM_UPDATE` | 富文本编辑试卷 |
| | `/teacher/exams/[id]/analytics` | `EXAM_READ` | 考试分析 |
| | `/teacher/exams/[id]/proctoring` | `EXAM_PROCTOR` | 考试监考 |
| | `/teacher/exams/grading` | `HOMEWORK_GRADE` | 批改列表 |
| | `/teacher/exams/grading/[submissionId]` | `HOMEWORK_GRADE` | 批改详情 |
| **homework** | `/teacher/homework/assignments` | `HOMEWORK_CREATE` | 作业管理 |
| | `/teacher/homework/assignments/create` | `HOMEWORK_CREATE` | 布置作业 |
| | `/teacher/homework/assignments/[id]` | `HOMEWORK_CREATE` | 作业详情 |
| | `/teacher/homework/assignments/[id]/submissions` | `HOMEWORK_GRADE` | 提交列表 |
| | `/teacher/homework/submissions` | `HOMEWORK_GRADE` | 批改列表 |
| | `/teacher/homework/submissions/[submissionId]` | `HOMEWORK_GRADE` | 批改详情 |
| | `/teacher/homework/submissions/[submissionId]/scan-grading` | `HOMEWORK_GRADE` | 阅卷式批改 |
| **grades** | `/teacher/grades` | `GRADE_RECORD_READ` | 成绩查询 |
| | `/teacher/grades/analytics` | `GRADE_RECORD_READ` | 成绩分析 |
| | `/teacher/grades/entry` | `GRADE_RECORD_MANAGE` | 成绩录入 |
| | `/teacher/grades/stats` | `GRADE_RECORD_READ` | 成绩统计 |
| **questions** | `/teacher/questions` | `QUESTION_READ` | 题库管理 |
| **textbooks** | `/teacher/textbooks` | `TEXTBOOK_READ` | 教材查看 |
| | `/teacher/textbooks/[id]` | `TEXTBOOK_READ` | 教材内容 |
| **lesson-plans** | `/teacher/lesson-plans` | `LESSON_PLAN_READ` | 教案列表 |
| | `/teacher/lesson-plans/new` | `LESSON_PLAN_READ` | 创建教案 |
| | `/teacher/lesson-plans/[planId]/edit` | `LESSON_PLAN_READ` | 编辑教案 |
| **diagnostic** | `/teacher/diagnostic` | `DIAGNOSTIC_READ` | 学情诊断总览 |
| | `/teacher/diagnostic/class/[classId]` | `DIAGNOSTIC_READ` | 班级诊断 |
| | `/teacher/diagnostic/student/[studentId]` | `DIAGNOSTIC_READ` | 学生诊断 |
| **attendance** | `/teacher/attendance` | `ATTENDANCE_READ` | 考勤查看 |
| | `/teacher/attendance/sheet` | `ATTENDANCE_MANAGE` | 考勤登记 |
| | `/teacher/attendance/stats` | `ATTENDANCE_READ` | 考勤统计 |
| **course-plans** | `/teacher/course-plans` | `COURSE_PLAN_READ` | 课程计划 |
| | `/teacher/course-plans/[id]` | `COURSE_PLAN_READ` | 计划详情 |
| **elective** | `/teacher/elective` | `ELECTIVE_READ` | 选课管理 |
| | `/teacher/elective/create` | `ELECTIVE_MANAGE` | 创建选修课 |
| | `/teacher/elective/[id]/edit` | `ELECTIVE_MANAGE` | 编辑选修课 |
| **error-book** | `/teacher/error-book` | `ERROR_BOOK_ANALYTICS_READ` | 班级错题分析 |
| **practice** | `/teacher/practice` | `ADAPTIVE_PRACTICE_READ` | 专项练习统计 |
| **schedule** | `/teacher/schedule-changes` | `SCHEDULE_ADJUST` | 调课申请 |
---
### 2.3 student学生
| 模块 | 页面路由 | 权限点 | 功能说明 |
|------|----------|--------|----------|
| **dashboard** | `/student/dashboard` | `DASHBOARD_STUDENT_READ` | 学生仪表盘(作业/考试/成绩趋势) |
| **learning** | `/student/learning` | `CLASS_READ` | 学习中心 |
| | `/student/learning/assignments` | — | 作业列表 |
| | `/student/learning/assignments/[assignmentId]` | — | 作答 |
| | `/student/learning/assignments/[assignmentId]/result` | — | 作答结果 |
| | `/student/learning/courses` | `CLASS_READ` | 课程列表 |
| | `/student/learning/courses/[classId]` | `CLASS_READ` | 课程详情 |
| | `/student/learning/textbooks` | `TEXTBOOK_READ` | 教材 |
| | `/student/learning/textbooks/[id]` | `TEXTBOOK_READ` | 教材内容 |
| | `/student/learning/study-path` | `AI_CHAT` | AI 学习路径 |
| **grades** | `/student/grades` | `GRADE_RECORD_READ` | 成绩查询 |
| **error-book** | `/student/error-book` | `ERROR_BOOK_READ` | 我的错题本 |
| **practice** | `/student/practice` | `ADAPTIVE_PRACTICE_READ` | 专项练习 |
| | `/student/practice/[sessionId]` | `ADAPTIVE_PRACTICE_READ` | 练习对话 |
| **diagnostic** | `/student/diagnostic` | `DIAGNOSTIC_READ` | 学情诊断 |
| **elective** | `/student/elective` | `ELECTIVE_READ` | 选课 |
| | `/student/elective/[id]` | `ELECTIVE_READ` | 课程详情 |
| **schedule** | `/student/schedule` | `CLASS_READ` | 课表查看 |
| **attendance** | `/student/attendance` | `ATTENDANCE_READ` | 考勤查询 |
| **course-plans** | `/student/course-plans` | `COURSE_PLAN_READ` | 课程计划 |
| | `/student/course-plans/[id]` | `COURSE_PLAN_READ` | 计划详情 |
| **lesson-plans** | `/student/lesson-plans` | `LESSON_PLAN_READ` | 教案查看 |
| | `/student/lesson-plans/[planId]/view` | `LESSON_PLAN_READ` | 教案详情 |
---
### 2.4 parent家长
| 模块 | 页面路由 | 权限点 | 功能说明 |
|------|----------|--------|----------|
| **dashboard** | `/parent/dashboard` | `DASHBOARD_PARENT_READ` | 家长仪表盘(子女学习概况) |
| **children** | `/parent/children/[studentId]` | — | 子女详情聚合页 |
| **grades** | `/parent/grades` | `GRADE_RECORD_READ` | 子女成绩查询 |
| **error-book** | `/parent/error-book` | `ERROR_BOOK_READ` | 子女错题本 |
| **diagnostic** | `/parent/diagnostic` | `DIAGNOSTIC_READ` | 子女学情诊断 |
| **elective** | `/parent/elective` | `ELECTIVE_READ` | 子女选课查看 |
| **attendance** | `/parent/attendance` | `ATTENDANCE_READ` | 子女考勤查询 |
| **course-plans** | `/parent/course-plans` | `COURSE_PLAN_READ` | 课程计划 |
| | `/parent/course-plans/[id]` | `COURSE_PLAN_READ` | 计划详情 |
| **lesson-plans** | `/parent/lesson-plans` | `LESSON_PLAN_READ` | 教案查看 |
| | `/parent/lesson-plans/[planId]/view` | `LESSON_PLAN_READ` | 教案详情 |
| **practice** | `/parent/practice` | `ADAPTIVE_PRACTICE_READ` | 子女练习记录 |
| **leave** | `/parent/leave` | — | 请假申请 |
---
### 2.5 management年级组长/教研组长)
| 模块 | 页面路由 | 权限点 | 功能说明 |
|------|----------|--------|----------|
| **grade** | `/management/grade` | `GRADE_MANAGE` | 年级管理首页 |
| | `/management/grade/dashboard` | `GRADE_RECORD_READ` | 年级仪表盘 |
| | `/management/grade/insights` | `GRADE_RECORD_READ` | 年级洞察分析 |
| | `/management/grade/classes` | `GRADE_MANAGE` | 年级班级管理 |
| | `/management/grade/practice` | `ADAPTIVE_PRACTICE_READ` | 年级练习统计 |
---
## 三、共享模块(所有角色可访问)
以下模块为所有已登录用户提供服务,不区分角色路由:
| 模块 | 路由 | 权限点 | 说明 |
|------|------|--------|------|
| **auth** | `/login`, `/register` | 公开 | 认证(登录/注册) |
| **onboarding** | `/onboarding` | 已登录 | 新手引导(首次登录) |
| **announcements** | `/announcements`, `/announcements/[id]` | `ANNOUNCEMENT_READ` | 公告查看(所有角色) |
| **messages** | `/messages`, `/messages/[id]`, `/messages/compose` | `MESSAGE_READ` / `MESSAGE_SEND` | 站内消息 |
| **profile** | `/profile` | `USER_PROFILE_UPDATE` | 个人资料 |
| **settings** | `/settings` | `USER_PROFILE_UPDATE` | 用户设置(通知/外观/安全) |
| **dashboard** | `/dashboard` | 角色自动路由 | 仪表盘(自动重定向到角色对应仪表盘) |
| **layout** | 所有路由 | — | 全局布局框架(侧边栏/顶部栏/导航配置) |
| **notifications** | 全局组件 | — | 通知弹窗/偏好管理(内嵌于 layout |
| **files** | 全局上传 | `FILE_UPLOAD` | 文件上传所有角色可上传admin 可管理) |
---
## 四、权限点汇总67 个)
| 权限分类 | 权限点 | 适用角色 |
|----------|--------|----------|
| **考试** | `exam:create/create/read/update/delete/duplicate/publish/ai_generate/submit` | teacher |
| | `exam:proctor/read` | teacher |
| **作业** | `homework:create/grade` | teacher |
| | `homework:submit` | student |
| **题库** | `question:create/read/update/delete` | admin, teacher |
| **教材** | `textbook:create/read/update/delete` | admin, teacher |
| **班级** | `class:create/read/update/delete/enroll/schedule` | admin, teacher, student |
| **学校管理** | `school:manage` | admin |
| | `grade:manage` | admin, grade_head |
| | `user:manage` | admin |
| **成绩** | `grade_record:manage/read` | admin, teacher, student, parent |
| **考勤** | `attendance:manage/read` | admin, teacher, student, parent |
| **课程计划** | `course_plan:manage/read` | admin, teacher, student, parent |
| **公告** | `announcement:manage` | admin |
| | `announcement:read` | all |
| **消息** | `message:send/read/delete` | all |
| **排课** | `schedule:auto/adjust` | admin, teacher |
| **选课** | `elective:manage/read/select` | admin, teacher, student, parent |
| **诊断** | `diagnostic:manage/read` | admin, teacher, student, parent |
| **备课** | `lesson_plan:create/read/update/delete/publish` | admin, teacher, student, parent |
| **仪表盘** | `dashboard:admin_read/teacher_read/student_read/parent_read` | 各角色独立 |
| **错题本** | `error_book:read/manage` | student, parent |
| | `error_book:analytics_read` | admin, teacher |
| **专项练习** | `adaptive_practice:read/manage` | student, teacher, parent, grade_head |
| **AI** | `ai:chat` | all |
| | `ai:configure` | admin |
| **文件** | `file:upload/read/delete` | alluploadadmindelete |
| **审计** | `audit_log:read` | admin |
| **RBAC** | `role:create/read/update/delete/assign` | admin |
| | `permission:read` | admin |
| **设置** | `settings:admin` | admin |
| | `user:profile_update` | all |
---
## 五、模块依赖关系速查
| 模块 | 依赖模块(通过 data-access | 被依赖模块 |
|------|------------------------------|-----------|
| **exams** | questions, classes, school, homework | homework, dashboard, proctoring, diagnostic, grades |
| **homework** | exams, classes, school, users | grades, dashboard, diagnostic |
| **grades** | exams, classes, school, users | dashboard, parent |
| **classes** | homework, scheduling | exams, homework, grades, scheduling, attendance, dashboard |
| **dashboard** | users, classes, textbooks, questions, exams, homework | — |
| **parent** | classes, homework, grades | — |
| **diagnostic** | exams, questions, classes, users | — |
| **lesson-preparation** | textbooks, questions, exams, homework, classes, files | — |
| **messaging** | notifications | settings |
| **scheduling** | classes, users | — |
| **proctoring** | exams, users | — |
| **school** | — | exams, classes, grades, scheduling, attendance |
| **textbooks** | — | questions, exams, homework, lesson-preparation |
| **users** | — | all modules |
| **rbac** | — | admin |
| **audit** | — | all modules (via shared/audit-logger) |
---
## 六、维护说明
- 新增模块时,需同步更新本文档的模块-角色速查表(第一节)和对应角色的详细清单(第二节)
- 新增权限点时,需同步更新权限点汇总(第四节)
- 模块依赖关系变更时,需同步更新依赖关系速查(第五节)
- 路由变更时,需同步更新对应角色的路由表

View File

@@ -0,0 +1,101 @@
// 临时脚本:更新 004_architecture_impact_map.md
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '004_architecture_impact_map.md');
const content = fs.readFileSync(filePath, 'utf8');
const oldString = [
'| `components/blocks/reflection-block.tsx` | 反思 BlockV3 续select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
'',
'---',
'',
'## 2.28 error-book错题本模块'
].join('\r\n');
const newLines = [
'| `components/blocks/reflection-block.tsx` | 反思 BlockV3 续select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
'| `components/calendar-view.tsx` | **M9 日历视图组件2026-07-01 新增)**:客户端组件,支持周历/月历切换6 种事件类型颜色映射 |',
'| `components/curriculum-map-view.tsx` | **M4 课程地图热力图组件2026-07-01 新增)**:学科×年级矩阵表格,颜色编码覆盖率 |',
'| `data-access-calendar.ts` | **M9 日历事件数据访问层2026-07-01 新增)** |',
'| `data-access-review.ts` | **M3 审核工作流数据访问层2026-07-01 新增)** |',
'| `data-access-comments.ts` | **M2 协同备课评论数据访问层2026-07-01 新增)** |',
'| `data-access-formative.ts` | **M5 形成性评价数据访问层2026-07-01 新增)** |',
'| `data-access-attachments.ts` | **M6 资源附件库数据访问层2026-07-01 新增)** |',
'| `data-access-substitutes.ts` | **M12 代课教师数据访问层2026-07-01 新增)** |',
'| `data-access-ai-evaluation.ts` | **M8 AI 评估数据访问层2026-07-01 新增)** |',
'| `data-access-analytics.ts` | **M10 备课分析仪表盘数据访问层2026-07-01 新增)** |',
'| `actions-calendar.ts` | **M9 日历 Server Actions2026-07-01 新增)** |',
'| `actions-review.ts` | **M3 审核工作流 Server Actions2026-07-01 新增)** |',
'| `actions-comments.ts` | **M2 评论 Server Actions2026-07-01 新增)** |',
'| `actions-formative.ts` | **M5 形成性评价 Server Actions2026-07-01 新增)** |',
'| `actions-attachments.ts` | **M6 附件库 Server Actions2026-07-01 新增)** |',
'| `actions-substitutes.ts` | **M12 代课教师 Server Actions2026-07-01 新增)** |',
'| `actions-ai-evaluation.ts` | **M8 AI 评估 Server Actions2026-07-01 新增)** |',
'| `actions-analytics.ts` | **M10 分析仪表盘 Server Actions2026-07-01 新增)** |',
'',
'> 架构变更2026-07-01M1-M12 中长期计划完整实施):',
'> - **M1 课标对标体系**:新增 modules/standards/ 模块;新增 3 个 STANDARD_* 权限点;新增路由 /admin/curriculum-map',
'> - **M2 协同备课评论**:新增 data-access-comments.ts + actions-comments.ts支持 Block 级评论、resolved 状态',
'> - **M3 审核工作流**:新增 data-access-review.ts + actions-review.ts状态机 draft → submitted → approved/rejected → published',
'> - **M4 课程地图**:新增 admin/curriculum-map 路由 + curriculum-map-view.tsx 热力图组件',
'> - **M5 形成性评价闭环**:新增 data-access-formative.ts + actions-formative.ts4 种互动类型',
'> - **M6 资源附件库**:新增 data-access-attachments.ts + actions-attachments.ts',
'> - **M7 移动端 PWA**:见 1.1.1 章节',
'> - **M8 AI 差异化生成 + 评估**:新增 data-access-ai-evaluation.ts + actions-ai-evaluation.ts',
'> - **M9 日历视图**:新增 data-access-calendar.ts + actions-calendar.ts + calendar-view.tsx + teacher/lesson-plans/calendar 路由',
'> - **M10 备课分析仪表盘**:新增 data-access-analytics.ts + actions-analytics.ts3 个分析维度',
'> - **M11 版本 diff 预览**data-access-versions.ts 的 getVersionContent 支持读取历史版本内容',
'> - **M12 代课教师**:新增 data-access-substitutes.ts + actions-substitutes.ts',
'> - **权限扩展**:新增 STANDARD_READ/MANAGE/LINK + LESSON_PLAN_SUBMIT/REVIEW/ANALYTICS_READ/SUBSTITUTE_MANAGE',
'> - **i18n 扩展**zh-CN 新增 9 个命名空间status 扩展为 6 状态',
'> - **数据库表**:新增 6 张表lessonPlanComments/lessonPlanReviewRecords/lessonPlanFormativeItems/lessonPlanAttachments/lessonPlanAiEvaluations/lessonPlanSubstituteAssignments',
'',
'---',
'',
'## 2.27b standards课标模块2026-07-01 新增)',
'',
'**职责**K12 课标管理,支持课标树、按学科+年级筛选、课案与课标多对多关联。',
'',
'**导出函数**',
'- Data-accessgetStandards / getStandardsTree / getStandardById / createStandard / updateStandard / deactivateStandard / searchStandards / getStandardsByPlanId / linkPlanToStandard / unlinkPlanFromStandard',
'- Actions10 个 Server Actions全部使用 requirePermission() + ActionState<T>',
'- SchemacreateStandardSchema / updateStandardSchema / getStandardsParamsSchema / linkPlanToStandardSchema',
'- TypesStandard / StandardTreeNode / LessonPlanStandardLink',
'',
'**权限点**',
'- STANDARD_READstandard:read',
'- STANDARD_MANAGEstandard:manage',
'- STANDARD_LINKstandard:link',
'',
'**依赖关系**',
'- 依赖shared/*、modules/lesson-preparation/lib/i18n-errors',
'- 被依赖modules/lesson-preparation、app/(dashboard)/admin/curriculum-map',
'',
'**文件清单**',
'| 文件 | 职责 |',
'|------|------|',
'| types.ts | 类型定义 |',
'| schema.ts | Zod 验证 |',
'| data-access.ts | 课标 CRUD + 树形查询 + 课案关联 |',
'| actions.ts | 10 个 Server Actions |',
'',
'---',
'',
'## 2.28 error-book错题本模块'
];
const newString = newLines.join('\r\n');
if (!content.includes(oldString)) {
console.error('ERROR: old string not found');
process.exit(1);
}
const occurrences = content.split(oldString).length - 1;
if (occurrences !== 1) {
console.error('ERROR: appears ' + occurrences + ' times');
process.exit(1);
}
const newContent = content.replace(oldString, newString);
fs.writeFileSync(filePath, newContent, 'utf8');
console.log('SUCCESS 004 updated, before=' + content.length + ' after=' + newContent.length);

626
package-lock.json generated
View File

@@ -80,6 +80,7 @@
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^2.4.6",
@@ -3301,7 +3302,7 @@
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
@@ -6596,6 +6597,36 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -7109,6 +7140,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
@@ -7348,7 +7386,6 @@
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -7358,7 +7395,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -9168,7 +9204,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/d3-array": {
@@ -9608,6 +9643,13 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
@@ -12861,6 +12903,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -14179,6 +14231,17 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
@@ -14760,7 +14823,7 @@
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
@@ -14779,7 +14842,7 @@
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -14980,6 +15043,41 @@
}
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@@ -15301,7 +15399,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-markdown": {
@@ -16566,7 +16663,6 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
@@ -17015,7 +17111,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -17528,6 +17624,474 @@
}
}
},
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz",
@@ -17555,6 +18119,50 @@
}
}
},
"node_modules/vitest/node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/vitest/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",

View File

@@ -112,6 +112,7 @@
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/bcryptjs": "^2.4.6",

View File

@@ -1,23 +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 { isRole } from "@/shared/types/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"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { resolveClientIp } from "@/shared/lib/http-utils"
import {
getOrCreatePasswordSecurity,
recordFailedLogin,
resetFailedLogin,
} from "@/shared/lib/password-security-service"
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
import {
encodePermissionsBitmap,
decodePermissionsBitmap,
} from "@/shared/lib/permission-bitmap"
import { trackAuthEvent } from "@/shared/lib/track-event"
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
@@ -31,126 +22,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
password: { label: "Password", type: "password" },
totpCode: { label: "2FA Code", type: "text" },
},
// audit-P1-3 重构authorize 回调原混合 7 类职责107 行),
// 已抽取至 modules/auth/services/login-service.ts 的 authenticateUser 函数。
// 此处为薄包装,仅负责动态 import避免 server-only 模块在模块评估期加载)
// 与委托调用。所有登录逻辑(速率限制/账户锁定/密码校验/2FA/角色加载)均在
// login-service 中实现,可独立测试。
authorize: async (credentials) => {
const email = String(credentials?.email ?? "").trim().toLowerCase()
const password = String(credentials?.password ?? "")
const totpCode = String(credentials?.totpCode ?? "").trim()
if (!email || !password) return null
// 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"),
])
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
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) {
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)
// 2FA verification (if user has enabled it)
const { verifyTwoFactorForLogin } = await import("@/modules/settings/actions-security")
const twoFactorResult = await verifyTwoFactorForLogin({
userId: user.id,
token: totpCode || undefined,
})
if (twoFactorResult.required && !twoFactorResult.valid) {
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: totpCode
? "Invalid 2FA code"
: "2FA required but not provided",
})
return null
}
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const roleNames = roleRows.map((r) => r.name)
const resolvedRole = resolvePrimaryRole(roleNames)
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: resolvedRole,
roles: roleNames,
}
const { authenticateUser } = await import("@/modules/auth/services/login-service")
return authenticateUser(credentials)
},
}),
],
callbacks: {
jwt: async ({ token, user }) => {
if (user) {
const u = user as { id: string; role?: string; roles?: string[]; name?: string }
token.id = u.id
token.role = normalizeRole(u.role)
token.name = u.name ?? undefined
// P0-5 修复User 接口已在 next-auth.d.ts 中扩展了 role/roles 字段,
// 无需 `as` 断言即可安全访问。
if (user && user.id) {
token.id = user.id
token.role = normalizeRole(user.role)
token.name = user.name ?? undefined
// Store all roles (not just primary) and resolved permissions
const allRoles = (u.roles ?? [u.role ?? "student"]).filter(isRole)
const allRoles = (user.roles ?? [user.role ?? "student"]).filter(isRole)
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
// audit-P1-7JWT 中仅存位图(~14 字符),不存数组(~1.1KB)。
// Session callback 解码还原为 Permission[] 供业务使用。
const permissions = await resolvePermissions(allRoles)
token.permissionsBitmap = encodePermissionsBitmap(permissions)
// Onboarding status is resolved from DB below; default to false on first login
token.onboarded = false
}
@@ -181,7 +78,9 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.role = resolvePrimaryRole(allRoles)
token.name = fresh.name ?? token.name
token.roles = allRoles
token.permissions = resolvePermissions(allRoles)
// audit-P1-7刷新位图不再写 token.permissions 数组
const permissions = await resolvePermissions(allRoles)
token.permissionsBitmap = encodePermissionsBitmap(permissions)
token.onboarded = Boolean(fresh.onboardedAt)
}
}
@@ -189,11 +88,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
return token
},
session: async ({ session, token }) => {
// audit-P1-7session.user.permissions 由位图解码得到,
// 业务代码getAuthContext / usePermission无需感知位图存在。
if (session.user) {
session.user.id = String(token.id ?? "")
session.user.role = normalizeRole(token.role)
session.user.roles = (token.roles ?? []).filter(isRole)
session.user.permissions = (token.permissions ?? []) as typeof token.permissions
session.user.permissions = decodePermissionsBitmap(
token.permissionsBitmap ?? "",
)
session.user.onboarded = Boolean(token.onboarded)
if (typeof token.name === "string") {
session.user.name = token.name
@@ -210,17 +113,34 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
action: "signin",
status: "success",
})
// audit-P1-9登录成功埋点用于登录成功率、异常登录地理/设备告警)
// 非阻塞trackEvent 内部已吞掉异常
await trackAuthEvent("auth.signin_success", {
userId: user.id,
properties: {
// user.role 是单一主角色user.roles 是全部角色(可能未填充)
roles: (user.roles ?? (user.role ? [user.role] : [])),
},
})
},
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 ??
""
// P0-5 修复:使用 `in` 操作符对联合类型进行类型收窄,替代 `as` 断言。
// NextAuth v5 signOut 事件 message 为联合类型:
// { session: ... } | { token: JWT }
// JWT 策略下实际收到 { token: JWT },其中 JWT 已在 next-auth.d.ts 扩展。
let userId = ""
let userEmail = ""
let roles: string[] = []
if ("token" in message) {
const token = message.token
if (token) {
userId = String(token.id ?? "")
userEmail = String(token.email ?? "")
roles = (token.roles ?? []).filter(isRole)
}
}
if (userEmail) {
await logLoginEvent({
userId: userId || undefined,
@@ -229,6 +149,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
status: "success",
})
}
// audit-P1-9登出埋点
if (userId) {
await trackAuthEvent("auth.signout", {
userId,
properties: { roles },
})
}
},
},
})

View File

@@ -10,6 +10,13 @@ export const env = createEnv({
AI_API_KEY: z.string().min(1).optional(),
AI_BASE_URL: z.string().url().optional(),
AI_MODEL: z.string().min(1).optional(),
// Rate limit driver: "memory" (default, single-instance) or "redis" (distributed).
RATE_LIMIT_DRIVER: z.enum(["memory", "redis"]).default("memory"),
// Upstash Redis REST credentials (only required when RATE_LIMIT_DRIVER=redis).
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
// audit-P2-5: Cron job bearer token for /api/cron/* endpoints.
CRON_SECRET: z.string().min(1).optional(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
@@ -23,6 +30,10 @@ export const env = createEnv({
AI_API_KEY: process.env.AI_API_KEY,
AI_BASE_URL: process.env.AI_BASE_URL,
AI_MODEL: process.env.AI_MODEL,
RATE_LIMIT_DRIVER: process.env.RATE_LIMIT_DRIVER,
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN,
CRON_SECRET: process.env.CRON_SECRET,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,

View File

@@ -50,6 +50,10 @@ export default getRequestConfig(async () => {
users,
leave,
nav,
rbac,
questions,
parent,
invitationCodes,
] = await Promise.all([
import(`@/shared/i18n/messages/${locale}/common.json`),
import(`@/shared/i18n/messages/${locale}/auth.json`),
@@ -81,6 +85,10 @@ export default getRequestConfig(async () => {
import(`@/shared/i18n/messages/${locale}/users.json`),
import(`@/shared/i18n/messages/${locale}/leave.json`),
import(`@/shared/i18n/messages/${locale}/nav.json`),
import(`@/shared/i18n/messages/${locale}/rbac.json`),
import(`@/shared/i18n/messages/${locale}/questions.json`),
import(`@/shared/i18n/messages/${locale}/parent.json`),
import(`@/shared/i18n/messages/${locale}/invitation-codes.json`),
]);
return {
@@ -116,6 +124,10 @@ export default getRequestConfig(async () => {
users: users.default,
leave: leave.default,
nav: nav.default,
rbac: rbac.default,
questions: questions.default,
parent: parent.default,
invitationCodes: invitationCodes.default,
},
};
});

View File

@@ -5,6 +5,7 @@ export let MOCK_EXAMS: Exam[] = [
id: "exam_001",
title: "Algebra Midterm",
subject: "Mathematics",
subjectId: "subj_math",
grade: "Grade 10",
status: "draft",
difficulty: 3,
@@ -19,6 +20,7 @@ export let MOCK_EXAMS: Exam[] = [
id: "exam_002",
title: "Physics Mechanics Quiz",
subject: "Physics",
subjectId: "subj_physics",
grade: "Grade 11",
status: "published",
difficulty: 4,
@@ -33,6 +35,7 @@ export let MOCK_EXAMS: Exam[] = [
id: "exam_003",
title: "English Reading Comprehension",
subject: "English",
subjectId: "subj_english",
grade: "Grade 12",
status: "published",
difficulty: 2,
@@ -47,6 +50,7 @@ export let MOCK_EXAMS: Exam[] = [
id: "exam_004",
title: "Chemistry Final",
subject: "Chemistry",
subjectId: "subj_chemistry",
grade: "Grade 12",
status: "archived",
difficulty: 5,
@@ -61,6 +65,7 @@ export let MOCK_EXAMS: Exam[] = [
id: "exam_005",
title: "Geometry Chapter Test",
subject: "Mathematics",
subjectId: "subj_math",
grade: "Grade 9",
status: "published",
difficulty: 3,

View File

@@ -58,16 +58,14 @@ export async function getErrorBookItemsAction(
}
}
const status = params.status && params.status !== "all"
? (z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status).success
? (params.status as "new" | "learning" | "mastered" | "archived")
: undefined)
const statusParse = z.enum(["new", "learning", "mastered", "archived"]).safeParse(params.status)
const status = params.status && params.status !== "all" && statusParse.success
? statusParse.data
: undefined
const sourceType = params.sourceType && params.sourceType !== "all"
? (z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType).success
? (params.sourceType as "exam" | "homework" | "manual")
: undefined)
const sourceTypeParse = z.enum(["exam", "homework", "manual"]).safeParse(params.sourceType)
const sourceType = params.sourceType && params.sourceType !== "all" && sourceTypeParse.success
? sourceTypeParse.data
: undefined
const data = await getErrorBookItems({

View File

@@ -3,6 +3,7 @@
import { useState, useTransition, useEffect } from "react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import {
Dialog,
@@ -23,53 +24,44 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { getQuestionsAction } from "@/modules/questions/actions"
import { createErrorBookItemAction } from "../actions"
import { COMMON_ERROR_TAGS } from "../types"
export function AddErrorBookDialog() {
interface QuestionOption {
id: string
preview: string
}
interface AddErrorBookDialogProps {
/** 预加载的题目选项(若提供则直接使用,不再调用 onLoadQuestions */
questionOptions?: QuestionOption[]
/** 懒加载题目选项的回调(对话框打开时调用) */
onLoadQuestions?: () => Promise<QuestionOption[]>
}
export function AddErrorBookDialog({
questionOptions: questionOptionsProp,
onLoadQuestions,
}: AddErrorBookDialogProps) {
const t = useTranslations("error-book")
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [questionId, setQuestionId] = useState("")
const [note, setNote] = useState("")
const [errorTags, setErrorTags] = useState<string[]>([])
const [questionOptions, setQuestionOptions] = useState<Array<{
id: string
preview: string
}>>([])
const [loadedOptions, setLoadedOptions] = useState<QuestionOption[]>([])
function extractPreview(content: unknown): string {
if (typeof content === "string") return content.slice(0, 60)
if (Array.isArray(content)) {
const texts: string[] = []
for (const node of content) {
if (typeof node === "string") texts.push(node)
else if (typeof node === "object" && node !== null) {
const n = node as Record<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
}
}
return texts.join("").slice(0, 60)
}
return "题目"
}
const questionOptions = questionOptionsProp ?? loadedOptions
useEffect(() => {
if (open && questionOptions.length === 0) {
getQuestionsAction({ pageSize: 100 })
.then((res) => {
if (res.success && res.data) {
setQuestionOptions(
res.data.data.map((q) => ({
id: q.id,
preview: extractPreview(q.content),
}))
)
}
if (open && onLoadQuestions && loadedOptions.length === 0 && !questionOptionsProp) {
onLoadQuestions()
.then((options) => {
setLoadedOptions(options)
})
.catch(() => {})
}
}, [open, questionOptions.length])
}, [open, onLoadQuestions, loadedOptions.length, questionOptionsProp])
function toggleTag(tag: string) {
setErrorTags((prev) =>
@@ -79,7 +71,7 @@ export function AddErrorBookDialog() {
function handleSubmit() {
if (!questionId) {
toast.error("请选择题目")
toast.error(t("messages.selectQuestion"))
return
}
startTransition(async () => {
@@ -90,13 +82,13 @@ export function AddErrorBookDialog() {
)
const res = await createErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "已添加")
toast.success(res.message ?? t("messages.addedShort"))
setOpen(false)
setQuestionId("")
setNote("")
setErrorTags([])
} else {
toast.error(res.message ?? "添加失败")
toast.error(res.message ?? t("messages.addFailed"))
}
})
}
@@ -106,23 +98,23 @@ export function AddErrorBookDialog() {
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" data-icon="inline-start" />
{t("actions.add")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("addDialog.title")}</DialogTitle>
<DialogDescription>
/
{t("addDialog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="question"></Label>
<Label htmlFor="question">{t("addDialog.selectQuestion")}</Label>
<Select value={questionId} onValueChange={setQuestionId}>
<SelectTrigger id="question">
<SelectValue placeholder="从题库中选择..." />
<SelectValue placeholder={t("addDialog.selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{questionOptions.map((q) => (
@@ -135,18 +127,18 @@ export function AddErrorBookDialog() {
</div>
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Label htmlFor="note">{t("addDialog.noteLabel")}</Label>
<Textarea
id="note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录错误原因、解题思路..."
placeholder={t("addDialog.notePlaceholder")}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Label>{t("addDialog.errorTagsLabel")}</Label>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Button
@@ -165,10 +157,10 @@ export function AddErrorBookDialog() {
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("actions.cancel")}
</Button>
<Button disabled={isPending || !questionId} onClick={handleSubmit}>
{isPending ? "添加中..." : "添加"}
{isPending ? t("actions.adding") : t("actions.add")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,4 +1,7 @@
"use client"
import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
@@ -26,43 +29,52 @@ export function AnalyticsStatsCards({
knowledgePointCount,
className,
}: AnalyticsStatsCardsProps) {
const t = useTranslations("error-book")
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
const cards = [
{
label: "覆盖学生",
label: t("analyticsStats.coverage"),
value: studentsWithErrorBook,
sub: `/ ${totalStudents}`,
sub: t("analyticsStats.coverageSub", { total: totalStudents }),
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: "错题总数",
label: t("analyticsStats.totalErrors"),
value: totalErrorItems,
sub: `人均 ${(totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)}`,
sub: t("analyticsStats.totalErrorsSub", { avg }),
icon: TrendingUp,
color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-950/30",
},
{
label: "平均掌握率",
label: t("analyticsStats.avgMastery"),
value: `${Math.round(averageMasteryRate * 100)}%`,
sub: averageMasteryRate >= 0.6 ? "整体良好" : "需加强",
sub: averageMasteryRate >= 0.6
? t("analyticsStats.avgMasteryGood")
: t("analyticsStats.avgMasteryNeedImprove"),
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: "待复习",
label: t("analyticsStats.dueReview"),
value: dueReviewCount,
sub: dueReviewCount > 0 ? "需要关注" : "无到期",
sub: dueReviewCount > 0
? t("analyticsStats.dueReviewNeedAttention")
: t("analyticsStats.dueReviewNone"),
icon: Clock,
color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30",
},
{
label: "涉及知识点",
label: t("analyticsStats.knowledgePoints"),
value: knowledgePointCount ?? 0,
sub: knowledgePointCount && knowledgePointCount > 5 ? "范围较广" : "集中",
sub: knowledgePointCount && knowledgePointCount > 5
? t("analyticsStats.knowledgePointsWide")
: t("analyticsStats.knowledgePointsFocused"),
icon: Brain,
color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30",

View File

@@ -1,6 +1,7 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
@@ -18,12 +19,36 @@ interface ChapterWeaknessChartProps {
className?: string
}
interface ChapterChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
knowledgePointCount: number
topKps: Array<{ knowledgePointName: string; errorCount: number }>
}
function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number" &&
typeof obj.knowledgePointCount === "number"
)
}
/**
* 章节薄弱度图表(哪些课在错)
* 横向柱状图,按错题数降序
* 每个柱子可展开显示该章节下错得最多的知识点
*/
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -37,7 +62,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
const chartConfig: ChartConfig = {
errorCount: {
label: "错题数",
label: t("chapterChart.errorCount"),
color: "var(--color-chart-2)",
},
}
@@ -45,10 +70,14 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("chapterChart.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[300px] w-full">
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
aria-label={t("chapterChart.title")}
>
<BarChart
data={chartData}
layout="vertical"
@@ -71,29 +100,32 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
<ChartTooltipContent
className="w-[280px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
knowledgePointCount: number
topKps: Array<{ knowledgePointName: string; errorCount: number }>
}
if (!isChapterChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
{t("chapterChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("chapterChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
<span className="ml-2"><span className="font-medium text-foreground">{p.knowledgePointCount}</span></span>
{t("chapterChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
<span className="ml-2">
{t("chapterChart.knowledgePointCount")}
<span className="font-medium text-foreground">{payload.knowledgePointCount}</span>
</span>
</div>
{p.topKps && p.topKps.length > 0 ? (
{payload.topKps && payload.topKps.length > 0 ? (
<div className="border-t pt-1.5 mt-1.5">
<div className="text-xs text-muted-foreground mb-1"></div>
{p.topKps.map((kp) => (
<div className="text-xs text-muted-foreground mb-1">
{t("chapterChart.weakKpsLabel")}
</div>
{payload.topKps.map((kp) => (
<div key={kp.knowledgePointName} className="flex justify-between text-xs">
<span>{kp.knowledgePointName}</span>
<span className="font-medium text-rose-600">{kp.errorCount}</span>
@@ -129,7 +161,7 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
<div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{chapter.chapterTitle}</span>
<Badge variant="outline" className="shrink-0 text-xs">
{chapter.knowledgePointCount}
{t("chapterChart.knowledgePointBadge", { count: chapter.knowledgePointCount })}
</Badge>
</div>
{chapter.topKnowledgePoints.length > 0 ? (
@@ -144,7 +176,9 @@ export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartPr
</div>
<div className="flex shrink-0 flex-col items-end gap-0.5 text-xs">
<span className="font-bold text-rose-600">{chapter.errorCount}</span>
<span className="text-muted-foreground"> {Math.round(chapter.masteryRate * 100)}%</span>
<span className="text-muted-foreground">
{t("chapterChart.masteryRateLabel")} {Math.round(chapter.masteryRate * 100)}%
</span>
</div>
</div>
))}

View File

@@ -1,6 +1,7 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
@@ -17,6 +18,29 @@ interface ClassErrorBarChartProps {
className?: string
}
interface ClassBarChartPayload {
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
function isClassBarChartPayload(v: unknown): v is ClassBarChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.totalErrorItems === "number" &&
typeof obj.studentCount === "number" &&
typeof obj.averageErrorPerStudent === "number" &&
typeof obj.averageMasteryRate === "number" &&
typeof obj.dueReviewCount === "number"
)
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
@@ -31,6 +55,8 @@ const CHART_COLORS = [
* 颜色按班级区分tooltip 显示学生数/人均/掌握率
*/
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -44,7 +70,7 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
const chartConfig: ChartConfig = {
totalErrorItems: {
label: "错题总数",
label: t("classErrorBar.errorCount"),
color: "var(--color-chart-1)",
},
}
@@ -52,10 +78,14 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("classErrorBar.title")}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<ChartContainer
config={chartConfig}
className="h-[280px] w-full"
aria-label={t("classErrorBar.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
@@ -73,31 +103,29 @@ export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps)
<ChartTooltipContent
className="w-[220px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
totalErrorItems: number
studentCount: number
averageErrorPerStudent: number
averageMasteryRate: number
dueReviewCount: number
}
if (!isClassBarChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.totalErrorItems}</span>
{t("classErrorBar.errorCount")}
<span className="font-medium text-foreground">{payload.totalErrorItems}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.studentCount}</span>
{t("classErrorBar.studentCount")}
<span className="font-medium text-foreground">{payload.studentCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageErrorPerStudent}</span>
{t("classErrorBar.avgPerStudent")}
<span className="font-medium text-foreground">{payload.averageErrorPerStudent}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.averageMasteryRate}%</span>
{t("classErrorBar.avgMastery")}
<span className="font-medium text-foreground">{payload.averageMasteryRate}%</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-rose-600">{p.dueReviewCount}</span>
{t("classErrorBar.dueReview")}
<span className="font-medium text-rose-600">{payload.dueReviewCount}</span>
</div>
</div>
)

View File

@@ -1,198 +0,0 @@
import Link from "next/link"
import { Users, AlertTriangle, TrendingUp, Target } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate, formatNumber } from "@/shared/lib/utils"
import type {
StudentErrorBookSummary,
KnowledgePointWeakness,
SubjectErrorDistribution,
} from "../types"
interface ClassErrorBookOverviewProps {
totalStudents: number
studentsWithErrorBook: number
totalErrorItems: number
averageMasteryRate: number
topWeakKnowledgePoints: KnowledgePointWeakness[]
subjectDistribution: SubjectErrorDistribution[]
}
export function ClassErrorBookOverview({
totalStudents,
studentsWithErrorBook,
totalErrorItems,
averageMasteryRate,
topWeakKnowledgePoints,
subjectDistribution,
}: ClassErrorBookOverviewProps) {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="覆盖学生"
value={`${studentsWithErrorBook}/${totalStudents}`}
icon={Users}
description="有错题记录的学生数"
/>
<StatCard
title="错题总数"
value={totalErrorItems}
icon={AlertTriangle}
color="text-rose-500"
description="班级累计错题"
/>
<StatCard
title="平均掌握率"
value={`${formatNumber(averageMasteryRate * 100, 0)}%`}
icon={TrendingUp}
color="text-emerald-500"
description="已掌握错题占比"
/>
<StatCard
title="薄弱知识点"
value={topWeakKnowledgePoints.length}
icon={Target}
color="text-amber-500"
description="需重点讲解"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* 薄弱知识点 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Target className="h-4 w-4" />
Top 10
</CardTitle>
</CardHeader>
<CardContent>
{topWeakKnowledgePoints.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{topWeakKnowledgePoints.map((kp, idx) => (
<div key={kp.knowledgePointId} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2">
<Badge variant="outline" className="w-6 justify-center">
{idx + 1}
</Badge>
<span className="line-clamp-1">{kp.knowledgePointName}</span>
</span>
<span className="text-muted-foreground">
{kp.errorCount} · {formatNumber(kp.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 学科分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{subjectDistribution.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
</p>
) : (
<div className="space-y-3">
{subjectDistribution.map((s) => (
<div key={s.subjectId ?? "none"} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{s.subjectName}</span>
<span className="text-muted-foreground">
{s.errorCount} · {formatNumber(s.masteryRate * 100, 0)}%
</span>
</div>
<Progress value={s.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
interface StudentErrorTableProps {
students: StudentErrorBookSummary[]
studentNames: Map<string, string>
basePath: string
}
export function StudentErrorTable({ students, studentNames, basePath }: StudentErrorTableProps) {
if (students.length === 0) {
return (
<EmptyState
icon={Users}
title="暂无学生错题数据"
description="学生完成作业或考试后,错题数据会自动汇总到这里。"
className="h-[300px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{students.map((s) => {
const name = studentNames.get(s.studentId) ?? "未知"
return (
<tr key={s.studentId} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link
href={`${basePath}?studentId=${s.studentId}`}
className="font-medium hover:underline"
>
{name}
</Link>
</td>
<td className="px-4 py-3 text-right">{s.totalCount}</td>
<td className="px-4 py-3 text-right text-blue-600 dark:text-blue-400">{s.newCount}</td>
<td className="px-4 py-3 text-right text-amber-600 dark:text-amber-400">{s.learningCount}</td>
<td className="px-4 py-3 text-right text-emerald-600 dark:text-emerald-400">{s.masteredCount}</td>
<td className="px-4 py-3 text-right text-rose-600 dark:text-rose-400">{s.dueReviewCount}</td>
<td className="px-4 py-3 text-right">
{formatNumber(s.masteredRate * 100, 0)}%
</td>
<td className="px-4 py-3 text-right text-muted-foreground text-xs">
{s.lastActivityAt ? formatDate(s.lastActivityAt) : "-"}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge"
@@ -25,6 +26,7 @@ export function ClassFilter({
}: ClassFilterProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations("error-book")
const handleSelect = (classId: string) => {
const params = new URLSearchParams(searchParams.toString())
@@ -42,6 +44,8 @@ export function ClassFilter({
<div className="flex flex-wrap gap-2">
<button
type="button"
role="tab"
aria-selected={currentClassId === "all"}
onClick={() => handleSelect("all")}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
@@ -50,7 +54,7 @@ export function ClassFilter({
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium"></span>
<span className="font-medium">{t("classFilter.all")}</span>
</button>
{classes.map((cls) => {
const isActive = currentClassId === cls.classId
@@ -58,6 +62,8 @@ export function ClassFilter({
<button
key={cls.classId}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(cls.classId)}
className={cn(
"inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors",
@@ -71,7 +77,7 @@ export function ClassFilter({
variant={isActive ? "secondary" : "outline"}
className="text-xs"
>
{cls.totalErrorItems}
{t("classFilter.errorCount", { count: cls.totalErrorItems })}
</Badge>
{cls.dueReviewCount > 0 ? (
<span
@@ -80,7 +86,7 @@ export function ClassFilter({
isActive ? "text-primary-foreground/80" : "text-rose-600"
)}
>
{cls.dueReviewCount}
{t("classFilter.dueReview", { count: cls.dueReviewCount })}
</span>
) : null}
</button>

View File

@@ -1,7 +1,6 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Archive, Trash2, FileText, Calendar, History, Target } from "lucide-react"
import { toast } from "sonner"
@@ -37,64 +36,31 @@ import {
type ErrorBookItem,
} from "../types"
import { ReviewButtons } from "./review-buttons"
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
interface ErrorBookDetailDialogProps {
item: ErrorBookItemDetail | (Omit<ErrorBookItemDetail, "reviews"> & { reviews?: ErrorBookItemDetail["reviews"] })
trigger: React.ReactNode
/** 当前学生 ID用于 AI 薄弱点分析 */
/** 当前学生 ID保留接口兼容AI 分析已通过 aiAnalysisSlot 注入 */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析 */
/** 全部错题列表(保留接口兼容AI 分析已通过 aiAnalysisSlot 注入 */
errorItems?: ErrorBookItem[]
/** AI 分析区域插槽(由父组件注入,未提供则不渲染) */
aiAnalysisSlot?: React.ReactNode
/** 发起变式练习回调(未提供则不渲染变式练习入口) */
onStartVariantPractice?: () => void
}
/**
* 从题目内容中提取纯文本(用于 AI 相似题推荐)
*
* 类型收窄:从 unknown 逐步缩小到具体类型,避免使用 as 断言。
*/
function extractQuestionText(content: unknown): string {
if (!content) return ""
if (typeof content === "string") return content
if (typeof content === "object" && content !== null && "text" in content) {
const textValue = (content as Record<string, unknown>).text
if (typeof textValue === "string") return textValue
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}
/**
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
*/
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
questionText: string
questionType: string
knowledgePointIds?: string[]
errorCount: number
masteryLevel: number
}> {
return items.map((it) => ({
questionText: extractQuestionText(it.question?.content),
questionType: it.question?.type ?? "unknown",
knowledgePointIds: it.knowledgePointIds ?? undefined,
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
masteryLevel: it.masteryLevel,
}))
}
export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }: ErrorBookDetailDialogProps) {
export function ErrorBookDetailDialog({
item,
trigger,
aiAnalysisSlot,
onStartVariantPractice,
}: ErrorBookDetailDialogProps) {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
const router = useRouter()
const t = useTranslations("error-book")
const tPractice = useTranslations("practice")
function handleSaveNote() {
startTransition(async () => {
@@ -140,49 +106,12 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
})
}
/**
* 发起错题变式练习。
*
* 从当前错题出发,创建一个 error_variant 类型的练习会话,
* 使用该错题关联的原题进行针对性练习。
*/
function handleStartVariantPractice() {
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
practiceType: "error_variant",
subjectId: item.subjectId ?? undefined,
sourceMeta: {
errorBookItemIds: [item.id],
sourceQuestionIds: [item.questionId],
},
questionCount: 10,
}),
)
const res = await createPracticeSessionAction(undefined, formData)
if (res.success && res.data) {
toast.success(res.message ?? tPractice("starter.title"))
setOpen(false)
router.push(`/student/practice/${res.data.sessionId}`)
} else {
toast.error(res.message ?? t("messages.saveFailed"))
}
})
}
function toggleTag(tag: string) {
setErrorTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
)
}
// AI 分析所需数据
const currentQuestionText = extractQuestionText(item.question?.content)
const currentQuestionType = item.question?.type
const aiErrorItems = errorItems ? mapErrorItemsForAnalysis(errorItems) : []
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
@@ -208,10 +137,10 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<DialogDescription className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(item.createdAt)}
{t("itemCard.addedAt", { date: formatDate(item.createdAt) })}
</span>
<span>: {item.masteryLevel}/5</span>
<span> {item.reviewCount} </span>
<span>{t("itemCard.masteryOutOf", { level: item.masteryLevel })}</span>
<span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
</DialogDescription>
</DialogHeader>
@@ -219,7 +148,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<div className="space-y-4 pb-4">
{/* 题目内容 */}
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.question")}</h4>
<div className="rounded-md border bg-muted/30 p-3 text-sm">
{item.question ? (
<pre className="whitespace-pre-wrap break-words font-sans">
@@ -228,7 +157,9 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
: JSON.stringify(item.question.content, null, 2)}
</pre>
) : (
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">
{t("detailDialog.questionDeleted")}
</span>
)}
</div>
</section>
@@ -239,7 +170,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
{item.studentAnswer !== null && item.studentAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-rose-600 dark:text-rose-400">
{t("detailDialog.myAnswer")}
</h4>
<div className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm dark:border-rose-900 dark:bg-rose-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
@@ -253,7 +184,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
{item.correctAnswer !== null && item.correctAnswer !== undefined ? (
<div>
<h4 className="mb-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
{t("detailDialog.correctAnswer")}
</h4>
<div className="rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950/20">
<pre className="whitespace-pre-wrap break-words font-sans">
@@ -267,40 +198,36 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
</section>
) : null}
{/* AI 分析区(相似题推荐 + 薄弱点分析 */}
{studentId && currentQuestionText ? (
{/* AI 分析区(由父组件注入 */}
{aiAnalysisSlot ? (
<section>
<h4 className="mb-2 text-sm font-medium">AI </h4>
<AiErrorBookAnalysis
studentId={studentId}
subjectId={item.subjectId ?? undefined}
currentQuestionText={currentQuestionText}
currentQuestionType={currentQuestionType}
errorItems={aiErrorItems}
/>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.aiAnalysis")}</h4>
{aiAnalysisSlot}
</section>
) : null}
{/* 变式练习入口 */}
<section>
<Button
onClick={handleStartVariantPractice}
disabled={isPending}
className="w-full"
variant="default"
>
<Target className="h-4 w-4" />
{isPending ? tPractice("starter.creating") : tPractice("types.error_variant")}
</Button>
<p className="mt-1 text-xs text-muted-foreground text-center">
{tPractice("starter.description")}
</p>
</section>
{/* 变式练习入口(由父组件注入回调) */}
{onStartVariantPractice ? (
<section>
<Button
onClick={onStartVariantPractice}
disabled={isPending}
className="w-full"
variant="default"
>
<Target className="h-4 w-4" />
{t("detailDialog.variantPractice")}
</Button>
<p className="mt-1 text-xs text-muted-foreground text-center">
{t("detailDialog.variantPracticeDesc")}
</p>
</section>
) : null}
{/* 复习区 */}
{item.status !== "mastered" && item.status !== "archived" ? (
<section>
<h4 className="mb-2 text-sm font-medium"></h4>
<h4 className="mb-2 text-sm font-medium">{t("detailDialog.reviewSelf")}</h4>
<ReviewButtons
itemId={item.id}
onReviewed={() => setOpen(false)}
@@ -312,17 +239,19 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<FileText className="h-4 w-4" />
{t("detailDialog.studyNote")}
</h4>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="记录你的反思、解题思路、易错点..."
placeholder={t("detailDialog.notePlaceholder")}
className="w-full min-h-[80px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
maxLength={2000}
/>
<div className="mt-2">
<p className="mb-1 text-xs text-muted-foreground"></p>
<p className="mb-1 text-xs text-muted-foreground">
{t("detailDialog.errorTagsLabel")}
</p>
<div className="flex flex-wrap gap-1">
{COMMON_ERROR_TAGS.map((tag) => (
<Badge
@@ -343,7 +272,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
disabled={isPending}
onClick={handleSaveNote}
>
{t("actions.saveNote")}
</Button>
</section>
@@ -352,7 +281,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
<section>
<h4 className="mb-2 flex items-center gap-1 text-sm font-medium">
<History className="h-4 w-4" />
{t("detailDialog.reviewHistory")}
</h4>
<div className="space-y-1">
{item.reviews.slice(0, 10).map((r) => (
@@ -386,7 +315,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
onClick={handleArchive}
>
<Archive className="h-4 w-4" data-icon="inline-start" />
{t("actions.archive")}
</Button>
<Button
variant="ghost"
@@ -396,7 +325,7 @@ export function ErrorBookDetailDialog({ item, trigger, studentId, errorItems }:
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" data-icon="inline-start" />
{t("actions.delete")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
@@ -12,6 +13,7 @@ import {
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ErrorBookFilters() {
const t = useTranslations("error-book")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))
@@ -37,40 +39,40 @@ export function ErrorBookFilters() {
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="搜索笔记内容..."
placeholder={t("filters.searchPlaceholder")}
className="flex-1 md:max-w-sm"
inputClassName="border-muted-foreground/20 pl-8"
/>
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="状态" />
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="new"></SelectItem>
<SelectItem value="learning"></SelectItem>
<SelectItem value="mastered"></SelectItem>
<SelectItem value="archived"></SelectItem>
<SelectItem value="all">{t("filters.allStatus")}</SelectItem>
<SelectItem value="new">{t("status.new")}</SelectItem>
<SelectItem value="learning">{t("status.learning")}</SelectItem>
<SelectItem value="mastered">{t("status.mastered")}</SelectItem>
<SelectItem value="archived">{t("status.archived")}</SelectItem>
</SelectContent>
</Select>
<Select value={sourceType} onValueChange={(val) => setSourceType(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" />
<SelectValue placeholder={t("filters.source")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="exam"></SelectItem>
<SelectItem value="homework"></SelectItem>
<SelectItem value="manual"></SelectItem>
<SelectItem value="all">{t("filters.allSource")}</SelectItem>
<SelectItem value="exam">{t("source.exam")}</SelectItem>
<SelectItem value="homework">{t("source.homework")}</SelectItem>
<SelectItem value="manual">{t("source.manual")}</SelectItem>
</SelectContent>
</Select>
<Select value={dueOnly} onValueChange={(val) => setDueOnly(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="复习" />
<SelectValue placeholder={t("filters.review")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="due"></SelectItem>
<SelectItem value="all">{t("filters.allErrors")}</SelectItem>
<SelectItem value="due">{t("filters.dueOnly")}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -1,10 +1,14 @@
"use client"
import { Calendar, FileText, BookMarked } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { cn } from "@/shared/lib/utils"
import { formatDate } from "@/shared/lib/utils"
import { extractQuestionPreview } from "@/shared/lib/question-content"
import {
ERROR_BOOK_SOURCE_LABEL,
@@ -19,45 +23,19 @@ interface ErrorBookItemCardProps {
children?: React.ReactNode
}
/** 从题目内容中提取纯文本预览 */
function extractQuestionPreview(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
const texts: string[] = []
for (const node of content) {
if (typeof node === "string") {
texts.push(node)
} else if (typeof node === "object" && node !== null) {
const n = node as Record<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
if (Array.isArray(n.children)) {
texts.push(extractQuestionPreview(n.children))
}
}
}
return texts.join("")
}
if (typeof content === "object" && content !== null) {
const c = content as Record<string, unknown>
if (typeof c.text === "string") return c.text
}
return "(题目内容)"
}
const MASTERY_LEVEL_LABELS: Record<number, string> = {
0: "未学习",
1: "入门",
2: "了解",
3: "熟悉",
4: "熟练",
5: "掌握",
}
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
const preview = item.question ? extractQuestionPreview(item.question.content) : "(题目已删除)"
const t = useTranslations("error-book")
const preview = item.question
? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
: t("itemCard.questionDeleted")
const isDue = item.nextReviewAt ? item.nextReviewAt <= new Date() : false
const isMastered = item.status === "mastered"
const masteryLabel =
item.masteryLevel >= 0 && item.masteryLevel <= 5
? t(`masteryLevel.${item.masteryLevel}`)
: String(item.masteryLevel)
return (
<Card
className={cn(
@@ -87,7 +65,7 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
) : null}
{item.question?.difficulty ? (
<Badge variant="secondary">
{item.question.difficulty}
{t("itemCard.difficulty", { level: item.question.difficulty })}
</Badge>
) : null}
</div>
@@ -120,11 +98,13 @@ export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>: {MASTERY_LEVEL_LABELS[item.masteryLevel] ?? item.masteryLevel}</span>
<span> {item.reviewCount} </span>
<span>{t("itemCard.mastery", { level: masteryLabel })}</span>
<span>{t("itemCard.reviewTimes", { count: item.reviewCount })}</span>
{item.nextReviewAt && !isMastered ? (
<span className={cn(isDue && "font-medium text-rose-600 dark:text-rose-400")}>
{isDue ? "需复习" : `下次 ${formatDate(item.nextReviewAt)}`}
{isDue
? t("itemCard.needReview")
: t("itemCard.nextReview", { date: formatDate(item.nextReviewAt) })}
</span>
) : null}
</div>

View File

@@ -1,4 +1,8 @@
"use client"
import type { JSX } from "react"
import { BookX } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Button } from "@/shared/components/ui/button"
@@ -8,19 +12,25 @@ import type { ErrorBookItem } from "../types"
interface ErrorBookListProps {
items: ErrorBookItem[]
/** 当前学生 ID用于 AI 薄弱点分析 */
studentId?: string
/** 全部错题列表(用于 AI 薄弱点分析,不传则禁用 AI 分析 */
errorItems?: ErrorBookItem[]
/** AI 分析区域插槽(由父组件注入,传递给每个 ErrorBookDetailDialog */
aiAnalysisSlot?: (item: ErrorBookItem) => React.ReactNode
/** 发起变式练习回调(由父组件注入 */
onStartVariantPractice?: (item: ErrorBookItem) => void
}
export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListProps) {
export function ErrorBookList({
items,
aiAnalysisSlot,
onStartVariantPractice,
}: ErrorBookListProps): JSX.Element {
const t = useTranslations("error-book")
if (items.length === 0) {
return (
<EmptyState
icon={BookX}
title="错题本为空"
description="完成考试或作业后,错题会自动收录到这里。你也可以手动添加错题。"
title={t("empty.title")}
description={t("empty.description")}
className="h-[360px] bg-card"
/>
)
@@ -32,11 +42,11 @@ export function ErrorBookList({ items, studentId, errorItems }: ErrorBookListPro
<ErrorBookItemCard key={item.id} item={item}>
<ErrorBookDetailDialog
item={item}
studentId={studentId}
errorItems={errorItems}
aiAnalysisSlot={aiAnalysisSlot?.(item)}
onStartVariantPractice={onStartVariantPractice ? () => onStartVariantPractice(item) : undefined}
trigger={
<Button variant="outline" size="sm">
{t("actions.viewDetail")}
</Button>
}
/>

View File

@@ -1,4 +1,7 @@
"use client"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { useTranslations } from "next-intl"
import { StatCard } from "@/shared/components/ui/stat-card"
import type { ErrorBookStats } from "../types"
@@ -9,6 +12,7 @@ interface ErrorBookStatsCardsProps {
}
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
const t = useTranslations("error-book")
const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100)
: 0
@@ -16,42 +20,42 @@ export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsPro
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
title="错题总数"
title={t("stats.total")}
value={stats.totalCount}
icon={BookX}
description="累计收录的错题"
description={t("stats.totalDesc")}
isLoading={isLoading}
/>
<StatCard
title="待学习"
title={t("stats.new")}
value={stats.newCount}
icon={Sparkles}
color="text-blue-500"
description="尚未开始复习"
description={t("stats.newDesc")}
isLoading={isLoading}
/>
<StatCard
title="学习中"
title={t("stats.learning")}
value={stats.learningCount}
icon={Repeat}
color="text-amber-500"
description="正在复习掌握"
description={t("stats.learningDesc")}
isLoading={isLoading}
/>
<StatCard
title="已掌握"
title={t("stats.mastered")}
value={stats.masteredCount}
icon={GraduationCap}
color="text-emerald-500"
description={`掌握率 ${masteredPercent}%`}
description={t("stats.masteredDesc", { rate: masteredPercent })}
isLoading={isLoading}
/>
<StatCard
title="待复习"
title={t("stats.dueReview")}
value={stats.dueReviewCount}
icon={Clock}
color="text-rose-500"
description="今日到期复习"
description={t("stats.dueReviewDesc")}
highlight={stats.dueReviewCount > 0}
isLoading={isLoading}
/>

View File

@@ -1,7 +1,9 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { ChevronDown, ChevronRight, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
@@ -32,6 +34,7 @@ export function GroupedStudentErrorTable({
studentNames,
basePath,
}: GroupedStudentErrorTableProps) {
const t = useTranslations("error-book")
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
// 按班级分组
@@ -44,7 +47,7 @@ export function GroupedStudentErrorTable({
if (!group) {
group = {
classId: key,
className: student.className ?? "未分班",
className: student.className ?? t("groupedTable.unclassified"),
students: [],
totalErrors: 0,
averageMasteryRate: 0,
@@ -95,6 +98,7 @@ export function GroupedStudentErrorTable({
{/* 班级头部(可点击展开) */}
<button
type="button"
aria-expanded={isExpanded}
onClick={() => toggleClass(group.classId)}
className="flex w-full items-center justify-between gap-3 bg-muted/50 p-3 text-left transition-colors hover:bg-muted"
>
@@ -107,21 +111,21 @@ export function GroupedStudentErrorTable({
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{group.className}</span>
<Badge variant="outline" className="text-xs">
{group.students.length}
{t("groupedTable.studentCount", { count: group.students.length })}
</Badge>
{studentsWithErrors.length < group.students.length ? (
<Badge variant="secondary" className="text-xs">
{studentsWithErrors.length}
{t("groupedTable.studentsWithErrors", { count: studentsWithErrors.length })}
</Badge>
) : null}
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">{t("groupedTable.totalErrors")}</div>
<div className="font-bold text-rose-600">{group.totalErrors}</div>
</div>
<div className="text-right">
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">{t("groupedTable.avgMastery")}</div>
<div className="font-medium">
{Math.round(group.averageMasteryRate * 100)}%
</div>
@@ -135,18 +139,18 @@ export function GroupedStudentErrorTable({
<table className="w-full text-sm">
<thead className="border-b bg-muted/30">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 text-right font-medium"></th>
<th className="px-3 py-2 font-medium"></th>
<th className="px-3 py-2 font-medium">{t("groupedTable.student")}</th>
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.totalErrors")}</th>
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.new")}</th>
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.learning")}</th>
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.mastered")}</th>
<th className="px-3 py-2 text-right font-medium">{t("groupedTable.dueReview")}</th>
<th className="px-3 py-2 font-medium">{t("groupedTable.masteryRate")}</th>
</tr>
</thead>
<tbody>
{group.students.map((student) => {
const name = studentNames.get(student.studentId) ?? "未知"
const name = studentNames.get(student.studentId) ?? t("groupedTable.unknown")
const hasErrors = student.totalCount > 0
return (
<tr
@@ -158,12 +162,12 @@ export function GroupedStudentErrorTable({
>
<td className="px-3 py-2">
{hasErrors ? (
<a
<Link
href={`${basePath}?studentId=${student.studentId}`}
className="font-medium text-primary hover:underline"
>
{name}
</a>
</Link>
) : (
<span className="text-muted-foreground">{name}</span>
)}

View File

@@ -1,6 +1,7 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
@@ -18,6 +19,27 @@ interface KnowledgePointWeaknessChartProps {
className?: string
}
interface KpChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
chapterTitle: string
}
function isKpChartPayload(v: unknown): v is KpChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number" &&
typeof obj.chapterTitle === "string"
)
}
/**
* 知识点薄弱度图表
* 横向柱状图,按错题数降序
@@ -28,6 +50,8 @@ export function KnowledgePointWeaknessChart({
data,
className,
}: KnowledgePointWeaknessChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -35,12 +59,12 @@ export function KnowledgePointWeaknessChart({
errorCount: d.errorCount,
masteredCount: d.masteredCount,
masteryRate: Number((d.masteryRate * 100).toFixed(0)),
chapterTitle: d.chapterTitle ?? "未分类",
chapterTitle: d.chapterTitle ?? t("weaknessChart.unclassified"),
}))
const chartConfig: ChartConfig = {
errorCount: {
label: "错题数",
label: t("weaknessChart.errorCount"),
color: "var(--color-chart-1)",
},
}
@@ -48,10 +72,16 @@ export function KnowledgePointWeaknessChart({
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"> Top {data.length}</CardTitle>
<CardTitle className="text-base">
{t("weaknessChart.title", { count: data.length })}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ChartContainer config={chartConfig} className="h-[320px] w-full">
<ChartContainer
config={chartConfig}
className="h-[320px] w-full"
aria-label={t("weaknessChart.title", { count: data.length })}
>
<BarChart
data={chartData}
layout="vertical"
@@ -74,25 +104,24 @@ export function KnowledgePointWeaknessChart({
<ChartTooltipContent
className="w-[240px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
chapterTitle: string
}
if (!isKpChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="font-medium">{payload.name}</div>
<div className="text-xs text-muted-foreground">
{p.chapterTitle}
{t("weaknessChart.chapterLabel", { title: payload.chapterTitle })}
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
<span className="ml-2"><span className="font-medium text-emerald-600">{p.masteredCount}</span></span>
{t("weaknessChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
<span className="ml-2">
{t("weaknessChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
{t("weaknessChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)

View File

@@ -3,6 +3,7 @@
import { useState, useTransition } from "react"
import { RotateCcw, ThumbsUp, Check, Zap } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { reviewErrorBookItemAction } from "../actions"
@@ -13,47 +14,50 @@ interface ReviewButtonsProps {
onReviewed?: () => void
}
const REVIEW_OPTIONS: Array<{
type ReviewOption = {
result: ErrorBookReviewResultValue
label: string
description: string
icon: typeof RotateCcw
variant: "destructive" | "secondary" | "default" | "outline"
}> = [
{
result: "again",
label: "重来",
description: "完全不会,明天再复习",
icon: RotateCcw,
variant: "destructive",
},
{
result: "hard",
label: "困难",
description: "勉强答对2 天后复习",
icon: Zap,
variant: "secondary",
},
{
result: "good",
label: "良好",
description: "正常答对4 天后复习",
icon: ThumbsUp,
variant: "default",
},
{
result: "easy",
label: "简单",
description: "轻松答对7 天后复习",
icon: Check,
variant: "outline",
},
]
}
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
const t = useTranslations("error-book")
const [isPending, startTransition] = useTransition()
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
const reviewOptions: ReviewOption[] = [
{
result: "again",
label: t("review.again"),
description: t("review.againDesc"),
icon: RotateCcw,
variant: "destructive",
},
{
result: "hard",
label: t("review.hard"),
description: t("review.hardDesc"),
icon: Zap,
variant: "secondary",
},
{
result: "good",
label: t("review.good"),
description: t("review.goodDesc"),
icon: ThumbsUp,
variant: "default",
},
{
result: "easy",
label: t("review.easy"),
description: t("review.easyDesc"),
icon: Check,
variant: "outline",
},
]
function handleReview(result: ErrorBookReviewResultValue) {
setSelected(result)
startTransition(async () => {
@@ -61,10 +65,10 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
formData.append("json", JSON.stringify({ itemId, result }))
const res = await reviewErrorBookItemAction(undefined, formData)
if (res.success) {
toast.success(res.message ?? "复习结果已记录")
toast.success(res.message ?? t("messages.reviewRecorded"))
onReviewed?.()
} else {
toast.error(res.message ?? "记录失败")
toast.error(res.message ?? t("messages.recordFailed"))
setSelected(null)
}
})
@@ -72,7 +76,7 @@ export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{REVIEW_OPTIONS.map((opt) => {
{reviewOptions.map((opt) => {
const Icon = opt.icon
const isLoading = isPending && selected === opt.result
return (

View File

@@ -1,6 +1,7 @@
"use client"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Cell } from "recharts"
import { useTranslations } from "next-intl"
import {
ChartContainer,
ChartTooltip,
@@ -17,6 +18,25 @@ interface SubjectDistributionChartProps {
className?: string
}
interface SubjectDistChartPayload {
name: string
errorCount: number
masteredCount: number
masteryRate: number
}
function isSubjectDistChartPayload(v: unknown): v is SubjectDistChartPayload {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换:类型守卫内需要属性访问来校验字段类型
const obj = v as Record<string, unknown>
return (
typeof obj.name === "string" &&
typeof obj.errorCount === "number" &&
typeof obj.masteredCount === "number" &&
typeof obj.masteryRate === "number"
)
}
const SUBJECT_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
@@ -34,6 +54,8 @@ export function SubjectDistributionChart({
data,
className,
}: SubjectDistributionChartProps) {
const t = useTranslations("error-book")
if (data.length === 0) return null
const chartData = data.map((d) => ({
@@ -45,7 +67,7 @@ export function SubjectDistributionChart({
const chartConfig: ChartConfig = {
errorCount: {
label: "错题数",
label: t("subjectDistChart.errorCount"),
color: "var(--color-chart-1)",
},
}
@@ -53,10 +75,14 @@ export function SubjectDistributionChart({
return (
<Card className={cn("overflow-hidden", className)}>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("subjectDistChart.title")}</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[280px] w-full">
<ChartContainer
config={chartConfig}
className="h-[280px] w-full"
aria-label={t("subjectDistChart.title")}
>
<BarChart data={chartData} margin={{ left: 8, right: 8, top: 8, bottom: 8 }}>
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
<XAxis
@@ -74,23 +100,21 @@ export function SubjectDistributionChart({
<ChartTooltipContent
className="w-[200px]"
formatter={(payload: unknown) => {
const p = payload as unknown as {
name: string
errorCount: number
masteredCount: number
masteryRate: number
}
if (!isSubjectDistChartPayload(payload)) return null
return (
<div className="space-y-1.5">
<div className="font-medium">{p.name}</div>
<div className="font-medium">{payload.name}</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.errorCount}</span>
{t("subjectDistChart.errorCount")}
<span className="font-medium text-foreground">{payload.errorCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-emerald-600">{p.masteredCount}</span>
{t("subjectDistChart.masteredLabel")}
<span className="font-medium text-emerald-600">{payload.masteredCount}</span>
</div>
<div className="text-muted-foreground">
<span className="font-medium text-foreground">{p.masteryRate}%</span>
{t("subjectDistChart.masteryRateLabel")}
<span className="font-medium text-foreground">{payload.masteryRate}%</span>
</div>
</div>
)

View File

@@ -1,6 +1,7 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge"
@@ -25,6 +26,7 @@ export function SubjectTabs({
}: SubjectTabsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations("error-book")
const handleSelect = (subjectId: string | null) => {
const params = new URLSearchParams(searchParams.toString())
@@ -44,6 +46,8 @@ export function SubjectTabs({
<div className="flex flex-wrap gap-2">
<button
type="button"
role="tab"
aria-selected={currentSubjectId === null}
onClick={() => handleSelect(null)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
@@ -52,7 +56,7 @@ export function SubjectTabs({
: "bg-card hover:bg-muted"
)}
>
<span className="font-medium"></span>
<span className="font-medium">{t("subjectTabs.all")}</span>
<Badge
variant={currentSubjectId === null ? "secondary" : "outline"}
className="text-xs"
@@ -66,6 +70,8 @@ export function SubjectTabs({
<button
key={subject.subjectId}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => handleSelect(subject.subjectId)}
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition-colors",
@@ -88,7 +94,7 @@ export function SubjectTabs({
isActive ? "text-primary-foreground/80" : "text-rose-600"
)}
>
{subject.dueReviewCount}
{t("subjectTabs.dueReview", { count: subject.dueReviewCount })}
</span>
) : null}
</button>

View File

@@ -1,8 +1,12 @@
"use client"
import { Flame } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { extractQuestionPreview } from "@/shared/lib/question-content"
interface TopWrongQuestion {
questionId: string
@@ -16,45 +20,36 @@ interface TopWrongQuestionsProps {
questions: TopWrongQuestion[]
}
function extractPreview(content: unknown): string {
if (typeof content === "string") return content.slice(0, 120)
if (Array.isArray(content)) {
const texts: string[] = []
for (const node of content) {
if (typeof node === "string") texts.push(node)
else if (typeof node === "object" && node !== null) {
const n = node as Record<string, unknown>
if (typeof n.text === "string") texts.push(n.text)
}
}
return texts.join("").slice(0, 120)
}
return "题目内容"
}
const KNOWN_QUESTION_TYPES = [
"single_choice",
"multiple_choice",
"judgment",
"text",
"composite",
] as const
type KnownQuestionType = (typeof KNOWN_QUESTION_TYPES)[number]
const QUESTION_TYPE_LABEL: Record<string, string> = {
single_choice: "单选",
multiple_choice: "多选",
judgment: "判断",
text: "简答",
composite: "复合",
function isKnownQuestionType(v: string): v is KnownQuestionType {
return (KNOWN_QUESTION_TYPES as readonly string[]).includes(v)
}
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
const t = useTranslations("error-book")
if (questions.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" />
{t("topWrong.title")}
</CardTitle>
</CardHeader>
<CardContent>
<EmptyState
icon={Flame}
title="暂无高频错题"
description="学生完成作业或考试后,错频统计会显示在这里。"
title={t("topWrong.emptyTitle")}
description={t("topWrong.emptyDesc")}
className="h-[200px]"
/>
</CardContent>
@@ -67,7 +62,7 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" />
Top 10
{t("topWrong.topTitle")}
</CardTitle>
</CardHeader>
<CardContent>
@@ -84,19 +79,21 @@ export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
</Badge>
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm line-clamp-2">
{extractPreview(q.questionContent)}
{extractQuestionPreview(q.questionContent, t("itemCard.questionContent"), 120)}
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-xs">
{QUESTION_TYPE_LABEL[q.questionType] ?? q.questionType}
{isKnownQuestionType(q.questionType)
? t(`questionType.${q.questionType}`)
: q.questionType}
</Badge>
<span>{q.errorCount} </span>
<span>{t("topWrong.errorCount", { count: q.errorCount })}</span>
<span>·</span>
<span className="text-emerald-600 dark:text-emerald-400">
{q.masteredCount}
{t("topWrong.masteredCount", { count: q.masteredCount })}
</span>
<span>·</span>
<span> {Math.round(masteryRate * 100)}%</span>
<span>{t("topWrong.masteryRate", { rate: Math.round(masteryRate * 100) })}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,552 @@
import "server-only"
import { count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
errorBookItems,
questions,
knowledgePoints,
chapters,
subjects,
users,
classEnrollments,
classes,
} from "@/shared/db/schema"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { ROLE_NAMES } from "@/shared/types/permissions"
import type {
ChapterWeakness,
ClassErrorOverview,
KnowledgePointWeakness,
StudentErrorBookSummary,
SubjectErrorOverview,
} from "./types"
import { buildStudentErrorWhereClause, toStatus, toStringArray } from "./data-access"
// ---------------------------------------------------------------------------
// 教师/管理员分析查询SQL 聚合优化)
// ---------------------------------------------------------------------------
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
studentIds: string[],
subjectId?: string | null
): Promise<StudentErrorBookSummary[]> {
if (studentIds.length === 0) return []
const now = new Date()
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 使用 SQL 条件聚合GROUP BY studentId
const rows = await db
.select({
studentId: errorBookItems.studentId,
totalCount: count(),
newCount: sql<number>`sum(case when ${errorBookItems.status} = 'new' then 1 else 0 end)`,
learningCount: sql<number>`sum(case when ${errorBookItems.status} = 'learning' then 1 else 0 end)`,
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
lastActivityAt: sql<Date | null>`max(${errorBookItems.updatedAt})`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.studentId)
// 查询学生所属班级(用于按班级分组展示)
const enrollmentRows = await db
.select({
studentId: classEnrollments.studentId,
classId: classes.id,
className: classes.name,
})
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.studentId, studentIds))
const studentClassMap = new Map<string, { classId: string; className: string }>()
for (const row of enrollmentRows) {
// 取第一个班级(学生通常只属于一个班)
if (!studentClassMap.has(row.studentId)) {
studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
}
}
return rows.map((row) => {
const totalCount = Number(row.totalCount)
const masteredCount = Number(row.masteredCount)
const classInfo = studentClassMap.get(row.studentId)
return {
studentId: row.studentId,
totalCount,
newCount: Number(row.newCount),
learningCount: Number(row.learningCount),
masteredCount,
dueReviewCount: Number(row.dueReviewCount),
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
lastActivityAt: row.lastActivityAt,
classId: classInfo?.classId ?? null,
className: classInfo?.className ?? null,
}
})
}
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<Array<{
questionId: string
questionContent: unknown
questionType: string
errorCount: number
masteredCount: number
}>> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 使用子查询聚合,避免 GROUP BY JSON 列的问题
const aggregatedSubquery = db
.select({
questionId: errorBookItems.questionId,
errorCount: count(),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.questionId)
.as("aggregated")
const rows = await db
.select({
questionId: aggregatedSubquery.questionId,
errorCount: aggregatedSubquery.errorCount,
masteredCount: aggregatedSubquery.masteredCount,
content: questions.content,
type: questions.type,
})
.from(aggregatedSubquery)
.innerJoin(questions, eq(questions.id, aggregatedSubquery.questionId))
.orderBy(desc(aggregatedSubquery.errorCount))
.limit(limit)
return rows.map((row) => ({
questionId: row.questionId,
questionContent: row.content,
questionType: row.type,
errorCount: Number(row.errorCount),
masteredCount: Number(row.masteredCount),
}))
}
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<KnowledgePointWeakness[]> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY保留 JS 聚合
// 仅查询必要字段(不查询全行)
const rows = await db
.select({
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点并统计(使用类型守卫替代 as 断言)
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = toStringArray(row.knowledgePointIds) ?? []
for (const kpId of kps) {
const stat = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpMap.set(kpId, stat)
}
}
if (kpMap.size === 0) return []
// 查询知识点名称及所属章节
const kpIds = Array.from(kpMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
const kpInfoMap = new Map(kpRows.map((k) => [k.id, { name: k.name, chapterId: k.chapterId }]))
// 查询章节标题
const chapterIds = Array.from(new Set(
kpRows.map((k) => k.chapterId).filter((c): c is string => c !== null)
))
let chapterTitleMap = new Map<string, string>()
if (chapterIds.length > 0) {
const chapterRows = await db
.select({ id: chapters.id, title: chapters.title })
.from(chapters)
.where(inArray(chapters.id, chapterIds))
chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
}
return Array.from(kpMap.entries())
.map(([kpId, stat]) => {
const info = kpInfoMap.get(kpId)
return {
knowledgePointId: kpId,
knowledgePointName: info?.name ?? "未知知识点",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
totalCount: stat.errorCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
chapterId: info?.chapterId ?? null,
chapterTitle: info?.chapterId ? (chapterTitleMap.get(info.chapterId) ?? null) : null,
}
})
.sort((a, b) => {
// 按错误数降序,掌握率升序(最薄弱的在前)
if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
return a.masteryRate - b.masteryRate
})
.slice(0, limit)
}
/** 查询多个学生的学科错题分布 */
export async function getSubjectErrorDistribution(
studentIds: string[]
): Promise<Array<{
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}>> {
if (studentIds.length === 0) return []
// 使用 SQL GROUP BY 聚合
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
errorCount: count(),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
.groupBy(errorBookItems.subjectId)
// 查询学科名称
const subjectIds = rows
.map((r) => r.subjectId)
.filter((s): s is string => s !== null)
let subjectNameMap = new Map<string, string>()
if (subjectIds.length > 0) {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
}
return rows.map((row) => {
const errorCount = Number(row.errorCount)
const masteredCount = Number(row.masteredCount)
const sid = row.subjectId
return {
subjectId: sid,
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
errorCount,
masteredCount,
masteryRate: errorCount > 0 ? masteredCount / errorCount : 0,
}
})
}
/**
* 查询章节薄弱度统计(哪些课在错)。
* 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
* 支持按学科过滤(通过 errorBookItems.subjectId
*/
export async function getChapterWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<ChapterWeakness[]> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// knowledgePointIds 是 JSON 数组字段,无法直接用 SQL GROUP BY保留 JS 聚合
// 仅查询必要字段(不查询全行)
const rows = await db
.select({
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点,建立 kpId → 错题统计(使用类型守卫替代 as 断言)
const kpErrorMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = toStringArray(row.knowledgePointIds) ?? []
for (const kpId of kps) {
const stat = kpErrorMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpErrorMap.set(kpId, stat)
}
}
if (kpErrorMap.size === 0) return []
// 查询知识点 → 章节映射
const kpIds = Array.from(kpErrorMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
// 按章节聚合
const chapterMap = new Map<string, {
errorCount: number
masteredCount: number
knowledgePointCount: number
topKps: Array<{ knowledgePointId: string; knowledgePointName: string; errorCount: number }>
}>()
for (const kp of kpRows) {
if (!kp.chapterId) continue
const kpStat = kpErrorMap.get(kp.id)
if (!kpStat) continue
const chapterStat = chapterMap.get(kp.chapterId) ?? {
errorCount: 0,
masteredCount: 0,
knowledgePointCount: 0,
topKps: [],
}
chapterStat.errorCount += kpStat.errorCount
chapterStat.masteredCount += kpStat.masteredCount
chapterStat.knowledgePointCount++
chapterStat.topKps.push({
knowledgePointId: kp.id,
knowledgePointName: kp.name,
errorCount: kpStat.errorCount,
})
chapterMap.set(kp.chapterId, chapterStat)
}
if (chapterMap.size === 0) return []
// 查询章节标题
const chapterIds = Array.from(chapterMap.keys())
const chapterRows = await db
.select({ id: chapters.id, title: chapters.title })
.from(chapters)
.where(inArray(chapters.id, chapterIds))
const chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
return Array.from(chapterMap.entries())
.map(([chapterId, stat]) => ({
chapterId,
chapterTitle: chapterTitleMap.get(chapterId) ?? "未知章节",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
knowledgePointCount: stat.knowledgePointCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
topKnowledgePoints: stat.topKps
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, 3),
}))
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, limit)
}
/**
* 查询按班级分组的错题概览(教师视图:分班显示)。
* 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
* 支持按学科过滤。
*/
export async function getClassErrorOverviews(
classIds: string[],
subjectId?: string | null
): Promise<ClassErrorOverview[]> {
if (classIds.length === 0) return []
// 查询每个班级的学生
const enrollmentRows = await db
.select({
classId: classEnrollments.classId,
studentId: classEnrollments.studentId,
className: classes.name,
})
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.classId, classIds))
const classStudentMap = new Map<string, { className: string; studentIds: Set<string> }>()
for (const row of enrollmentRows) {
const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set<string>() }
entry.studentIds.add(row.studentId)
classStudentMap.set(row.classId, entry)
}
// 查询所有相关学生的错题(使用 SQL 聚合按 studentId 分组)
const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
if (allStudentIds.length === 0) return []
const now = new Date()
const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
const errorRows = await db
.select({
studentId: errorBookItems.studentId,
total: count(),
mastered: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
due: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
})
.from(errorBookItems)
.where(whereClause)
.groupBy(errorBookItems.studentId)
// 按学生聚合结果映射
const studentStatMap = new Map<string, { total: number; mastered: number; due: number }>()
for (const row of errorRows) {
studentStatMap.set(row.studentId, {
total: Number(row.total),
mastered: Number(row.mastered),
due: Number(row.due),
})
}
// 按班级聚合
return Array.from(classStudentMap.entries()).map(([classId, entry]) => {
const studentIds = Array.from(entry.studentIds)
let totalError = 0
let totalMastered = 0
let totalDue = 0
for (const sid of studentIds) {
const stat = studentStatMap.get(sid)
if (stat) {
totalError += stat.total
totalMastered += stat.mastered
totalDue += stat.due
}
}
return {
classId,
className: entry.className,
studentCount: studentIds.length,
totalErrorItems: totalError,
averageErrorPerStudent: studentIds.length > 0 ? totalError / studentIds.length : 0,
averageMasteryRate: totalError > 0 ? totalMastered / totalError : 0,
dueReviewCount: totalDue,
}
}).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
/**
* 查询按学科分组的错题概览(用于学科 Tab 展示)。
* 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
*/
export async function getSubjectErrorOverviews(
studentIds: string[]
): Promise<SubjectErrorOverview[]> {
if (studentIds.length === 0) return []
const now = new Date()
// 使用 SQL GROUP BY 聚合(含 count distinct studentId
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
totalErrorItems: count(),
studentCount: sql<number>`count(distinct ${errorBookItems.studentId})`,
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`,
dueReviewCount: sql<number>`sum(case when ${errorBookItems.status} not in ('mastered', 'archived') and (${errorBookItems.nextReviewAt} is null or ${errorBookItems.nextReviewAt} <= ${now}) then 1 else 0 end)`,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
.groupBy(errorBookItems.subjectId)
// 过滤无学科的错题
const filtered = rows.filter((r) => r.subjectId !== null)
if (filtered.length === 0) return []
// 查询学科名称
const subjectIds = filtered.map((r) => r.subjectId as string)
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
return filtered
.map((row) => {
const totalErrorItems = Number(row.totalErrorItems)
const masteredCount = Number(row.masteredCount)
const sid = row.subjectId as string
return {
subjectId: sid,
subjectName: subjectNameMap.get(sid) ?? "未知学科",
totalErrorItems,
studentCount: Number(row.studentCount),
averageMasteryRate: totalErrorItems > 0 ? masteredCount / totalErrorItems : 0,
dueReviewCount: Number(row.dueReviewCount),
}
})
.sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
/** 查询学生姓名映射 */
export async function getStudentNameMap(studentIds: string[]): Promise<Map<string, string>> {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, studentIds))
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
return await getStudentIdsByClassIds(classIds)
}
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
const { usersToRoles, roles } = await import("@/shared/db/schema")
const studentRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, ROLE_NAMES.STUDENT))
.limit(1)
if (studentRole.length === 0) return []
const userRoleRows = await db
.select({ userId: usersToRoles.userId })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, studentRole[0].id))
return userRoleRows.map((r) => r.userId)
}

View File

@@ -1,23 +1,15 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or, sql, type SQL } from "drizzle-orm"
import { and, count, desc, eq, inArray, isNull, lte, not, or, sql, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
errorBookItems,
errorBookReviews,
questions,
questionsToKnowledgePoints,
knowledgePoints,
chapters,
subjects,
users,
classEnrollments,
classes,
} from "@/shared/db/schema"
import { getStudentIdsByClassIds } from "@/modules/classes/data-access"
import {
calculateNewInterval,
calculateNewMastery,
@@ -34,18 +26,9 @@ import type {
ErrorBookStats,
ErrorBookStatusValue,
GetErrorBookItemsParams,
KnowledgePointWeakness,
ChapterWeakness,
ClassErrorOverview,
SubjectErrorOverview,
StudentErrorBookSummary,
} from "./types"
import type { ErrorBookReviewResult } from "./schema"
// ---------------------------------------------------------------------------
// SM-2 间隔重复算法(简化版)
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// 类型守卫
// ---------------------------------------------------------------------------
@@ -59,27 +42,41 @@ const toReviewResult = (v: string | null | undefined): ErrorBookReviewResult =>
const isStatus = (v: unknown): v is ErrorBookStatusValue =>
v === "new" || v === "learning" || v === "mastered" || v === "archived"
const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
/** 将数据库状态字符串转换为 ErrorBookStatusValue,无效值回退为 "new" */
export const toStatus = (v: string | null | undefined): ErrorBookStatusValue =>
isStatus(v) ? v : "new"
/** 类型守卫:判断未知值是否为 string[] */
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every((item) => typeof item === "string")
}
/** 将未知值安全转换为 string[] | null用于 JSON 列字段) */
export function toStringArray(v: unknown): string[] | null {
return isStringArray(v) ? v : null
}
// ---------------------------------------------------------------------------
// 行映射
// ---------------------------------------------------------------------------
function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
question?: typeof questions.$inferSelect | null
subject?: typeof subjects.$inferSelect | null
}): ErrorBookItem {
/** 错题条目查询结果类型(含关联的 question/subject 字段) */
type ErrorBookItemWithRelations = typeof errorBookItems.$inferSelect & {
question?: { id: string; content: unknown; type: string; difficulty: number | null } | null
subject?: { id: string; name: string } | null
}
function mapRowToItem(row: ErrorBookItemWithRelations): ErrorBookItem {
return {
id: row.id,
studentId: row.studentId,
questionId: row.questionId,
sourceType: row.sourceType as ErrorBookItem["sourceType"],
sourceType: row.sourceType,
sourceId: row.sourceId,
studentAnswer: row.studentAnswer,
correctAnswer: row.correctAnswer,
subjectId: row.subjectId,
knowledgePointIds: row.knowledgePointIds as string[] | null,
knowledgePointIds: toStringArray(row.knowledgePointIds),
status: toStatus(row.status),
masteryLevel: row.masteryLevel,
nextReviewAt: row.nextReviewAt,
@@ -87,7 +84,7 @@ function mapRowToItem(row: typeof errorBookItems.$inferSelect & {
reviewCount: row.reviewCount,
correctStreak: row.correctStreak,
note: row.note,
errorTags: row.errorTags as string[] | null,
errorTags: toStringArray(row.errorTags),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
question: row.question
@@ -126,12 +123,13 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
if (dueOnly) {
const now = new Date()
conditions.push(
or(
isNull(errorBookItems.nextReviewAt),
lte(errorBookItems.nextReviewAt, now)
)!
const dueCondition = or(
isNull(errorBookItems.nextReviewAt),
lte(errorBookItems.nextReviewAt, now)
)
if (dueCondition) {
conditions.push(dueCondition)
}
}
if (q && q.trim().length > 0) {
@@ -172,7 +170,7 @@ export const getErrorBookItems = cache(async (params: GetErrorBookItemsParams):
})
return {
data: rows.map((row) => mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])),
data: rows.map((row) => mapRowToItem(row)),
meta: {
page,
pageSize,
@@ -219,7 +217,7 @@ export const getErrorBookItemById = cache(async (
if (!row) return null
const base = mapRowToItem(row as unknown as Parameters<typeof mapRowToItem>[0])
const base = mapRowToItem(row)
const reviews: ErrorBookReviewRecord[] = (row.reviews ?? []).map((r) => ({
id: r.id,
result: toReviewResult(r.result),
@@ -232,49 +230,65 @@ export const getErrorBookItemById = cache(async (
})
// ---------------------------------------------------------------------------
// 查询:错题本统计
// 查询:错题本统计SQL 聚合优化)
// ---------------------------------------------------------------------------
export const getErrorBookStats = cache(async (studentId: string): Promise<ErrorBookStats> => {
const now = new Date()
const rows = await db
// 使用 SQL GROUP BY 聚合状态计数
const statusCounts = await db
.select({
status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt,
count: count(),
})
.from(errorBookItems)
.where(eq(errorBookItems.studentId, studentId))
.groupBy(errorBookItems.status)
const total = rows.length
let totalCount = 0
let newCount = 0
let learningCount = 0
let masteredCount = 0
let archivedCount = 0
let dueReviewCount = 0
for (const row of rows) {
for (const row of statusCounts) {
const status = toStatus(row.status)
if (status === "new") newCount++
else if (status === "learning") learningCount++
else if (status === "mastered") masteredCount++
else if (status === "archived") archivedCount++
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) {
dueReviewCount++
}
}
totalCount += row.count
if (status === "new") newCount = row.count
else if (status === "learning") learningCount = row.count
else if (status === "mastered") masteredCount = row.count
else if (status === "archived") archivedCount = row.count
}
// 待复习数单独查询status NOT IN mastered/archived AND due
const dueConditions: SQL[] = [
eq(errorBookItems.studentId, studentId),
not(inArray(errorBookItems.status, ["mastered", "archived"])),
]
const dueCondition = or(
isNull(errorBookItems.nextReviewAt),
lte(errorBookItems.nextReviewAt, now)
)
if (dueCondition) {
dueConditions.push(dueCondition)
}
const dueReviewResult = await db
.select({ value: count() })
.from(errorBookItems)
.where(and(...dueConditions))
const dueReviewCount = Number(dueReviewResult[0]?.value ?? 0)
return {
totalCount: total,
totalCount,
newCount,
learningCount,
masteredCount,
archivedCount,
dueReviewCount,
masteredRate: total > 0 ? masteredCount / total : 0,
masteredRate: totalCount > 0 ? masteredCount / totalCount : 0,
}
})
@@ -448,11 +462,11 @@ export {
} from "./data-access-collection"
// ---------------------------------------------------------------------------
// 跨模块查询接口:供教师/家长视图使用
// 共享工具函数:供 data-access-analytics.ts 使用
// ---------------------------------------------------------------------------
/** 构建学生错题查询的 where 条件(支持按学科过滤) */
function buildStudentErrorWhereClause(
export function buildStudentErrorWhereClause(
studentIds: string[],
subjectId?: string | null
): SQL | undefined {
@@ -462,568 +476,3 @@ function buildStudentErrorWhereClause(
}
return and(...conditions)
}
/** 查询多个学生的错题统计(教师视图,支持按学科过滤) */
export async function getStudentErrorBookSummaries(
studentIds: string[],
subjectId?: string | null
): Promise<StudentErrorBookSummary[]> {
if (studentIds.length === 0) return []
const now = new Date()
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
const rows = await db
.select({
studentId: errorBookItems.studentId,
status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt,
updatedAt: errorBookItems.updatedAt,
})
.from(errorBookItems)
.where(whereClause)
// 查询学生所属班级(用于按班级分组展示)
const enrollmentRows = await db
.select({
studentId: classEnrollments.studentId,
classId: classes.id,
className: classes.name,
})
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.studentId, studentIds))
const studentClassMap = new Map<string, { classId: string; className: string }>()
for (const row of enrollmentRows) {
// 取第一个班级(学生通常只属于一个班)
if (!studentClassMap.has(row.studentId)) {
studentClassMap.set(row.studentId, { classId: row.classId, className: row.className })
}
}
const map = new Map<string, {
totalCount: number
newCount: number
learningCount: number
masteredCount: number
dueReviewCount: number
lastActivityAt: Date | null
}>()
for (const row of rows) {
const stat = map.get(row.studentId) ?? {
totalCount: 0,
newCount: 0,
learningCount: 0,
masteredCount: 0,
dueReviewCount: 0,
lastActivityAt: null,
}
stat.totalCount++
const status = toStatus(row.status)
if (status === "new") stat.newCount++
else if (status === "learning") stat.learningCount++
else if (status === "mastered") stat.masteredCount++
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) {
stat.dueReviewCount++
}
}
if (!stat.lastActivityAt || row.updatedAt > stat.lastActivityAt) {
stat.lastActivityAt = row.updatedAt
}
map.set(row.studentId, stat)
}
return Array.from(map.entries()).map(([studentId, stat]) => {
const classInfo = studentClassMap.get(studentId)
return {
studentId,
...stat,
masteredRate: stat.totalCount > 0 ? stat.masteredCount / stat.totalCount : 0,
classId: classInfo?.classId ?? null,
className: classInfo?.className ?? null,
}
})
}
/** 查询班级内错题最多的题目(教师视图:高频错题,支持按学科过滤) */
export async function getTopWrongQuestionsByStudentIds(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<Array<{
questionId: string
questionContent: unknown
questionType: string
errorCount: number
masteredCount: number
}>> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
const rows = await db
.select({
questionId: errorBookItems.questionId,
status: errorBookItems.status,
content: questions.content,
type: questions.type,
})
.from(errorBookItems)
.innerJoin(questions, eq(questions.id, errorBookItems.questionId))
.where(whereClause)
const map = new Map<string, { questionContent: unknown; questionType: string; errorCount: number; masteredCount: number }>()
for (const row of rows) {
const stat = map.get(row.questionId) ?? {
questionContent: row.content,
questionType: row.type,
errorCount: 0,
masteredCount: 0,
}
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
map.set(row.questionId, stat)
}
return Array.from(map.entries())
.map(([questionId, stat]) => ({ questionId, ...stat }))
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, limit)
}
/** 按班级 ID 查询学生 ID 列表(委托给 classes 模块) */
export async function getStudentIdsByClassIdList(classIds: string[]): Promise<string[]> {
return await getStudentIdsByClassIds(classIds)
}
/**
* 查询所有学生用户 ID管理员视图
* 通过 usersToRoles + roles 表关联查询 role === "student" 的用户。
* 此函数封装了 DB 访问,避免 app 层直接查询 DB遵循三层架构
*/
export async function getAllStudentIds(): Promise<string[]> {
const { usersToRoles, roles } = await import("@/shared/db/schema")
const studentRole = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "student"))
.limit(1)
if (studentRole.length === 0) return []
const userRoleRows = await db
.select({ userId: usersToRoles.userId })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, studentRole[0].id))
return userRoleRows.map((r) => r.userId)
}
// ---------------------------------------------------------------------------
// 统计:知识点薄弱度 & 学科分布 & 章节维度(教师/管理员视图)
// ---------------------------------------------------------------------------
/** 查询多个学生的知识点薄弱度统计(支持按学科过滤,关联章节信息) */
export async function getKnowledgePointWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<KnowledgePointWeakness[]> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 查询这些学生的所有错题条目(含知识点)
const rows = await db
.select({
itemId: errorBookItems.id,
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点并统计
const kpMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = (row.knowledgePointIds as string[] | null) ?? []
for (const kpId of kps) {
const stat = kpMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpMap.set(kpId, stat)
}
}
if (kpMap.size === 0) return []
// 查询知识点名称及所属章节
const kpIds = Array.from(kpMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
const kpInfoMap = new Map(kpRows.map((k) => [k.id, { name: k.name, chapterId: k.chapterId }]))
// 查询章节标题
const chapterIds = Array.from(new Set(
kpRows.map((k) => k.chapterId).filter((c): c is string => c !== null)
))
let chapterTitleMap = new Map<string, string>()
if (chapterIds.length > 0) {
const chapterRows = await db
.select({ id: chapters.id, title: chapters.title })
.from(chapters)
.where(inArray(chapters.id, chapterIds))
chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
}
return Array.from(kpMap.entries())
.map(([kpId, stat]) => {
const info = kpInfoMap.get(kpId)
return {
knowledgePointId: kpId,
knowledgePointName: info?.name ?? "未知知识点",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
totalCount: stat.errorCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
chapterId: info?.chapterId ?? null,
chapterTitle: info?.chapterId ? (chapterTitleMap.get(info.chapterId) ?? null) : null,
}
})
.sort((a, b) => {
// 按错误数降序,掌握率升序(最薄弱的在前)
if (b.errorCount !== a.errorCount) return b.errorCount - a.errorCount
return a.masteryRate - b.masteryRate
})
.slice(0, limit)
}
/** 查询多个学生的学科错题分布 */
export async function getSubjectErrorDistribution(
studentIds: string[]
): Promise<Array<{
subjectId: string | null
subjectName: string
errorCount: number
masteredCount: number
masteryRate: number
}>> {
if (studentIds.length === 0) return []
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
status: errorBookItems.status,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
const subjectMap = new Map<string | null, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const key = row.subjectId
const stat = subjectMap.get(key) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
subjectMap.set(key, stat)
}
// 查询学科名称
const subjectIds = Array.from(subjectMap.keys()).filter((k): k is string => k !== null)
let subjectNameMap = new Map<string, string>()
if (subjectIds.length > 0) {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
}
return Array.from(subjectMap.entries()).map(([sid, stat]) => ({
subjectId: sid,
subjectName: sid ? (subjectNameMap.get(sid) ?? "未知学科") : "未分类",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
}))
}
/**
* 查询章节薄弱度统计(哪些课在错)。
* 通过 errorBookItems.knowledgePointIds → knowledgePoints.chapterId → chapters 关联。
* 支持按学科过滤(通过 errorBookItems.subjectId
*/
export async function getChapterWeakness(
studentIds: string[],
limit = 10,
subjectId?: string | null
): Promise<ChapterWeakness[]> {
if (studentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 查询错题条目(含知识点和状态)
const rows = await db
.select({
itemId: errorBookItems.id,
status: errorBookItems.status,
knowledgePointIds: errorBookItems.knowledgePointIds,
})
.from(errorBookItems)
.where(whereClause)
// 展开知识点,建立 kpId → 错题统计
const kpErrorMap = new Map<string, { errorCount: number; masteredCount: number }>()
for (const row of rows) {
const kps = (row.knowledgePointIds as string[] | null) ?? []
for (const kpId of kps) {
const stat = kpErrorMap.get(kpId) ?? { errorCount: 0, masteredCount: 0 }
stat.errorCount++
if (toStatus(row.status) === "mastered") stat.masteredCount++
kpErrorMap.set(kpId, stat)
}
}
if (kpErrorMap.size === 0) return []
// 查询知识点 → 章节映射
const kpIds = Array.from(kpErrorMap.keys())
const kpRows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: knowledgePoints.chapterId,
})
.from(knowledgePoints)
.where(inArray(knowledgePoints.id, kpIds))
// 按章节聚合
const chapterMap = new Map<string, {
errorCount: number
masteredCount: number
knowledgePointCount: number
topKps: Array<{ knowledgePointId: string; knowledgePointName: string; errorCount: number }>
}>()
for (const kp of kpRows) {
if (!kp.chapterId) continue
const kpStat = kpErrorMap.get(kp.id)
if (!kpStat) continue
const chapterStat = chapterMap.get(kp.chapterId) ?? {
errorCount: 0,
masteredCount: 0,
knowledgePointCount: 0,
topKps: [],
}
chapterStat.errorCount += kpStat.errorCount
chapterStat.masteredCount += kpStat.masteredCount
chapterStat.knowledgePointCount++
chapterStat.topKps.push({
knowledgePointId: kp.id,
knowledgePointName: kp.name,
errorCount: kpStat.errorCount,
})
chapterMap.set(kp.chapterId, chapterStat)
}
if (chapterMap.size === 0) return []
// 查询章节标题
const chapterIds = Array.from(chapterMap.keys())
const chapterRows = await db
.select({ id: chapters.id, title: chapters.title })
.from(chapters)
.where(inArray(chapters.id, chapterIds))
const chapterTitleMap = new Map(chapterRows.map((c) => [c.id, c.title]))
return Array.from(chapterMap.entries())
.map(([chapterId, stat]) => ({
chapterId,
chapterTitle: chapterTitleMap.get(chapterId) ?? "未知章节",
errorCount: stat.errorCount,
masteredCount: stat.masteredCount,
knowledgePointCount: stat.knowledgePointCount,
masteryRate: stat.errorCount > 0 ? stat.masteredCount / stat.errorCount : 0,
topKnowledgePoints: stat.topKps
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, 3),
}))
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, limit)
}
/**
* 查询按班级分组的错题概览(教师视图:分班显示)。
* 对每个班级统计:学生数、错题总数、人均错题数、平均掌握率、待复习数。
* 支持按学科过滤。
*/
export async function getClassErrorOverviews(
classIds: string[],
subjectId?: string | null
): Promise<ClassErrorOverview[]> {
if (classIds.length === 0) return []
// 查询每个班级的学生
const enrollmentRows = await db
.select({
classId: classEnrollments.classId,
studentId: classEnrollments.studentId,
className: classes.name,
})
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(inArray(classEnrollments.classId, classIds))
const classStudentMap = new Map<string, { className: string; studentIds: Set<string> }>()
for (const row of enrollmentRows) {
const entry = classStudentMap.get(row.classId) ?? { className: row.className, studentIds: new Set<string>() }
entry.studentIds.add(row.studentId)
classStudentMap.set(row.classId, entry)
}
// 查询所有相关学生的错题
const allStudentIds = Array.from(new Set(enrollmentRows.map((r) => r.studentId)))
if (allStudentIds.length === 0) return []
const whereClause = buildStudentErrorWhereClause(allStudentIds, subjectId)
const errorRows = await db
.select({
studentId: errorBookItems.studentId,
status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt,
})
.from(errorBookItems)
.where(whereClause)
const now = new Date()
// 按学生聚合
const studentStatMap = new Map<string, { total: number; mastered: number; due: number }>()
for (const row of errorRows) {
const stat = studentStatMap.get(row.studentId) ?? { total: 0, mastered: 0, due: 0 }
stat.total++
const status = toStatus(row.status)
if (status === "mastered") stat.mastered++
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) stat.due++
}
studentStatMap.set(row.studentId, stat)
}
// 按班级聚合
return Array.from(classStudentMap.entries()).map(([classId, entry]) => {
const studentIds = Array.from(entry.studentIds)
let totalError = 0
let totalMastered = 0
let totalDue = 0
for (const sid of studentIds) {
const stat = studentStatMap.get(sid)
if (stat) {
totalError += stat.total
totalMastered += stat.mastered
totalDue += stat.due
}
}
return {
classId,
className: entry.className,
studentCount: studentIds.length,
totalErrorItems: totalError,
averageErrorPerStudent: studentIds.length > 0 ? totalError / studentIds.length : 0,
averageMasteryRate: totalError > 0 ? totalMastered / totalError : 0,
dueReviewCount: totalDue,
}
}).sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
/**
* 查询按学科分组的错题概览(用于学科 Tab 展示)。
* 返回每个学科的错题总数、涉及学生数、平均掌握率、待复习数。
*/
export async function getSubjectErrorOverviews(
studentIds: string[]
): Promise<SubjectErrorOverview[]> {
if (studentIds.length === 0) return []
const rows = await db
.select({
subjectId: errorBookItems.subjectId,
studentId: errorBookItems.studentId,
status: errorBookItems.status,
nextReviewAt: errorBookItems.nextReviewAt,
})
.from(errorBookItems)
.where(inArray(errorBookItems.studentId, studentIds))
const now = new Date()
const subjectMap = new Map<string, {
totalErrorItems: number
masteredCount: number
dueReviewCount: number
studentSet: Set<string>
}>()
for (const row of rows) {
const key = row.subjectId
if (!key) continue // 跳过无学科的错题
const stat = subjectMap.get(key) ?? {
totalErrorItems: 0,
masteredCount: 0,
dueReviewCount: 0,
studentSet: new Set<string>(),
}
stat.totalErrorItems++
stat.studentSet.add(row.studentId)
const status = toStatus(row.status)
if (status === "mastered") stat.masteredCount++
if (status !== "mastered" && status !== "archived") {
if (!row.nextReviewAt || row.nextReviewAt <= now) stat.dueReviewCount++
}
subjectMap.set(key, stat)
}
if (subjectMap.size === 0) return []
// 查询学科名称
const subjectIds = Array.from(subjectMap.keys())
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const subjectNameMap = new Map(subjectRows.map((s) => [s.id, s.name]))
return Array.from(subjectMap.entries())
.map(([sid, stat]) => ({
subjectId: sid,
subjectName: subjectNameMap.get(sid) ?? "未知学科",
totalErrorItems: stat.totalErrorItems,
studentCount: stat.studentSet.size,
averageMasteryRate: stat.totalErrorItems > 0 ? stat.masteredCount / stat.totalErrorItems : 0,
dueReviewCount: stat.dueReviewCount,
}))
.sort((a, b) => b.totalErrorItems - a.totalErrorItems)
}
/** 查询学生姓名映射 */
export async function getStudentNameMap(studentIds: string[]): Promise<Map<string, string>> {
if (studentIds.length === 0) return new Map()
const rows = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, studentIds))
return new Map(rows.map((r) => [r.id, r.name ?? "未知"]))
}

View File

@@ -95,6 +95,8 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
items: [
{ title: "admin.userList", href: "/admin/users" },
{ title: "admin.importUsers", href: "/admin/users/import", permission: Permissions.USER_MANAGE },
{ title: "admin.roles", href: "/admin/roles", permission: Permissions.ROLE_READ },
{ title: "admin.permissions", href: "/admin/permissions", permission: Permissions.PERMISSION_READ },
]
},
{

View File

@@ -2,15 +2,12 @@
import { revalidatePath } from "next/cache"
import { z } from "zod"
import { eq } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { parseExcel } from "@/shared/lib/excel"
import { formatDateForFile } from "@/shared/lib/utils"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
batchImportUsers,
@@ -19,7 +16,7 @@ import {
parseUserImportData,
type UserImportResult,
} from "./import-export"
import { updateUserProfileById, type UpdateUserProfileInput } from "./data-access"
import { deleteUserById, updateUserProfileById, type UpdateUserProfileInput } from "./data-access"
/** Zod schema for self-service profile update (P0-13) */
const UpdateUserProfileSchema = z.object({
@@ -171,41 +168,18 @@ export async function exportUsersAction(
}
}
/**
* 更新用户角色(管理员)
*/
export async function updateUserRoleAction(
prevState: ActionState<unknown>,
formData: FormData
): Promise<ActionState<unknown>> {
try {
await requirePermission(Permissions.USER_MANAGE)
const userId = formData.get("userId") as string
const role = formData.get("role") as string
// 简化实现:更新用户角色
void userId
void role
return { success: true, message: "用户角色已更新" }
} 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: "更新用户角色失败" }
}
}
/**
* 删除用户(管理员)
*
* P0-2 修复DB 操作下沉到 data-access.deleteUserById包含最后管理员保护。
* P0-3 修复:客户端通过 Server Action 调用,不再用 fetch。
*/
export async function deleteUserAction(
prevState: ActionState<unknown>,
formData: FormData
): Promise<ActionState<unknown>> {
userId: string
): Promise<ActionState<void>> {
try {
await requirePermission(Permissions.USER_MANAGE)
const userId = formData.get("userId") as string
await db.delete(users).where(eq(users.id, userId))
await deleteUserById(userId)
revalidatePath("/admin/users")
return { success: true, message: "用户已删除" }
} catch (e) {

View File

@@ -2,10 +2,8 @@
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil } from "lucide-react"
import { toast } from "sonner"
import { useRouter, useSearchParams } from "next/navigation"
import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil, UserCog } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -44,6 +42,13 @@ import {
} from "@/shared/components/ui/alert-dialog"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
import { useTranslations } from "next-intl"
import {
UserRoleAssignDialog,
type AssignableRole,
} from "@/modules/rbac/components/user-role-assign-dialog"
import { deleteUserAction } from "../actions"
interface AdminUserListItem {
id: string
@@ -63,6 +68,8 @@ interface AdminUsersViewProps {
totalPages: number
search: string
roleFilter: string
/** All roles (with id/name/isSystem/isEnabled) for the assign dialog. */
assignableRoles?: AssignableRole[]
}
export function AdminUsersView({
@@ -74,12 +81,22 @@ export function AdminUsersView({
totalPages,
search,
roleFilter,
assignableRoles,
}: AdminUsersViewProps) {
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations("users")
const [searchInput, setSearchInput] = React.useState(search)
const [deleteUserId, setDeleteUserId] = React.useState<string | null>(null)
const [deleting, setDeleting] = React.useState(false)
const [assignTarget, setAssignTarget] = React.useState<AdminUserListItem | null>(null)
const deleteMutation = useActionMutation({
successMessage: t("messages.deleted"),
onSuccess: () => {
setDeleteUserId(null)
router.refresh()
},
})
const updateParams = React.useCallback(
(updates: Record<string, string | undefined>) => {
@@ -105,22 +122,9 @@ export function AdminUsersView({
updateParams({ search: searchInput, page: undefined })
}
const handleDelete = async () => {
const handleDelete = (): void => {
if (!deleteUserId) return
setDeleting(true)
try {
const res = await fetch("/api/admin/users/" + deleteUserId, {
method: "DELETE",
})
if (!res.ok) throw new Error("删除失败")
toast.success("用户已删除")
setDeleteUserId(null)
router.refresh()
} catch (e) {
toast.error("删除失败:" + (e as Error).message)
} finally {
setDeleting(false)
}
void deleteMutation.mutate(() => deleteUserAction(deleteUserId))
}
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
@@ -130,13 +134,13 @@ export function AdminUsersView({
<div className="flex h-full flex-col space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-muted-foreground">{t("adminDescription")}</p>
</div>
<Button asChild>
<Link href="/admin/users/import">
<Upload className="mr-2 h-4 w-4" />
{t("importButton")}
</Link>
</Button>
</div>
@@ -147,7 +151,7 @@ export function AdminUsersView({
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索姓名或邮箱..."
placeholder={t("searchPlaceholder")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-9"
@@ -158,10 +162,10 @@ export function AdminUsersView({
onValueChange={(v) => updateParams({ role: v === "all" ? undefined : v, page: undefined })}
>
<SelectTrigger className="w-full md:w-48">
<SelectValue placeholder="所有角色" />
<SelectValue placeholder={t("allRoles")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="all">{t("allRoles")}</SelectItem>
{roleOptions.map((r) => (
<SelectItem key={r} value={r}>
{r}
@@ -169,7 +173,7 @@ export function AdminUsersView({
))}
</SelectContent>
</Select>
<Button type="submit"></Button>
<Button type="submit">{t("search")}</Button>
{(search || roleFilter) && (
<Button
type="button"
@@ -179,7 +183,7 @@ export function AdminUsersView({
updateParams({ search: undefined, role: undefined, page: undefined })
}}
>
{t("reset")}
</Button>
)}
</form>
@@ -191,8 +195,8 @@ export function AdminUsersView({
{users.length === 0 ? (
<EmptyState
icon={Users}
title="暂无用户"
description={search || roleFilter ? "没有匹配的用户,请调整搜索条件。" : "系统中还没有用户,点击批量导入创建。"}
title={t("emptyTitle")}
description={search || roleFilter ? t("emptyFilteredDescription") : t("emptyDescription")}
/>
) : (
<>
@@ -200,12 +204,12 @@ export function AdminUsersView({
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead>{t("columns.name")}</TableHead>
<TableHead>{t("columns.email")}</TableHead>
<TableHead>{t("columns.role")}</TableHead>
<TableHead>{t("columns.phone")}</TableHead>
<TableHead>{t("columns.createdAt")}</TableHead>
<TableHead className="w-[60px]">{t("columns.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -216,7 +220,7 @@ export function AdminUsersView({
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length === 0 ? (
<Badge variant="secondary"></Badge>
<Badge variant="secondary">{t("unassigned")}</Badge>
) : (
u.roles.map((r) => (
<Badge key={r} variant="secondary">{r}</Badge>
@@ -236,14 +240,20 @@ export function AdminUsersView({
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" />
{t("actions.edit")}
</DropdownMenuItem>
{assignableRoles && (
<DropdownMenuItem onClick={() => setAssignTarget(u)}>
<UserCog className="mr-2 h-4 w-4" />
{t("actions.assignRoles")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-destructive"
onClick={() => setDeleteUserId(u.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -256,7 +266,7 @@ export function AdminUsersView({
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground">
{start}-{end} {total}
{t("pagination.info", { start, end, total })}
</p>
<div className="flex items-center gap-2">
<Button
@@ -265,10 +275,10 @@ export function AdminUsersView({
disabled={page <= 1}
onClick={() => updateParams({ page: String(page - 1) })}
>
{t("pagination.prev")}
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
{t("pagination.pageInfo", { page, totalPages })}
</span>
<Button
variant="outline"
@@ -276,7 +286,7 @@ export function AdminUsersView({
disabled={page >= totalPages}
onClick={() => updateParams({ page: String(page + 1) })}
>
{t("pagination.next")}
</Button>
</div>
</div>
@@ -288,23 +298,35 @@ export function AdminUsersView({
<AlertDialog open={!!deleteUserId} onOpenChange={(v) => !v && setDeleteUserId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isWorking}>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={deleting}
disabled={deleteMutation.isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "删除中..." : "确认删除"}
{deleteMutation.isWorking ? t("actions.deleting") : t("actions.confirmDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{assignableRoles && assignTarget && (
<UserRoleAssignDialog
open={true}
onOpenChange={(v) => !v && setAssignTarget(null)}
userId={assignTarget.id}
userName={assignTarget.name ?? ""}
userEmail={assignTarget.email}
allRoles={assignableRoles}
currentRoleNames={assignTarget.roles}
/>
)}
</div>
)
}

View File

@@ -1,11 +1,11 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, gt, ilike, inArray, or } from "drizzle-orm"
import { and, count, desc, eq, gte, ilike, inArray, or } from "drizzle-orm"
import { auth } from "@/auth"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { db } from "@/shared/db"
import { roles, sessions, users, usersToRoles } from "@/shared/db/schema"
import { loginLogs, roles, users, usersToRoles } from "@/shared/db/schema"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
export type UserProfile = {
@@ -122,11 +122,22 @@ export type UsersDashboardStats = {
}
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => {
const now = new Date()
// audit-P1-9原查询 sessions 表JWT 策略下无数据)。
// 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([
db.select({ value: count() }).from(users),
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db
.select({ value: count() })
.from(loginLogs)
.where(
and(
eq(loginLogs.action, "signin"),
eq(loginLogs.status, "success"),
gte(loginLogs.createdAt, oneDayAgo),
),
),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
@@ -225,13 +236,17 @@ export const getUserWithRole = cache(
/**
* Returns the current authenticated student user (id + name) by reading the
* session and verifying the "student" role via JOIN users + usersToRoles + roles.
* auth context and verifying the "student" role via JOIN users + usersToRoles + roles.
* Returns null if not authenticated or the user does not have the student role.
*/
export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => {
const session = await auth()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
let ctx
try {
ctx = await getAuthContext()
} catch {
return null
}
const userId = ctx.userId
const student = await getUserWithRole(userId, "student")
@@ -422,3 +437,46 @@ export async function getAdminUserRoles(): Promise<string[]> {
const rows = await db.select({ name: roles.name }).from(roles)
return rows.map((r) => r.name)
}
/**
* Delete a user by id.
*
* Related rows (accounts, usersToRoles, passwordSecurity) are
* cleaned up via DB-level ON DELETE CASCADE foreign keys.
* (sessions table is deprecated under JWT strategy — see schema.ts)
*
* Safety: throws if the user is the last remaining admin role holder,
* preventing accidental lockout.
*/
export async function deleteUserById(userId: string): Promise<void> {
// Last-admin protection: count how many users have the admin role
const adminRoleRow = await db
.select({ id: roles.id })
.from(roles)
.where(eq(roles.name, "admin"))
.limit(1)
if (adminRoleRow.length > 0) {
const adminRoleId = adminRoleRow[0].id
const adminCountRow = await db
.select({ value: count() })
.from(usersToRoles)
.where(eq(usersToRoles.roleId, adminRoleId))
const adminCount = Number(adminCountRow[0]?.value ?? 0)
if (adminCount <= 1) {
// Check if the target user is an admin
const targetIsAdmin = await db
.select({ userId: usersToRoles.userId })
.from(usersToRoles)
.where(and(eq(usersToRoles.userId, userId), eq(usersToRoles.roleId, adminRoleId)))
.limit(1)
if (targetIsAdmin.length > 0) {
throw new Error("Cannot delete the last admin user")
}
}
}
await db.delete(users).where(eq(users.id, userId))
}

27
src/next-auth.d.ts vendored
View File

@@ -2,6 +2,12 @@ import type { DefaultSession } from "next-auth"
import type { Permission, Role } from "@/shared/types/permissions"
declare module "next-auth" {
interface User {
/** 主要角色(向后兼容) */
role?: string
/** 用户拥有的全部角色名 */
roles?: string[]
}
interface Session {
user: DefaultSession["user"] & {
id: string
@@ -18,7 +24,26 @@ declare module "next-auth/jwt" {
id: string
role: string // kept for backward compatibility
roles: Role[]
permissions: Permission[]
/**
* 权限位图base36 字符串audit-P1-7
*
* 由 `encodePermissionsBitmap()` 编码生成,约 14 字符,
* 相比 `permissions: Permission[]` JSON 数组(~1.1KB)显著减小 JWT 体积。
*
* proxy.ts 边缘运行时直接通过 `hasPermissionInBitmap()` 检查权限,
* 无需解码为完整数组。
*
* Session callback 中通过 `decodePermissionsBitmap()` 还原为 Permission[]
* 供服务端 `getAuthContext()` 和客户端 `usePermission()` 使用。
*/
permissionsBitmap: string
/**
* @deprecated 仅用于向后兼容,新代码应使用 `permissionsBitmap`。
* Session callback 已自动从 bitmap 解码并填充到 `session.user.permissions`
* 业务代码无需直接读取 `token.permissions`。
* 在 JWT 中保留此字段会导致 token 体积膨胀,未来版本将移除。
*/
permissions?: Permission[]
onboarded: boolean
}
}

View File

@@ -2,51 +2,15 @@ import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { getToken } from "next-auth/jwt"
import { Permissions } from "@/shared/types/permissions"
// Route prefix → minimum required permission
// Note: /admin/announcements is covered by /admin prefix (requires school:manage)
// Note: /announcements is accessible to all authenticated users (no permission entry needed)
// P0 修复:原先 /teacher 和 /parent 都使用 EXAM_READ但 student/parent 也有 EXAM_READ
// 导致跨角色访问漏洞(学生可访问 /teacher/*,教师可访问 /parent/*)。
// 改为使用各角色独有的权限点,确保跨角色访问被拒绝。
const ROUTE_PERMISSIONS: Record<string, string> = {
"/admin": Permissions.SCHOOL_MANAGE,
"/teacher": Permissions.EXAM_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/parent": Permissions.DASHBOARD_PARENT_READ,
"/management": Permissions.GRADE_MANAGE,
}
// 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等
const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
// 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 用于将 /admin/* 下的特定页面开放给非管理员角色
// V3.1/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider
const SPECIFIC_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/ai-settings": Permissions.AI_CHAT,
}
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/api/ai/chat": Permissions.AI_CHAT,
}
function resolveDefaultPath(roles: string[]): string {
if (roles.includes("admin")) return "/admin/dashboard"
if (roles.includes("grade_head") || roles.includes("teaching_head")) return "/teacher/dashboard"
if (roles.includes("teacher")) return "/teacher/dashboard"
if (roles.includes("student")) return "/student/dashboard"
if (roles.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}
import { type Permission } from "@/shared/types/permissions"
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
import { hasPermissionInBitmap } from "@/shared/lib/permission-bitmap"
import {
SPECIFIC_ROUTE_PERMISSIONS,
ROUTE_PREFIX_PERMISSIONS,
DASHBOARD_ROUTE_PERMISSIONS,
API_ROUTE_PERMISSIONS,
} from "@/shared/lib/route-permissions"
// Next.js 16 renamed `middleware` to `proxy`.
// See: https://nextjs.org/docs/messages/middleware-to-proxy
@@ -92,13 +56,21 @@ export async function proxy(request: NextRequest) {
return NextResponse.redirect(new URL(defaultPath, request.url))
}
const permissions: string[] = (token.permissions as string[]) ?? []
const permissionsBitmap: string = (token.permissionsBitmap as string) ?? ""
const roles: string[] = (token.roles as string[]) ?? []
/**
* audit-P1-7使用位图检查权限避免每次路由检查都解码完整权限数组。
* proxy.ts 在 edge runtime 运行,每个请求都经过这里,性能至关重要。
*/
function hasPermission(requiredPerm: Permission): boolean {
return hasPermissionInBitmap(permissionsBitmap, requiredPerm)
}
// Check API route permissions
for (const [prefix, requiredPerm] of Object.entries(API_PERMISSIONS)) {
for (const [prefix, requiredPerm] of Object.entries(API_ROUTE_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
break
@@ -109,7 +81,7 @@ export async function proxy(request: NextRequest) {
// 优先级 1精确路由权限覆盖前缀匹配用于将 /admin/* 下特定页面开放给非管理员)
if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) {
const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname]
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
@@ -122,7 +94,7 @@ export async function proxy(request: NextRequest) {
// 优先级 2仪表盘路由的细粒度权限防止跨角色访问仪表盘
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
const redirectUrl = new URL(defaultPath, request.url)
redirectUrl.searchParams.set("from", pathname)
@@ -132,9 +104,9 @@ export async function proxy(request: NextRequest) {
return NextResponse.next()
}
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PREFIX_PERMISSIONS)) {
if (pathname.startsWith(prefix)) {
if (!permissions.includes(requiredPerm)) {
if (!hasPermission(requiredPerm)) {
const defaultPath = resolveDefaultPath(roles)
// Carry original path + reason in URL so the target page can explain
// why the user was redirected (Web Interface Guidelines: URL reflects state).