Compare commits
5 Commits
0e63c24ed9
...
284c7939b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
284c7939b8 | ||
|
|
6a22922ddd | ||
|
|
93eacccdbf | ||
|
|
2dd8c2197c | ||
|
|
56c3f32e2d |
@@ -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
299
docs/architecture/008_module_role_mapping.md
Normal file
299
docs/architecture/008_module_role_mapping.md
Normal 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` | all(upload),admin(delete) |
|
||||
| **审计** | `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) |
|
||||
|
||||
---
|
||||
|
||||
## 六、维护说明
|
||||
|
||||
- 新增模块时,需同步更新本文档的模块-角色速查表(第一节)和对应角色的详细清单(第二节)
|
||||
- 新增权限点时,需同步更新权限点汇总(第四节)
|
||||
- 模块依赖关系变更时,需同步更新依赖关系速查(第五节)
|
||||
- 路由变更时,需同步更新对应角色的路由表
|
||||
101
docs/architecture/_update_004.cjs
Normal file
101
docs/architecture/_update_004.cjs
Normal 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` | 反思 Block(V3 续:select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'## 2.28 error-book(错题本模块)'
|
||||
].join('\r\n');
|
||||
|
||||
const newLines = [
|
||||
'| `components/blocks/reflection-block.tsx` | 反思 Block(V3 续: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 Actions(2026-07-01 新增)** |',
|
||||
'| `actions-review.ts` | **M3 审核工作流 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-comments.ts` | **M2 评论 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-formative.ts` | **M5 形成性评价 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-attachments.ts` | **M6 附件库 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-substitutes.ts` | **M12 代课教师 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-ai-evaluation.ts` | **M8 AI 评估 Server Actions(2026-07-01 新增)** |',
|
||||
'| `actions-analytics.ts` | **M10 分析仪表盘 Server Actions(2026-07-01 新增)** |',
|
||||
'',
|
||||
'> 架构变更(2026-07-01,M1-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.ts,4 种互动类型',
|
||||
'> - **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.ts,3 个分析维度',
|
||||
'> - **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-access:getStandards / getStandardsTree / getStandardById / createStandard / updateStandard / deactivateStandard / searchStandards / getStandardsByPlanId / linkPlanToStandard / unlinkPlanFromStandard',
|
||||
'- Actions:10 个 Server Actions,全部使用 requirePermission() + ActionState<T>',
|
||||
'- Schema:createStandardSchema / updateStandardSchema / getStandardsParamsSchema / linkPlanToStandardSchema',
|
||||
'- Types:Standard / StandardTreeNode / LessonPlanStandardLink',
|
||||
'',
|
||||
'**权限点**:',
|
||||
'- STANDARD_READ(standard:read)',
|
||||
'- STANDARD_MANAGE(standard:manage)',
|
||||
'- STANDARD_LINK(standard: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
626
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
201
src/auth.ts
201
src/auth.ts
@@ -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-7:JWT 中仅存位图(~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-7:session.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 },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
11
src/env.mjs
11
src/env.mjs
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
{/* 变式练习入口 */}
|
||||
{/* 变式练习入口(由父组件注入回调) */}
|
||||
{onStartVariantPractice ? (
|
||||
<section>
|
||||
<Button
|
||||
onClick={handleStartVariantPractice}
|
||||
onClick={onStartVariantPractice}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
variant="default"
|
||||
>
|
||||
<Target className="h-4 w-4" />
|
||||
{isPending ? tPractice("starter.creating") : tPractice("types.error_variant")}
|
||||
{t("detailDialog.variantPractice")}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
{tPractice("starter.description")}
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,46 +14,49 @@ interface ReviewButtonsProps {
|
||||
onReviewed?: () => void
|
||||
}
|
||||
|
||||
const REVIEW_OPTIONS: Array<{
|
||||
type ReviewOption = {
|
||||
result: ErrorBookReviewResultValue
|
||||
label: string
|
||||
description: string
|
||||
icon: typeof RotateCcw
|
||||
variant: "destructive" | "secondary" | "default" | "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: "重来",
|
||||
description: "完全不会,明天再复习",
|
||||
label: t("review.again"),
|
||||
description: t("review.againDesc"),
|
||||
icon: RotateCcw,
|
||||
variant: "destructive",
|
||||
},
|
||||
{
|
||||
result: "hard",
|
||||
label: "困难",
|
||||
description: "勉强答对,2 天后复习",
|
||||
label: t("review.hard"),
|
||||
description: t("review.hardDesc"),
|
||||
icon: Zap,
|
||||
variant: "secondary",
|
||||
},
|
||||
{
|
||||
result: "good",
|
||||
label: "良好",
|
||||
description: "正常答对,4 天后复习",
|
||||
label: t("review.good"),
|
||||
description: t("review.goodDesc"),
|
||||
icon: ThumbsUp,
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
result: "easy",
|
||||
label: "简单",
|
||||
description: "轻松答对,7 天后复习",
|
||||
label: t("review.easy"),
|
||||
description: t("review.easyDesc"),
|
||||
icon: Check,
|
||||
variant: "outline",
|
||||
},
|
||||
]
|
||||
|
||||
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)
|
||||
]
|
||||
|
||||
function handleReview(result: ErrorBookReviewResultValue) {
|
||||
setSelected(result)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
552
src/modules/error-book/data-access-analytics.ts
Normal file
552
src/modules/error-book/data-access-analytics.ts
Normal 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)
|
||||
}
|
||||
@@ -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(
|
||||
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++
|
||||
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
|
||||
}
|
||||
|
||||
if (status !== "mastered" && status !== "archived") {
|
||||
if (!row.nextReviewAt || row.nextReviewAt <= now) {
|
||||
dueReviewCount++
|
||||
}
|
||||
}
|
||||
// 待复习数:单独查询(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 ?? "未知"]))
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
27
src/next-auth.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
76
src/proxy.ts
76
src/proxy.ts
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user