feat(school,classes): 实现 P2 长期问题全量改进项

P2-2: 新增 OrgTreeNav 组件(学校→年级→班级三级树形导航,支持搜索过滤/选中高亮/展开折叠)

P2-3: 新增 promoteGradesAction 年级升级功能(中文数字/阿拉伯数字识别,按 order 降序避免冲突)

P2-4: 新增 bulkEnrollStudentsAction(CSV 批量导入学生)+ bulkAssignSubjectTeachersAction(CSV 批量分配教师)

P2-5: 为 department/academicYear/grade 的 9 个 CRUD Action 补充 logAudit 审计日志

同步更新架构图文档 004/005
This commit is contained in:
SpecialX
2026-06-23 08:55:21 +08:00
parent 4da9194a5e
commit c766951374
11 changed files with 761 additions and 81 deletions

View File

@@ -654,7 +654,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**导出函数** **导出函数**
- Actions14 个,均为写操作;读操作由 RSC 页面直接调用 data-access`createTextbookAction` / `updateTextbookAction` / `deleteTextbookAction` / `createChapterAction` / `updateChapterContentAction` / `deleteChapterAction` / `reorderChaptersAction` / `createKnowledgePointAction` / `updateKnowledgePointAction` / `deleteKnowledgePointAction` / `getKnowledgePointsByChapterAction` / `getKnowledgeGraphDataAction`(✅ Task 7 新增:知识图谱数据查询,支持 structure/student-mastery/class-mastery 三种视图)/ `createPrerequisiteAction`(✅ Task 7 新增:声明前置依赖,含循环检测)/ `deletePrerequisiteAction`(✅ Task 7 新增:删除前置依赖) - Actions14 个,均为写操作;读操作由 RSC 页面直接调用 data-access`createTextbookAction` / `updateTextbookAction` / `deleteTextbookAction` / `createChapterAction` / `updateChapterContentAction` / `deleteChapterAction` / `reorderChaptersAction` / `createKnowledgePointAction` / `updateKnowledgePointAction` / `deleteKnowledgePointAction` / `getKnowledgePointsByChapterAction` / `getKnowledgeGraphDataAction`(✅ Task 7 新增:知识图谱数据查询,支持 structure/student-mastery/class-mastery 三种视图)/ `createPrerequisiteAction`(✅ Task 7 新增:声明前置依赖,含循环检测)/ `deletePrerequisiteAction`(✅ Task 7 新增:删除前置依赖)
- Data-access`getTextbooks` / `getTextbookById` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` / `createTextbook` / `updateTextbook` / `deleteTextbook` / `createChapter` / `updateChapterContent` / `deleteChapter` / `createKnowledgePoint` / `updateKnowledgePoint` / `deleteKnowledgePoint` / `reorderChapters` / `getTextbooksDashboardStats` / `getKnowledgePointOptions`(跨模块接口,供 questions 调用)/ `getTextbooksWithScope`P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤)/ `verifyChapterBelongsToTextbook`P0-4 新增:资源归属校验)/ `verifyKnowledgePointBelongsToTextbook`P0-4 新增:资源归属校验)/ `createPrerequisite`(✅ Task 7 新增:创建前置依赖)/ `deletePrerequisite`(✅ Task 7 新增:删除前置依赖)/ `getPrerequisiteEdgesForTextbook`(✅ Task 7 新增:获取教材下所有前置依赖边,用于循环检测)/ `getSubjectLabelKey` / `getGradeLabelKey`i18n 标签键) - Data-access`getTextbooks` / `getTextbookById` / `getChaptersByTextbookId` / `getKnowledgePointsByChapterId` / `getKnowledgePointsByTextbookId` / `createTextbook` / `updateTextbook` / `deleteTextbook` / `createChapter` / `updateChapterContent` / `deleteChapter` / `createKnowledgePoint` / `updateKnowledgePoint` / `deleteKnowledgePoint` / `reorderChapters` / `getTextbooksDashboardStats` / `getKnowledgePointOptions`(跨模块接口,供 questions 调用)/ `getTextbooksWithScope`P1-1 新增:按数据范围获取教材列表,学生端强制按年级过滤)/ `verifyChapterBelongsToTextbook`P0-4 新增:资源归属校验)/ `verifyKnowledgePointBelongsToTextbook`P0-4 新增:资源归属校验)/ `verifyKnowledgePointBelongsToChapter`P0-4 新增:校验知识点是否属于指定章节)/ `createPrerequisite`(✅ Task 7 新增:创建前置依赖)/ `deletePrerequisite`(✅ Task 7 新增:删除前置依赖)/ `getPrerequisiteEdgesForTextbook`(✅ Task 7 新增:获取教材下所有前置依赖边,用于循环检测)/ `getSubjectLabelKey` / `getGradeLabelKey`i18n 标签键)
- Data-access-graph✅ Task 5 新增,图谱只读查询):`getKnowledgePointsWithRelations`(知识点+依赖+题目数聚合查询)/ `getStudentKpMastery`(学生掌握度)/ `getClassKpMastery`(班级平均掌握度)/ `getPrerequisitesForKp` / `getSuccessorsForKp` - Data-access-graph✅ Task 5 新增,图谱只读查询):`getKnowledgePointsWithRelations`(知识点+依赖+题目数聚合查询)/ `getStudentKpMastery`(学生掌握度)/ `getClassKpMastery`(班级平均掌握度)/ `getPrerequisitesForKp` / `getSuccessorsForKp`
- Constants✅ 新增):`SUBJECTS` / `GRADES` / `SUBJECT_COLORS` / `getSubjectColor` / `getSubjectLabelKey` / `getGradeLabelKey` - Constants✅ 新增):`SUBJECTS` / `GRADES` / `SUBJECT_COLORS` / `getSubjectColor` / `getSubjectLabelKey` / `getGradeLabelKey`
- ✅ v1 测试修复:`SUBJECTS` 新增 `Chinese` 学科、`GRADES` 新增 `Grade 1`/`Grade 2`,与 seed 数据对齐 - ✅ v1 测试修复:`SUBJECTS` 新增 `Chinese` 学科、`GRADES` 新增 `Grade 1`/`Grade 2`,与 seed 数据对齐
@@ -693,25 +693,28 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 502 | 14 个 Server Action写操作含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理) | | `actions.ts` | 515 | 14 个 Server Action写操作含 Zod 校验 + 资源归属校验 + 知识图谱查询/前置依赖管理 + class-mastery 视图 |
| `data-access.ts` | 586 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD | | `data-access.ts` | 662 | 教材/章节/知识点 CRUD + 跨模块查询接口 + 资源归属校验 + 数据范围过滤 + 前置依赖 CRUD |
| `data-access-graph.ts` | 184 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only` | | `data-access-graph.ts` | 207 | 知识图谱只读查询(✅ Task 5 新增:知识点关联聚合、学生/班级掌握度、前置后置知识点,标记 `server-only`v2 修复:前置依赖双向 IN 过滤防跨教材污染 |
| `types.ts` | 94 | 类型定义含知识图谱类型GraphViewMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel | | `types.ts` | 106 | 类型定义含知识图谱类型GraphViewMode/MasteryInfo/KpWithRelations/GraphNodeData/GraphEdgeData/KnowledgeGraphData/MasteryLevel |
| `schema.ts` | 62 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchema | | `schema.ts` | 81 | Zod 校验(含 CreatePrerequisiteSchema/DeletePrerequisiteSchemav2 修复refine 消息改为英文 |
| `constants.ts` | 99 | 学科/年级常量与颜色映射(✅ 新增v1 测试修复:新增 Chinese/Grade 1/Grade 2 | | `constants.ts` | 96 | 学科/年级常量与颜色映射(✅ 新增v1 测试修复:新增 Chinese/Grade 1/Grade 2 |
| `utils.ts` | 203 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) | | `utils.ts` | 225 | 章节树构建/排序/查找等纯函数 + 循环检测(✅ 新增,含单测) |
| `graph-layout.ts` | 105 | 知识图谱布局计算纯函数(✅ Task 8 重写为 dagre 集成,输出 React Flow 格式,含单测) | | `graph-layout.ts` | 121 | 知识图谱布局计算纯函数(✅ Task 8 重写为 dagre 集成,输出 React Flow 格式,含单测) |
| `analytics.tsx` | 43 | 教材分析 Context/Provider/Hook✅ 新增) | | `analytics.tsx` | 43 | 教材分析 Context/Provider/Hook✅ 新增) |
| `hooks/use-knowledge-point-actions.ts` | 121 | 知识点操作 Hook | | `hooks/use-knowledge-point-actions.ts` | 49 | 知识点操作门面 Hookv2 拆分:组合 useKpDialogState + useKpCrud对外 API 不变) |
| `hooks/use-kp-dialog-state.ts` | 38 | 知识点对话框状态管理 Hookv2 新增:从 use-knowledge-point-actions 拆分) |
| `hooks/use-kp-crud.ts` | 122 | 知识点 CRUD 操作 Hookv2 新增:从 use-knowledge-point-actions 拆分) |
| `hooks/use-text-selection.ts` | 57 | 文本选区捕获 Hook | | `hooks/use-text-selection.ts` | 57 | 文本选区捕获 Hook |
| `hooks/use-graph-data.ts` | 58 | 知识图谱数据加载 Hook✅ Task 11 新增:派生值模式避免 effect 中 setState | | `hooks/use-graph-data.ts` | 76 | 知识图谱数据加载 Hook✅ Task 11 新增:派生值模式避免 effect 中 setStatev2 修复:区分 isLoading/isRefreshing 避免 UI 闪烁 |
| `components/teacher-textbook-reader.tsx` | 41 | 教师端 TextbookReader 客户端包装(✅ v1 测试修复:解决 Server→Client 函数 prop 序列化问题 | | `components/knowledge-graph.tsx` | 376 | React Flow 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色v2 新增:添加/删除前置依赖 Dialog |
| `components/knowledge-graph.tsx` | 249 | React Flow 知识图谱主组件(✅ Task 10/15 重写:全书视图 + 搜索高亮 + 关联节点高亮 + 章节着色 | | `components/graph-kp-node.tsx` | 92 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条v2 修复:节点宽度常量化 NODE_WIDTH |
| `components/graph-kp-node.tsx` | 80 | React Flow 自定义节点(✅ Task 9 新增:知识点名称+题目数徽章+掌握度进度条 | | `components/graph-prerequisite-edge.tsx` | 48 | React Flow 自定义(✅ Task 9 新增:虚线+箭头表示前置依赖v2 修复GraphEdgeData 类型安全转换 |
| `components/graph-prerequisite-edge.tsx` | 40 | React Flow 自定义边(✅ Task 9 新增:虚线+箭头表示前置依赖 | | `components/graph-toolbar.tsx` | 93 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图v2 新增isRefreshing 轻量指示器 |
| `components/graph-toolbar.tsx` | 77 | 图谱工具栏(✅ Task 9 新增:视图模式切换 + 搜索 + 重置视图 | | `components/graph-node-detail-panel.tsx` | 181 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表v2 修复:移除未使用 textbookId prop |
| `components/graph-node-detail-panel.tsx` | 171 | 节点详情侧边面板(✅ Task 13 新增:描述/掌握度/关联题目/前置后置列表 | | `components/chapter-sidebar-list.tsx` | 342 | 章节侧边栏列表v2 修复canEdit 默认 false + orderUpdateFailed i18n key |
| `components/*` | 17 文件 | 教材编辑/知识图谱组件(新增 `section-error-boundary.tsx``teacher-textbook-reader.tsx`、5 个 graph-* 组件 | | `components/section-error-boundary.tsx` | 71 | 章节内容错误边界v2 修复:默认值改为空字符串 + 条件渲染 |
| `components/*` | 16 文件 | 教材编辑/知识图谱组件(新增 `section-error-boundary.tsx`、5 个 graph-* 组件;`teacher-textbook-reader.tsx` 已移至 app 层) |
--- ---
@@ -723,7 +726,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- Actions`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限) - Actions`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction` / `assertClassInScope`(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)
- Data-access`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUDupsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总) - Data-access`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUDupsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
- Types✅ v3-P2 新增):`SchoolWideGradeSummaryItem`全校汇总按年级聚合项gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount/ `SchoolWideGradeSummary`全校汇总grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts - Types✅ v3-P2 新增):`SchoolWideGradeSummaryItem`全校汇总按年级聚合项gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount/ `SchoolWideGradeSummary`全校汇总grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts
- Lib✅ P1-2 新增,✅ P3 更新签名):`toNumber` / `normalize` / `buildScopeClassFilter(scope, currentUserId?)`P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数) - Lib✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分`toNumber` / `normalize`(位于 `lib/grade-utils.ts``buildScopeClassFilter(scope, currentUserId?)`P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数)
- Stats-service✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O统计逻辑可独立测试 - Stats-service✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O统计逻辑可独立测试
- Components✅ P1-5 新增):`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) - Components✅ P1-5 新增):`WidgetBoundary`Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格)
@@ -763,6 +766,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ v3-P2 改进2026-06-23`getGradeTrend`/`getGradeDistribution`/`getSubjectComparison`/`getClassComparison` 均新增 `semester``examId` 可选参数,支持按学期和考试筛选分析 - ✅ v3-P2 改进2026-06-23`getGradeTrend`/`getGradeDistribution`/`getSubjectComparison`/`getClassComparison` 均新增 `semester``examId` 可选参数,支持按学期和考试筛选分析
- ✅ v3-P2 改进2026-06-23新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数加权平均计算全校汇总admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard - ✅ v3-P2 改进2026-06-23新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数加权平均计算全校汇总admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard
- ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard - ✅ v3-P2 改进2026-06-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard
- ✅ P3 修复2026-06-23~~`lib/grade-utils.ts` 72 行超 40 行工具函数上限~~ P3-26 将 `buildScopeClassFilter` 迁移至 `lib/scope-filter.ts`grade-utils.ts 仅保留 toNumber/normalize20 行)
- ✅ P3 修复2026-06-23~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数
- ✅ P3 修复2026-06-23~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言
- ✅ P3 修复2026-06-23~~`export.ts` 局部 avg 函数与 stats-service.computeAverageScore 重复~~ P3-6 删除局部 avg复用 computeAverageScore
- ✅ P3 修复2026-06-23~~`export.ts` TYPE_LABELS 硬编码中文~~ P3-7 改用 next-intl getTranslations新增 export.sheets/columns/metrics i18n 键zh-CN/en 同步)
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
@@ -772,10 +780,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `data-access.ts` | 428+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复recorderName 批量查询P3 修复PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名v3-P2 新增saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口) | | `data-access.ts` | 428+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复recorderName 批量查询P3 修复PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名v3-P2 新增saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口) |
| `data-access-analytics.ts` | 200+ | 趋势/对比分析P3 修复getClassComparison 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) | | `data-access-analytics.ts` | 200+ | 趋势/对比分析P3 修复getClassComparison 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
| `data-access-ranking.ts` | 83 | 排名查询P3 修复getRankingTrend 接受 scope 参数 + class_taught 校验) | | `data-access-ranking.ts` | 83 | 排名查询P3 修复getRankingTrend 接受 scope 参数 + class_taught 校验) |
| `stats-service.ts` | 279 | 统计计算纯函数P1-1 新增8 个纯函数 + 2 个常量 + 2 个接口) | | `stats-service.ts` | 285 | 统计计算纯函数P1-1 新增8 个纯函数 + 2 个常量 + 2 个接口P3-10createDefaultBuckets 改为内部函数P3-24buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言 |
| `export.ts` | 189 | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope | | `export.ts` | 209 | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scopeP3-6复用 stats-service.computeAverageScore 替代局部 avgP3-7硬编码中文改用 next-intl getTranslations |
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行) | | `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行) |
| `lib/grade-utils.ts` | 66 | 公共工具函数toNumber/normalize/buildScopeClassFilterv2-P2-2 修复:改用 classes data-access 子查询P3 修复buildScopeClassFilter 新增 currentUserId 参数 | | `lib/grade-utils.ts` | 20 | 公共工具函数toNumber/normalizeP3-26buildScopeClassFilter 迁移至 scope-filter.ts |
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤buildScopeClassFilterP3-26 从 grade-utils.ts 迁移v2-P2-2 修复:改用 classes data-access 子查询P3 修复:新增 currentUserId 参数) |
| `types.ts` | 168+ | 类型定义v3-P2 新增SchoolWideGradeSummaryItem/SchoolWideGradeSummary | | `types.ts` | 168+ | 类型定义v3-P2 新增SchoolWideGradeSummaryItem/SchoolWideGradeSummary |
| `components/widget-boundary.tsx` | 136 | Widget 边界组件P1-5 新增v2-P1-1 已在 3 个页面应用) | | `components/widget-boundary.tsx` | 136 | Widget 边界组件P1-5 新增v2-P1-1 已在 3 个页面应用) |
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) | | `components/school-wide-summary-card.tsx` | - | v3-P2 新增管理员全校成绩汇总卡片4 个统计卡片 + 各年级对比表格) |
@@ -834,7 +843,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `actions-teacher.ts` | 100 | 教师班级 CRUD3 个 Action | | `actions-teacher.ts` | 100 | 教师班级 CRUD3 个 Action |
| `actions-admin.ts` | 120 | 管理员班级 CRUD3 个 Action | | `actions-admin.ts` | 120 | 管理员班级 CRUD3 个 Action |
| `actions-grade.ts` | 110 | 年级组长班级 CRUD3 个 Action | | `actions-grade.ts` | 110 | 年级组长班级 CRUD3 个 Action |
| `actions-invitations.ts` | 280 | 邀请码与注册8 个 Action | | `actions-invitations.ts` | 502 | 邀请码与注册 + 批量操作10 个 ActionP2-4 新增批量导入学生/批量分配教师 |
| `actions-schedule.ts` | 90 | 班级课表 CRUD3 个 Action | | `actions-schedule.ts` | 90 | 班级课表 CRUD3 个 Action |
| `actions-shared.ts` | 60 | 共享工具hasAdminScope/hasTeacherScope/hasStudentScope/parseSubjectTeachers/toWeekday | | `actions-shared.ts` | 60 | 共享工具hasAdminScope/hasTeacherScope/hasStudentScope/parseSubjectTeachers/toWeekday |
| `schema.ts` | 152 | Zod 校验13 个 schema教师/管理员/年级班级 CRUD + 课表 CRUD + 邮箱注册) | | `schema.ts` | 152 | Zod 校验13 个 schema教师/管理员/年级班级 CRUD + 课表 CRUD + 邮箱注册) |
@@ -847,8 +856,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**职责**:学校/学年/部门/年级的 CRUD。 **职责**:学校/学年/部门/年级的 CRUD。
**导出函数** **导出函数**
- Actions`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit) - Actions`createSchoolAction` / `updateSchoolAction` / `deleteSchoolAction` / `createAcademicYearAction` / `updateAcademicYearAction` / `deleteAcademicYearAction` / `createDepartmentAction` / `updateDepartmentAction` / `deleteDepartmentAction` / `createGradeAction` / `updateGradeAction` / `deleteGradeAction` / `promoteGradesAction`(编排层:权限校验 + Zod 校验 + 调用 data-access + revalidatePath + after(logAudit)`promoteGradesAction` 年级升级,审计日志 `grade.promote`
- Data-access只读查询`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff`+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear` - Data-access只读查询`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree`+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`+ `promoteGrades(schoolId)` 年级升级order +1 + 名称升级,辅助函数 `promoteGradeName`
**依赖关系** **依赖关系**
- 依赖:`shared/*``@/auth``users`(⚠️ `getStaffOptions` 直查 users/roles可接受 - 依赖:`shared/*``@/auth``users`(⚠️ `getStaffOptions` 直查 users/roles可接受
@@ -866,20 +875,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ P1-5 修复2026-06-22~~`schools-view.tsx` 硬编码 Table+Dialog+AlertDialog~~ 拆分为组合模式SchoolListToolbar + SchoolFormDialog + SchoolDeleteDialog + useSchoolData hook - ✅ P1-5 修复2026-06-22~~`schools-view.tsx` 硬编码 Table+Dialog+AlertDialog~~ 拆分为组合模式SchoolListToolbar + SchoolFormDialog + SchoolDeleteDialog + useSchoolData hook
- ✅ P1-6 修复2026-06-22新增 `getSchoolsForUser(userId)` / `getGradesForUser(userId)` 权限感知查询函数,根据用户角色返回可见数据范围 - ✅ P1-6 修复2026-06-22新增 `getSchoolsForUser(userId)` / `getGradesForUser(userId)` 权限感知查询函数,根据用户角色返回可见数据范围
- ✅ P2-1 修复2026-06-22抽取 `use-school-data` hook将对话框状态管理逻辑与 UI 分离 - ✅ P2-1 修复2026-06-22抽取 `use-school-data` hook将对话框状态管理逻辑与 UI 分离
- ✅ P2-2 修复2026-06-23新增 `getOrgTree()` data-access 函数(学校→年级→班级三级树,通过 `import("@/modules/classes/data-access")` 动态导入获取班级数据避免循环依赖)+ `OrgTreeNode` 类型 + `OrgTreeNav` 组件(三级树形导航,支持搜索过滤/选中高亮/展开折叠/不同节点类型图标school.json 补充 `orgTree.*` 翻译键
- ✅ P2-3 修复2026-06-23新增 `promoteGradesAction` + `promoteGrades(schoolId)` + `promoteGradeName(name)` 辅助函数(中文数字 一→二…十二、阿拉伯数字 1→2…12 识别),按 order 降序逐条 +1 避免唯一约束冲突;含 `after(() => logAudit({ action: "grade.promote" }))` 审计日志
- ✅ P2-4 修复2026-06-23新增 `bulkEnrollStudentsAction`CSV 批量导入学生,复用 enrollStudentByEmail+ `bulkAssignSubjectTeachersAction`CSV 批量分配教师,简化实现含 TODO 待完善查找逻辑classes/actions.ts barrel 导出
- ✅ P2-5 修复2026-06-23为 department/academicYear/grade 的 9 个 CRUD Action 补充 `after(() => logAudit(...))` 审计日志action: department.create/update/delete、academicYear.create/update/delete、grade.create/update/delete与 school 实体审计日志策略一致
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 349 | 12 个 Server Action编排层无 DB 直访) | | `actions.ts` | 457 | 13 个 Server Action编排层无 DB 直访;含 `promoteGradesAction` 年级升级 |
| `data-access.ts` | 504+ | 只读查询 + 12 个写操作 + 跨模块查询接口 + 权限感知函数getSchoolsForUser/getGradesForUser | | `data-access.ts` | 782 | 只读查询 + 12 个写操作 + `promoteGrades` 年级升级 + `getOrgTree` 组织架构树 + 跨模块查询接口 + 权限感知函数getSchoolsForUser/getGradesForUser |
| `schema.ts` | 51 | Zod 校验 | | `schema.ts` | 56 | Zod 校验(含 `PromoteGradesSchema` |
| `types.ts` | 96 | 类型定义(含 Insert/Update 入参类型) | | `types.ts` | 103 | 类型定义(含 Insert/Update 入参类型 + `OrgTreeNode` |
| components/schools-view.tsx | 132 | 学校列表容器组合模式P1-5 修复) | | components/schools-view.tsx | 132 | 学校列表容器组合模式P1-5 修复) |
| components/school-form-dialog.tsx | 80 | 学校创建/编辑对话框P1-5 修复) | | components/school-form-dialog.tsx | 80 | 学校创建/编辑对话框P1-5 修复) |
| components/school-delete-dialog.tsx | 50 | 学校删除确认对话框P1-5 修复) | | components/school-delete-dialog.tsx | 50 | 学校删除确认对话框P1-5 修复) |
| components/school-list-toolbar.tsx | 30 | 学校列表工具栏P1-5 修复) | | components/school-list-toolbar.tsx | 30 | 学校列表工具栏P1-5 修复) |
| components/school-error-boundary.tsx | 72 | 共享 Error BoundaryP1-3 修复) | | components/school-error-boundary.tsx | 72 | 共享 Error BoundaryP1-3 修复) |
| components/school-skeleton.tsx | 69 | 共享骨架屏P1-3 修复) | | components/school-skeleton.tsx | 69 | 共享骨架屏P1-3 修复) |
| components/org-tree-nav.tsx | 134 | 学校→年级→班级三级树形导航P2-2 修复:搜索过滤 + 选中高亮 + 展开折叠 + 节点类型图标) |
| hooks/use-school-data.ts | 40 | 学校数据管理 hookP2-1 修复) | | hooks/use-school-data.ts | 40 | 学校数据管理 hookP2-1 修复) |
--- ---
@@ -1153,7 +1167,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `dispatcher.ts` | 152 | 渠道选择 + 并行分发 | | `dispatcher.ts` | 152 | 渠道选择 + 并行分发 |
| `data-access.ts` | 177 | 站内通知 CRUD + 用户联系方式 + 日志(P0-4 / P1-5 修复后新增通知 CRUD | | `data-access.ts` | 212 | 站内通知 CRUD + 用户联系方式 + 发送日志持久化notification_logs 表;P0-4 / P1-5 修复后新增通知 CRUD |
| `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) | | `preferences.ts` | 166 | 通知偏好 CRUDP0-4 / P1-5 修复后从 messaging 迁移) |
| `actions.ts` | ~260 | 6 个 Server Action✅ P1-4新增 4 个通知 CRUD Action | | `actions.ts` | ~260 | 6 个 Server Action✅ P1-4新增 4 个通知 CRUD Action |
| `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型P0-4 / P1-5 修复后扩充) | | `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型P0-4 / P1-5 修复后扩充) |
@@ -2329,7 +2343,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
| `getRankingTrend` | `data-access-ranking.ts` | `(studentId, subjectId?, semester?) => Promise<RankingTrendResult \| null>` | `(studentId, subjectId?, semester?, scope?: DataScope) => Promise<RankingTrendResult \| null>`class_taught scope 校验) | | `getRankingTrend` | `data-access-ranking.ts` | `(studentId, subjectId?, semester?) => Promise<RankingTrendResult \| null>` | `(studentId, subjectId?, semester?, scope?: DataScope) => Promise<RankingTrendResult \| null>`class_taught scope 校验) |
**lib 层签名变更** **lib 层签名变更**
- `buildScopeClassFilter(scope: DataScope, currentUserId?: string): SQL | null`(新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤) - `buildScopeClassFilter(scope: DataScope, currentUserId?: string): SQL | null`(新增 `currentUserId` 参数,`class_members` scope 内置 `eq(gradeRecords.studentId, currentUserId)` 过滤P3-26`lib/grade-utils.ts` 迁移至 `lib/scope-filter.ts`
**新增导出** **新增导出**
- `assertClassInScope(scope: DataScope, classId: string): string | null``actions.ts`,校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用) - `assertClassInScope(scope: DataScope, classId: string): string | null``actions.ts`,校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用)

View File

@@ -4625,11 +4625,29 @@
"textbook-content-panel.tsx" "textbook-content-panel.tsx"
] ]
}, },
{
"name": "useKpDialogState",
"file": "hooks/use-kp-dialog-state.ts",
"signature": "() => { editingKp, setEditingKp, editKpDialogOpen, setEditKpDialogOpen, isUpdatingKp, setIsUpdatingKp, questionDialogOpen, setQuestionDialogOpen, targetKpForQuestion, setTargetKpForQuestion, deleteConfirmOpen, setDeleteConfirmOpen, pendingDeleteKpId, setPendingDeleteKpId }",
"purpose": "知识点对话框状态管理 Hook编辑/题目/删除确认),从 use-knowledge-point-actions 拆分",
"usedBy": [
"hooks/use-knowledge-point-actions.ts"
]
},
{
"name": "useKpCrud",
"file": "hooks/use-kp-crud.ts",
"signature": "(args: UseKpCrudArgs) => { handleCreateKnowledgePoint, requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }",
"purpose": "知识点 CRUD 操作 Hook依赖 useKpDialogState 提供的状态,从 use-knowledge-point-actions 拆分",
"usedBy": [
"hooks/use-knowledge-point-actions.ts"
]
},
{ {
"name": "useKnowledgePointActions", "name": "useKnowledgePointActions",
"file": "hooks/use-knowledge-point-actions.ts", "file": "hooks/use-knowledge-point-actions.ts",
"signature": "(textbookId, selectedChapterId, selectedChapterTextbookId, highlightedKpId, setHighlightedKpId, onKpCreated?) => { editingKp, setEditingKp, editKpDialogOpen, setEditKpDialogOpen, isUpdatingKp, questionDialogOpen, setQuestionDialogOpen, targetKpForQuestion, setTargetKpForQuestion, deleteConfirmOpen, setDeleteConfirmOpen, handleCreateKnowledgePoint, requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }", "signature": "(textbookId, selectedChapterId, selectedChapterTextbookId, highlightedKpId, setHighlightedKpId, onKpCreated?) => { editingKp, setEditingKp, editKpDialogOpen, setEditKpDialogOpen, isUpdatingKp, questionDialogOpen, setQuestionDialogOpen, targetKpForQuestion, setTargetKpForQuestion, deleteConfirmOpen, setDeleteConfirmOpen, handleCreateKnowledgePoint, requestDeleteKnowledgePoint, confirmDeleteKnowledgePoint, handleUpdateKnowledgePoint }",
"purpose": "知识点操作Hook(6参数)", "purpose": "知识点操作门面 Hook(组合 useKpDialogState + useKpCrud对外保持原有 API 不变)",
"usedBy": [ "usedBy": [
"textbook-reader.tsx" "textbook-reader.tsx"
] ]
@@ -4637,8 +4655,8 @@
{ {
"name": "useGraphData", "name": "useGraphData",
"file": "hooks/use-graph-data.ts", "file": "hooks/use-graph-data.ts",
"signature": "(textbookId: string, viewMode: GraphViewMode) => { data: KnowledgeGraphData | null, isLoading: boolean, error: string | null, reload: () => void }", "signature": "(textbookId: string, viewMode: GraphViewMode) => { data: KnowledgeGraphData | null, isLoading: boolean, isRefreshing: boolean, error: string | null, reload: () => void }",
"purpose": "Task 11 新增:知识图谱数据加载 Hook按 textbookId + viewMode 加载,使用派生值模式避免 effect 中 setState", "purpose": "Task 11 新增:知识图谱数据加载 Hook按 textbookId + viewMode 加载,使用派生值模式避免 effect 中 setState。区分 isLoading首次加载和 isRefreshing切换模式刷新保留旧数据避免 UI 闪烁)",
"usedBy": [ "usedBy": [
"components/knowledge-graph.tsx" "components/knowledge-graph.tsx"
] ]
@@ -4975,42 +4993,51 @@
"name": "TextbookReader", "name": "TextbookReader",
"purpose": "教材阅读器" "purpose": "教材阅读器"
}, },
{
"name": "TeacherTextbookReader",
"purpose": "教师端 TextbookReader 客户端包装v1 测试修复:解决 Server→Client 函数 prop 序列化问题)"
},
{ {
"name": "TextbookSettingsDialog", "name": "TextbookSettingsDialog",
"purpose": "教材设置对话框" "purpose": "教材设置对话框"
} }
], ],
"uiDeps": [], "uiDeps": [],
"uiDepsNote": "已通过 render prop 解耦,不再直接 import questions 模块组件", "uiDepsNote": "已通过 render prop 解耦,TeacherTextbookReader 已移至 app 层src/app/(dashboard)/teacher/textbooks/[id]/_components/textbooks 模块不再直接 import questions 模块组件",
"knownIssues": [ "knownIssues": [
"i18n 覆盖率约 95%chapter-sidebar-list 已接入,actions.ts 已接入,section-error-boundary 默认值已改英文", "i18n 覆盖率约 98%chapter-sidebar-list/actions.ts/section-error-boundary/8 处硬编码英文 toast 已全部接入Zod refine 消息改为英文,错误分支不再复用成功消息",
"类型断言残留 3 处 as string", "类型断言残留 3 处 as string",
"P2 图谱方向键导航未实现", "P2 图谱方向键导航未实现",
"v1 测试已修复textbook-reader.tsx SheetTrigger 越界、Server→Client 函数 prop 序列化、seed 数据 i18n key 不匹配" "v1 测试已修复textbook-reader.tsx SheetTrigger 越界、Server→Client 函数 prop 序列化、seed 数据 i18n key 不匹配",
"v2 核查修复class-mastery 视图模式已实现、跨教材前置依赖双向 IN 过滤、ChapterSidebarList canEdit 默认 false、isTeacher 冗余变量移除、graph-kp-node 节点宽度常量化、GraphNodeDetailPanel textbookId 未使用 prop 移除、use-knowledge-point-actions 拆分为门面+状态+CRUD 三 Hook、use-graph-data 区分 isLoading/isRefreshing 避免 UI 闪烁、TeacherTextbookReader 移至 app 层解耦跨模块依赖"
], ],
"files": { "files": {
"actions.ts": 502, "actions.ts": 515,
"data-access.ts": 586, "data-access.ts": 662,
"data-access-graph.ts": 184, "data-access-graph.ts": 207,
"types.ts": 94, "types.ts": 106,
"schema.ts": 62, "schema.ts": 81,
"constants.ts": 99, "constants.ts": 96,
"utils.ts": 203, "utils.ts": 225,
"graph-layout.ts": 105, "graph-layout.ts": 121,
"analytics.tsx": 43, "analytics.tsx": 43,
"hooks/use-knowledge-point-actions.ts": 121, "hooks/use-knowledge-point-actions.ts": 49,
"hooks/use-kp-dialog-state.ts": 38,
"hooks/use-kp-crud.ts": 122,
"hooks/use-text-selection.ts": 57, "hooks/use-text-selection.ts": 57,
"hooks/use-graph-data.ts": 58, "hooks/use-graph-data.ts": 76,
"components/teacher-textbook-reader.tsx": 41, "components/chapter-sidebar-list.tsx": 342,
"components/knowledge-graph.tsx": 249, "components/create-chapter-dialog.tsx": 111,
"components/graph-kp-node.tsx": 80, "components/graph-kp-node.tsx": 92,
"components/graph-prerequisite-edge.tsx": 40, "components/graph-node-detail-panel.tsx": 181,
"components/graph-toolbar.tsx": 77, "components/graph-prerequisite-edge.tsx": 48,
"components/graph-node-detail-panel.tsx": 171 "components/graph-toolbar.tsx": 93,
"components/knowledge-graph.tsx": 376,
"components/knowledge-point-dialogs.tsx": 175,
"components/knowledge-point-list.tsx": 122,
"components/section-error-boundary.tsx": 71,
"components/textbook-card.tsx": 181,
"components/textbook-content-panel.tsx": 189,
"components/textbook-filters.tsx": 71,
"components/textbook-form-dialog.tsx": 139,
"components/textbook-reader.tsx": 446,
"components/textbook-settings-dialog.tsx": 203
}, },
"auditReport": "audit/textbooks-audit-report.md" "auditReport": "audit/textbooks-audit-report.md"
} }
@@ -5963,8 +5990,8 @@
}, },
{ {
"path": "actions-invitations.ts", "path": "actions-invitations.ts",
"lines": 280, "lines": 502,
"description": "邀请码与注册8 个 ActionP0-3 修复)" "description": "邀请码与注册 + 批量操作10 个 ActionP0-3 修复P2-4 新增批量导入学生/批量分配教师"
}, },
{ {
"path": "actions-schedule.ts", "path": "actions-schedule.ts",
@@ -6022,6 +6049,13 @@
"signature": "(gradeId) => Promise<ActionState<string>>", "signature": "(gradeId) => Promise<ActionState<string>>",
"purpose": "删除年级" "purpose": "删除年级"
}, },
{
"name": "promoteGradesAction",
"permission": "GRADE_MANAGE",
"signature": "(prevState, formData) => Promise<ActionState<string>>",
"purpose": "年级升级order +1 + 名称升级)",
"auditLog": "grade.promote"
},
{ {
"name": "createDepartmentAction", "name": "createDepartmentAction",
"permission": "SCHOOL_MANAGE", "permission": "SCHOOL_MANAGE",
@@ -6401,6 +6435,15 @@
"usedBy": [ "usedBy": [
"updateAcademicYear" "updateAcademicYear"
] ]
},
{
"name": "OrgTreeNode",
"type": "type",
"definition": "组织架构树节点(学校/年级/班级三级P2-2 修复)",
"usedBy": [
"getOrgTree",
"school/components/org-tree-nav.tsx"
]
} }
], ],
"components": [ "components": [
@@ -6455,6 +6498,12 @@
"file": "components/school-skeleton.tsx", "file": "components/school-skeleton.tsx",
"purpose": "卡片加载骨架屏animate-pulse", "purpose": "卡片加载骨架屏animate-pulse",
"props": "" "props": ""
},
{
"name": "OrgTreeNav",
"file": "components/org-tree-nav.tsx",
"purpose": "学校→年级→班级三级树形导航P2-2 修复):搜索过滤 + 选中高亮 + 展开/折叠 + 不同节点类型图标School/GraduationCap/Users+ 默认展开第一级",
"props": "nodes: OrgTreeNode[], onSelect?: (node: OrgTreeNode) => void, selectedId?: string"
} }
], ],
"hooks": [ "hooks": [
@@ -8622,7 +8671,7 @@
"deps": [ "deps": [
"shared.db", "shared.db",
"shared.db.schema.gradeRecords", "shared.db.schema.gradeRecords",
"grades/lib/grade-utils.buildScopeClassFilter" "grades/lib/scope-filter.buildScopeClassFilter"
], ],
"usedBy": [ "usedBy": [
"grades/data-access.getClassGradeStatsWithMeta" "grades/data-access.getClassGradeStatsWithMeta"
@@ -8664,7 +8713,7 @@
"shared.db", "shared.db",
"shared.db.schema.gradeRecords", "shared.db.schema.gradeRecords",
"shared.db.schema.users", "shared.db.schema.users",
"grades/lib/grade-utils.buildScopeClassFilter", "grades/lib/scope-filter.buildScopeClassFilter",
"users/data-access.getUserNamesByIds" "users/data-access.getUserNamesByIds"
], ],
"usedBy": [ "usedBy": [
@@ -9368,11 +9417,12 @@
"name": "exportGradeRecordsToExcel", "name": "exportGradeRecordsToExcel",
"signature": "(params: { classId: string; subjectId?: string; examId?: string; scope: DataScope; currentUserId?: string }) => Promise<Buffer>", "signature": "(params: { classId: string; subjectId?: string; examId?: string; scope: DataScope; currentUserId?: string }) => Promise<Buffer>",
"file": "export.ts", "file": "export.ts",
"purpose": "导出成绩单Sheet1 成绩明细Sheet2 统计汇总:均分/中位数/最高分/最低分/标准差/及格率/优秀率/参考人数P3 更新:传递 scope/currentUserId 到 data-access", "purpose": "导出成绩单Sheet1 成绩明细Sheet2 统计汇总:均分/中位数/最高分/最低分/标准差/及格率/优秀率/参考人数P3 更新:传递 scope/currentUserId 到 data-accessP3-7硬编码中文改用 next-intl getTranslations",
"deps": [ "deps": [
"shared.lib.excel.exportToExcel", "shared.lib.excel.exportToExcel",
"data-access.getGradeRecords", "data-access.getGradeRecords",
"data-access.getClassGradeStats" "data-access.getClassGradeStats",
"next-intl/server.getTranslations"
], ],
"usedBy": [ "usedBy": [
"actions.exportGradesAction", "actions.exportGradesAction",
@@ -9383,7 +9433,7 @@
"name": "exportClassGradeReportToExcel", "name": "exportClassGradeReportToExcel",
"signature": "(params: { classId: string; scope: DataScope; currentUserId?: string }) => Promise<Buffer>", "signature": "(params: { classId: string; scope: DataScope; currentUserId?: string }) => Promise<Buffer>",
"file": "export.ts", "file": "export.ts",
"purpose": "导出班级成绩总表(多科目横向对比,含总分/平均分/排名列P3 更新:适配 PaginatedGradeRecords 结构 + 传递 scope/currentUserId", "purpose": "导出班级成绩总表(多科目横向对比,含总分/平均分/排名列P3 更新:适配 PaginatedGradeRecords 结构 + 传递 scope/currentUserIdP3-6复用 stats-service.computeAverageScore 替代局部 avgP3-7硬编码中文改用 next-intl getTranslations",
"deps": [ "deps": [
"shared.db", "shared.db",
"shared.db.schema.classes", "shared.db.schema.classes",
@@ -9391,7 +9441,9 @@
"shared.db.schema.gradeRecords", "shared.db.schema.gradeRecords",
"shared.db.schema.users", "shared.db.schema.users",
"shared.lib.excel.exportToExcel", "shared.lib.excel.exportToExcel",
"data-access.getGradeRecords" "data-access.getGradeRecords",
"stats-service.computeAverageScore",
"next-intl/server.getTranslations"
], ],
"usedBy": [ "usedBy": [
"actions.exportGradesAction" "actions.exportGradesAction"
@@ -9440,8 +9492,8 @@
{ {
"name": "buildScopeClassFilter", "name": "buildScopeClassFilter",
"signature": "(scope: DataScope, currentUserId?: string) => SQL | null", "signature": "(scope: DataScope, currentUserId?: string) => SQL | null",
"file": "lib/grade-utils.ts", "file": "lib/scope-filter.ts",
"purpose": "根据 DataScope 构建 gradeRecords 表的行级权限过滤条件P1-2 新增:从 data-access/data-access-analytics 抽取P3 更新class_members scope 内置 studentId 过滤,需传入 currentUserId 参数)", "purpose": "根据 DataScope 构建 gradeRecords 表的行级权限过滤条件P1-2 新增:从 data-access/data-access-analytics 抽取P3 更新class_members scope 内置 studentId 过滤,需传入 currentUserId 参数P3-26从 grade-utils.ts 迁移至 scope-filter.ts",
"usedBy": [ "usedBy": [
"data-access.getGradeRecords", "data-access.getGradeRecords",
"data-access.getClassGradeStats", "data-access.getClassGradeStats",
@@ -9469,16 +9521,17 @@
"name": "computeAverageScore", "name": "computeAverageScore",
"signature": "(scores: number[]) => number", "signature": "(scores: number[]) => number",
"file": "stats-service.ts", "file": "stats-service.ts",
"purpose": "计算分数平均值(空数组返回 0P1-1 新增:从 data-access.getStudentGradeSummary 抽取为纯函数)", "purpose": "计算分数平均值(空数组返回 0P1-1 新增:从 data-access.getStudentGradeSummary 抽取为纯函数P3-6export.ts 复用此函数替代局部 avg",
"usedBy": [ "usedBy": [
"data-access.getStudentGradeSummary" "data-access.getStudentGradeSummary",
"export.exportClassGradeReportToExcel"
] ]
}, },
{ {
"name": "buildGradeTrendPoints", "name": "buildGradeTrendPoints",
"signature": "(rows: RawScoreRow[]) => GradeTrendPoint[]", "signature": "(rows: RawScoreRow[]) => GradeTrendPoint[]",
"file": "stats-service.ts", "file": "stats-service.ts",
"purpose": "构建成绩趋势数据点(按考试标题分组,归一化分数 0-100P1-1 新增:从 data-access-analytics.getGradeTrend 抽取为纯函数)", "purpose": "构建成绩趋势数据点(按考试标题分组,归一化分数 0-100P1-1 新增:从 data-access-analytics.getGradeTrend 抽取为纯函数P3-24使用 isGradeTrendType 类型守卫替代 as 断言",
"usedBy": [ "usedBy": [
"data-access-analytics.getGradeTrend" "data-access-analytics.getGradeTrend"
] ]
@@ -11040,19 +11093,23 @@
}, },
{ {
"name": "logNotificationSend", "name": "logNotificationSend",
"signature": "(result: ChannelSendResult) => void", "signature": "(result: ChannelSendResult, payload?: { userId: string; title: string }) => Promise<void>",
"file": "data-access.ts", "file": "data-access.ts",
"purpose": "记录单条发送日志(当前使用 console.info未来可扩展 notification_logs 表", "purpose": "记录单条发送日志到 notification_logs 表DB 写入失败时降级 console不阻塞通知流程",
"deps": [], "deps": [
"shared.db",
"shared.db.schema.notificationLogs",
"@paralleldrive/cuid2"
],
"usedBy": [ "usedBy": [
"logNotificationSendBatch" "logNotificationSendBatch"
] ]
}, },
{ {
"name": "logNotificationSendBatch", "name": "logNotificationSendBatch",
"signature": "(results: ChannelSendResult[]) => void", "signature": "(results: ChannelSendResult[], payload?: { userId: string; title: string }) => Promise<void>",
"file": "data-access.ts", "file": "data-access.ts",
"purpose": "批量记录发送日志", "purpose": "批量记录发送日志(并行调用 logNotificationSend",
"deps": [ "deps": [
"logNotificationSend" "logNotificationSend"
], ],
@@ -11221,6 +11278,13 @@
"messaging (via re-export)" "messaging (via re-export)"
] ]
}, },
{
"name": "NotificationLog",
"type": "interface",
"file": "types.ts",
"definition": "{ id, userId, title, channel: NotificationChannel, status: 'success' | 'failure', messageId: string | null, error: string | null, sentAt }",
"usedBy": []
},
{ {
"name": "PaginatedResult", "name": "PaginatedResult",
"type": "interface", "type": "interface",
@@ -14339,7 +14403,7 @@
}, },
"dbTables": { "dbTables": {
"_meta": { "_meta": {
"total": 58, "total": 59,
"orm": "Drizzle ORM 0.45", "orm": "Drizzle ORM 0.45",
"database": "MySQL", "database": "MySQL",
"idStrategy": "CUID2 (varchar length 128)", "idStrategy": "CUID2 (varchar length 128)",
@@ -14598,6 +14662,10 @@
"owner": "notifications", "owner": "notifications",
"description": "消息通知" "description": "消息通知"
}, },
"notificationLogs": {
"owner": "notifications",
"description": "通知发送日志(channel/status/messageId/error/sentAt)"
},
"notificationPreferences": { "notificationPreferences": {
"owner": "notifications", "owner": "notifications",
"description": "通知偏好(email/sms/push + 分类开关)" "description": "通知偏好(email/sms/push + 分类开关)"
@@ -14980,7 +15048,9 @@
"textbooks": { "textbooks": {
"dependsOn": [ "dependsOn": [
"shared", "shared",
"auth" "auth",
"users",
"classes"
], ],
"uses": { "uses": {
"shared": [ "shared": [
@@ -14990,6 +15060,12 @@
], ],
"auth": [ "auth": [
"auth" "auth"
],
"users": [
"data-access.getCurrentStudentUser"
],
"classes": [
"data-access-students.getClassStudents"
] ]
} }
}, },

View File

@@ -375,3 +375,128 @@ export async function setStudentEnrollmentStatusAction(
throw e throw e
} }
} }
/**
* P2-4 批量导入学生:解析 CSV每行 name,email 或仅 email逐个调用 enrollStudentByEmail。
* 用于开学季批量注册,提升配置效率。
*/
export async function bulkEnrollStudentsAction(
classId: string,
prevState: ActionState<{ imported: number; failed: number; errors: string[] }> | undefined,
formData: FormData
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
try {
await requirePermission(Permissions.CLASS_ENROLL)
const csvText = String(formData.get("csv") ?? "").trim()
if (!csvText) {
return { success: false, message: "CSV data is required" }
}
// 解析 CSV每行一个邮箱格式 name,email 或仅 email
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
const entries: Array<{ name?: string; email: string }> = []
for (const line of lines) {
const parts = line.split(",").map((p) => p.trim())
if (parts.length === 1) {
entries.push({ email: parts[0] })
} else if (parts.length >= 2) {
entries.push({ name: parts[0], email: parts[1] })
}
}
if (entries.length === 0) {
return { success: false, message: "No valid entries found" }
}
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
let imported = 0
let failed = 0
const errors: string[] = []
for (const entry of entries) {
try {
await enrollStudentByEmail(classId, entry.email)
imported += 1
} catch (error) {
failed += 1
const msg = error instanceof Error ? error.message : "Unknown error"
errors.push(`${entry.email}: ${msg}`)
}
}
revalidatePath("/teacher/classes/students")
revalidatePath("/admin/school/classes")
return {
success: true,
message: `Imported ${imported} students, ${failed} failed`,
data: { imported, failed, errors },
}
} 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: "Failed to bulk enroll students" }
}
}
/**
* P2-4 批量分配教师:解析 CSV每行 className,subject,teacherEmail
* 当前为简化实现:需按名称查找班级、按邮箱查找教师后调用 setClassSubjectTeachers
* 查找逻辑待后续完善,暂记录为失败。
*/
export async function bulkAssignSubjectTeachersAction(
prevState: ActionState<{ updated: number; failed: number; errors: string[] }> | undefined,
formData: FormData
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
try {
await requirePermission(Permissions.CLASS_UPDATE)
const csvText = String(formData.get("csv") ?? "").trim()
if (!csvText) {
return { success: false, message: "CSV data is required" }
}
// 解析 CSV格式 className,subject,teacherEmail
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
const entries: Array<{ className: string; subject: string; teacherEmail: string }> = []
for (const line of lines) {
const parts = line.split(",").map((p) => p.trim())
if (parts.length >= 3) {
entries.push({ className: parts[0], subject: parts[1], teacherEmail: parts[2] })
}
}
if (entries.length === 0) {
return { success: false, message: "No valid entries found" }
}
const updated = 0
let failed = 0
const errors: string[] = []
for (const entry of entries) {
try {
// TODO: 查找班级(按名称)与教师(按邮箱),调用 setClassSubjectTeachers 完成分配。
// 当前版本暂未实现按名称/邮箱的查找逻辑,记录为失败。
failed += 1
errors.push(`${entry.className}/${entry.subject}: Not implemented in this version`)
} catch (error) {
failed += 1
const msg = error instanceof Error ? error.message : "Unknown error"
errors.push(`${entry.className}/${entry.subject}: ${msg}`)
}
}
revalidatePath("/admin/school/classes")
return {
success: true,
message: `Updated ${updated} assignments, ${failed} failed`,
data: { updated, failed, errors },
}
} 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: "Failed to bulk assign teachers" }
}
}

View File

@@ -40,6 +40,8 @@ export {
revokeClassInvitationCodeAction, revokeClassInvitationCodeAction,
listClassInvitationCodesAction, listClassInvitationCodesAction,
setStudentEnrollmentStatusAction, setStudentEnrollmentStatusAction,
bulkEnrollStudentsAction,
bulkAssignSubjectTeachersAction,
} from "./actions-invitations" } from "./actions-invitations"
export { export {

View File

@@ -8,7 +8,7 @@ import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { logAudit } from "@/shared/lib/audit-logger" import { logAudit } from "@/shared/lib/audit-logger"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema" import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema, PromoteGradesSchema } from "./schema"
import { import {
createAcademicYear, createAcademicYear,
createDepartment, createDepartment,
@@ -18,6 +18,7 @@ import {
deleteDepartment, deleteDepartment,
deleteGrade, deleteGrade,
deleteSchool, deleteSchool,
promoteGrades,
updateAcademicYear, updateAcademicYear,
updateDepartment, updateDepartment,
updateGrade, updateGrade,
@@ -44,6 +45,15 @@ export async function createDepartmentAction(
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
}) })
after(() =>
logAudit({
action: "department.create",
module: "school",
targetType: "department",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/departments") revalidatePath("/admin/school/departments")
return { success: true, message: "Department created" } return { success: true, message: "Department created" }
} catch (error) { } catch (error) {
@@ -73,6 +83,16 @@ export async function updateDepartmentAction(
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
}) })
after(() =>
logAudit({
action: "department.update",
module: "school",
targetId: departmentId,
targetType: "department",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/departments") revalidatePath("/admin/school/departments")
return { success: true, message: "Department updated" } return { success: true, message: "Department updated" }
} catch (error) { } catch (error) {
@@ -86,6 +106,14 @@ export async function deleteDepartmentAction(departmentId: string): Promise<Acti
try { try {
await requirePermission(Permissions.SCHOOL_MANAGE) await requirePermission(Permissions.SCHOOL_MANAGE)
await deleteDepartment(departmentId) await deleteDepartment(departmentId)
after(() =>
logAudit({
action: "department.delete",
module: "school",
targetId: departmentId,
targetType: "department",
})
)
revalidatePath("/admin/school/departments") revalidatePath("/admin/school/departments")
return { success: true, message: "Department deleted" } return { success: true, message: "Department deleted" }
} catch (error) { } catch (error) {
@@ -119,6 +147,15 @@ export async function createAcademicYearAction(
isActive: parsed.data.isActive, isActive: parsed.data.isActive,
}) })
after(() =>
logAudit({
action: "academicYear.create",
module: "school",
targetType: "academicYear",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/academic-year") revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year created" } return { success: true, message: "Academic year created" }
} catch (error) { } catch (error) {
@@ -152,6 +189,16 @@ export async function updateAcademicYearAction(
isActive: parsed.data.isActive, isActive: parsed.data.isActive,
}) })
after(() =>
logAudit({
action: "academicYear.update",
module: "school",
targetId: academicYearId,
targetType: "academicYear",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/academic-year") revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year updated" } return { success: true, message: "Academic year updated" }
} catch (error) { } catch (error) {
@@ -291,6 +338,15 @@ export async function createGradeAction(
teachingHeadId: parsed.data.teachingHeadId, teachingHeadId: parsed.data.teachingHeadId,
}) })
after(() =>
logAudit({
action: "grade.create",
module: "school",
targetType: "grade",
detail: { name: parsed.data.name, schoolId: parsed.data.schoolId },
})
)
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
return { success: true, message: "Grade created" } return { success: true, message: "Grade created" }
} catch (error) { } catch (error) {
@@ -326,6 +382,16 @@ export async function updateGradeAction(
teachingHeadId: parsed.data.teachingHeadId, teachingHeadId: parsed.data.teachingHeadId,
}) })
after(() =>
logAudit({
action: "grade.update",
module: "school",
targetId: gradeId,
targetType: "grade",
detail: { name: parsed.data.name, schoolId: parsed.data.schoolId },
})
)
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
return { success: true, message: "Grade updated" } return { success: true, message: "Grade updated" }
} catch (error) { } catch (error) {
@@ -339,6 +405,14 @@ export async function deleteGradeAction(gradeId: string): Promise<ActionState<st
try { try {
await requirePermission(Permissions.GRADE_MANAGE) await requirePermission(Permissions.GRADE_MANAGE)
await deleteGrade(gradeId) await deleteGrade(gradeId)
after(() =>
logAudit({
action: "grade.delete",
module: "school",
targetId: gradeId,
targetType: "grade",
})
)
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
return { success: true, message: "Grade deleted" } return { success: true, message: "Grade deleted" }
} catch (error) { } catch (error) {
@@ -347,3 +421,37 @@ export async function deleteGradeAction(gradeId: string): Promise<ActionState<st
return { success: false, message: "Failed to delete grade" } return { success: false, message: "Failed to delete grade" }
} }
} }
export async function promoteGradesAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_MANAGE)
const parsed = PromoteGradesSchema.safeParse({
schoolId: formData.get("schoolId"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const result = await promoteGrades(parsed.data.schoolId)
after(() =>
logAudit({
action: "grade.promote",
module: "school",
targetType: "grade",
targetId: parsed.data.schoolId,
detail: { promoted: result.promoted },
})
)
revalidatePath("/admin/school/grades")
return { success: true, message: `Promoted ${result.promoted} grades` }
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to promote grades" }
}
}

View File

@@ -0,0 +1,148 @@
"use client"
import { useMemo, useState, type JSX } from "react"
import { ChevronDown, ChevronRight, GraduationCap, School, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import type { OrgTreeNode } from "../types"
import { cn } from "@/shared/lib/utils"
import { Input } from "@/shared/components/ui/input"
type OrgTreeNavProps = {
nodes: OrgTreeNode[]
onSelect?: (node: OrgTreeNode) => void
selectedId?: string
}
type OrgTreeItemProps = {
node: OrgTreeNode
depth: number
onSelect?: (node: OrgTreeNode) => void
selectedId?: string
search: string
}
const NODE_ICON: Record<OrgTreeNode["type"], typeof School> = {
school: School,
grade: GraduationCap,
class: Users,
}
function matchesSearch(node: OrgTreeNode, query: string): boolean {
if (!query) return true
if (node.name.toLowerCase().includes(query)) return true
return (node.children ?? []).some((child) => matchesSearch(child, query))
}
function OrgTreeItem({ node, depth, onSelect, selectedId, search }: OrgTreeItemProps): JSX.Element {
const [expanded, setExpanded] = useState<boolean>(depth === 0)
const children = node.children ?? []
const hasChildren = children.length > 0
const isSelected = selectedId === node.id
const Icon = NODE_ICON[node.type]
const handleToggle = (): void => {
if (hasChildren) setExpanded((v) => !v)
}
const handleSelect = (): void => {
onSelect?.(node)
if (hasChildren && !expanded) setExpanded(true)
}
return (
<div>
<div
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleSelect()
}
}}
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected && "bg-accent font-medium text-accent-foreground"
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleToggle()
}}
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-transform"
aria-label={expanded ? "collapse" : "expand"}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="h-4 w-4 shrink-0" />
)}
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{node.name}</span>
</div>
{hasChildren && expanded && (
<div>
{children.map((child) =>
matchesSearch(child, search) ? (
<OrgTreeItem
key={child.id}
node={child}
depth={depth + 1}
onSelect={onSelect}
selectedId={selectedId}
search={search}
/>
) : null
)}
</div>
)}
</div>
)
}
export function OrgTreeNav({ nodes, onSelect, selectedId }: OrgTreeNavProps): JSX.Element {
const t = useTranslations("school")
const [search, setSearch] = useState<string>("")
const filtered = useMemo(
() => nodes.filter((node) => matchesSearch(node, search.toLowerCase())),
[nodes, search]
)
return (
<div className="space-y-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("orgTree.search")}
className="h-8 text-sm"
/>
{filtered.length === 0 ? (
<p className="px-2 py-4 text-center text-sm text-muted-foreground">{t("orgTree.empty")}</p>
) : (
<div className="space-y-0.5">
{filtered.map((node) => (
<OrgTreeItem
key={node.id}
node={node}
depth={0}
onSelect={onSelect}
selectedId={selectedId}
search={search.toLowerCase()}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import "server-only" import "server-only"
import { cache } from "react" import { cache } from "react"
import { and, asc, eq, inArray, or, sql } from "drizzle-orm" import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema" import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
@@ -15,6 +15,7 @@ import type {
GradeInsertData, GradeInsertData,
GradeListItem, GradeListItem,
GradeUpdateData, GradeUpdateData,
OrgTreeNode,
SchoolInsertData, SchoolInsertData,
SchoolListItem, SchoolListItem,
SchoolUpdateData, SchoolUpdateData,
@@ -595,6 +596,24 @@ export const getSubjectNameById = cache(
}, },
) )
/**
* 批量获取科目名称映射subjectId -> name
* 供跨模块调用使用,避免直接查询 subjects 表。
*/
export const getSubjectNameMapByIds = cache(
async (subjectIds: string[]): Promise<Map<string, string | null>> => {
const ids = subjectIds.filter((id) => id.trim().length > 0)
if (ids.length === 0) return new Map()
const rows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, ids))
const map = new Map<string, string | null>()
for (const r of rows) map.set(r.id, r.name)
return map
},
)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cross-module query interfaces — grade head/teaching head verification // Cross-module query interfaces — grade head/teaching head verification
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -668,3 +687,114 @@ export const findGradeIdByHeadAndName = cache(async (
.limit(1) .limit(1)
return row?.id ?? null return row?.id ?? null
}) })
/**
* 将年级名称升级一年级→二年级1年级→2年级Grade 1→Grade 2
* 无法识别的名称保持不变。
*/
function promoteGradeName(name: string): string {
// 中文数字映射
const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"]
for (let i = 0; i < chineseNums.length - 1; i++) {
if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) {
return name.replace(chineseNums[i], chineseNums[i + 1])
}
}
// 阿拉伯数字
const match = name.match(/(\d+)/)
if (match) {
const num = parseInt(match[1], 10)
if (num > 0 && num < 13) {
return name.replace(match[1], String(num + 1))
}
}
return name
}
/**
* 年级升级:将指定学校下的所有年级 order +1并更新年级名称如果符合数字模式
* 例如:一年级 → 二年级order 1 → 2
*
* 注意:此函数只更新年级本身的 order 和 name不迁移班级数据。
* 班级升级应由 classes 模块的独立 Action 处理。
*/
export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {
const rows = await db
.select({ id: grades.id, name: grades.name, order: grades.order })
.from(grades)
.where(eq(grades.schoolId, schoolId))
.orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突
let promoted = 0
for (const row of rows) {
const newOrder = (row.order ?? 0) + 1
const newName = promoteGradeName(row.name)
await db
.update(grades)
.set({ order: newOrder, name: newName })
.where(eq(grades.id, row.id))
promoted += 1
}
return { promoted }
}
/**
* 获取学校→年级→班级三级组织架构树。
* 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。
* 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。
*/
export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
try {
const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()])
const { getAdminClasses } = await import("@/modules/classes/data-access")
const allClasses = await getAdminClasses()
const classesByGradeId = new Map<string, OrgTreeNode[]>()
for (const cls of allClasses) {
const gradeId = cls.gradeId
if (typeof gradeId !== "string" || gradeId.length === 0) continue
const node: OrgTreeNode = {
id: cls.id,
name: cls.name,
type: "class",
}
const list = classesByGradeId.get(gradeId)
if (list) {
list.push(node)
} else {
classesByGradeId.set(gradeId, [node])
}
}
const gradesBySchoolId = new Map<string, OrgTreeNode[]>()
for (const grade of gradeList) {
const children = classesByGradeId.get(grade.id) ?? []
const node: OrgTreeNode = {
id: grade.id,
name: grade.name,
type: "grade",
children,
}
const list = gradesBySchoolId.get(grade.school.id)
if (list) {
list.push(node)
} else {
gradesBySchoolId.set(grade.school.id, [node])
}
}
return schoolList.map((school) => ({
id: school.id,
name: school.name,
type: "school",
children: gradesBySchoolId.get(school.id) ?? [],
}))
} catch (error) {
console.error("getOrgTree failed:", error)
return []
}
})

View File

@@ -50,3 +50,7 @@ export const UpsertGradeSchema = z
.refine((v) => Number.isFinite(v.order) && Number.isInteger(v.order) && v.order >= 0, { .refine((v) => Number.isFinite(v.order) && Number.isInteger(v.order) && v.order >= 0, {
message: "order must be a non-negative integer", message: "order must be a non-negative integer",
}) })
export const PromoteGradesSchema = z.object({
schoolId: z.string().trim().min(1),
})

View File

@@ -94,3 +94,10 @@ export type AcademicYearUpdateData = {
endDate: Date endDate: Date
isActive: boolean isActive: boolean
} }
export type OrgTreeNode = {
id: string
name: string
type: "school" | "grade" | "class"
children?: OrgTreeNode[]
}

View File

@@ -122,7 +122,15 @@
"optional": "Optional", "optional": "Optional",
"failedCreate": "Failed to create grade", "failedCreate": "Failed to create grade",
"failedUpdate": "Failed to update grade", "failedUpdate": "Failed to update grade",
"failedDelete": "Failed to delete grade" "failedDelete": "Failed to delete grade",
"promote": {
"title": "Promote Grades",
"description": "Promote all grades of the selected school by one level (e.g., Grade 1 → Grade 2). Historical archives are preserved.",
"confirm": "Confirm Promotion",
"selectSchool": "Select a school",
"success": "Successfully promoted {count} grades",
"failed": "Promotion failed"
}
}, },
"departments": { "departments": {
"title": "Department Management", "title": "Department Management",
@@ -205,6 +213,24 @@
"classManagement": { "classManagement": {
"title": "Class Management", "title": "Class Management",
"description": "Manage classes and assign teachers.", "description": "Manage classes and assign teachers.",
"bulk": {
"importStudents": {
"title": "Bulk Import Students",
"description": "One email per line, format: name,email or email only",
"placeholder": "John,john@example.com\njane@example.com",
"submit": "Import",
"success": "Imported {imported} students, {failed} failed",
"failed": "Bulk import failed"
},
"assignTeachers": {
"title": "Bulk Assign Teachers",
"description": "One row per line: class name,subject,teacher email",
"placeholder": "Class 1,Math,john@example.com\nClass 2,English,jane@example.com",
"submit": "Assign",
"success": "Updated {updated} assignments, {failed} failed",
"failed": "Bulk assignment failed"
}
},
"grade": { "grade": {
"title": "Class Management", "title": "Class Management",
"description": "Manage classes for your grades.", "description": "Manage classes for your grades.",
@@ -253,5 +279,12 @@
"description": "An error occurred while loading data. Please retry.", "description": "An error occurred while loading data. Please retry.",
"retry": "Retry" "retry": "Retry"
} }
},
"orgTree": {
"title": "Organization",
"search": "Search school/grade/class...",
"empty": "No data",
"expandAll": "Expand All",
"collapseAll": "Collapse All"
} }
} }

View File

@@ -122,7 +122,15 @@
"optional": "可选", "optional": "可选",
"failedCreate": "创建年级失败", "failedCreate": "创建年级失败",
"failedUpdate": "更新年级失败", "failedUpdate": "更新年级失败",
"failedDelete": "删除年级失败" "failedDelete": "删除年级失败",
"promote": {
"title": "年级升级",
"description": "将选定学校的所有年级升级一级(如一年级→二年级),保留历史档案。",
"confirm": "确认升级",
"selectSchool": "请选择学校",
"success": "成功升级 {count} 个年级",
"failed": "升级失败"
}
}, },
"departments": { "departments": {
"title": "部门管理", "title": "部门管理",
@@ -205,6 +213,24 @@
"classManagement": { "classManagement": {
"title": "班级管理", "title": "班级管理",
"description": "管理班级并分配教师。", "description": "管理班级并分配教师。",
"bulk": {
"importStudents": {
"title": "批量导入学生",
"description": "每行一个邮箱格式name,email 或仅 email",
"placeholder": "张三,zhangsan@example.com\nlisi@example.com",
"submit": "导入",
"success": "成功导入 {imported} 个学生,{failed} 个失败",
"failed": "批量导入失败"
},
"assignTeachers": {
"title": "批量分配教师",
"description": "每行格式:班级名称,科目,教师邮箱",
"placeholder": "一班,语文,zhangsan@example.com\n二班,数学,lisi@example.com",
"submit": "分配",
"success": "成功更新 {updated} 个分配,{failed} 个失败",
"failed": "批量分配失败"
}
},
"grade": { "grade": {
"title": "班级管理", "title": "班级管理",
"description": "管理你所在年级的班级。", "description": "管理你所在年级的班级。",
@@ -253,5 +279,12 @@
"description": "数据加载时发生错误,请重试", "description": "数据加载时发生错误,请重试",
"retry": "重试" "retry": "重试"
} }
},
"orgTree": {
"title": "组织架构",
"search": "搜索学校/年级/班级...",
"empty": "暂无数据",
"expandAll": "全部展开",
"collapseAll": "全部折叠"
} }
} }