Compare commits
17 Commits
d884c6d513
...
0c64219cb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c64219cb8 | ||
|
|
1f833097e2 | ||
|
|
e3b8455b31 | ||
|
|
37d2688a28 | ||
|
|
8c2fe14c20 | ||
|
|
c9e46f9f80 | ||
|
|
f0f713ff33 | ||
|
|
0cee93676b | ||
|
|
6bc113eaff | ||
|
|
a48e7d0e27 | ||
|
|
61e76f0d67 | ||
|
|
d7876c5854 | ||
|
|
9783be58c0 | ||
|
|
e4254f0f8e | ||
|
|
9d87388524 | ||
|
|
eb28a523cb | ||
|
|
7e320d78c1 |
406
bugs/teacher_web_test_post_audit.json
Normal file
406
bugs/teacher_web_test_post_audit.json
Normal file
File diff suppressed because one or more lines are too long
1673
bugs/teacher_web_test_post_audit.md
Normal file
1673
bugs/teacher_web_test_post_audit.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,7 +113,7 @@
|
||||
│ │ data-access │
|
||||
┌─┴────────┐ │ │
|
||||
│ grades │◀────┘ 仅外键引用(合理)
|
||||
│ (成绩) │
|
||||
│ (成绩) │◀──── exams data-access(✅ 2026-06-24 新增:getExamsForGradeEntry/getExamForGradeEntry 按试卷录入成绩)
|
||||
└────┬─────┘
|
||||
│ ✅ P1-1 已修复
|
||||
│ 通过 classes/school/users data-access
|
||||
@@ -499,7 +499,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `lib/ai.ts` | 9 | 向后兼容重导出(P2-2 已拆分到 `ai/` 目录) |
|
||||
| `lib/ai/payload-parser.ts` | 78 | 请求负载解析 |
|
||||
| `lib/ai/api-key-crypto.ts` | 28 | API Key 加密/解密 |
|
||||
| `lib/ai/provider-config.ts` | 61 | Provider 配置查询 |
|
||||
| `lib/ai/provider-config.ts` | 132 | Provider 配置查询(V3.1 增强:基于 session 用户的可见性/所有权校验,public + own private 过滤) |
|
||||
| `lib/ai/client.ts` | 58 | AI 客户端创建与调用 |
|
||||
| `lib/ai/errors.ts` | 8 | 错误格式化 |
|
||||
| `lib/ai/index.ts` | 5 | 聚合导出 |
|
||||
@@ -540,16 +540,17 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:考试全生命周期管理(创建/编辑/预览/发布/删除/复制)+ AI 辅助出题。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access)
|
||||
- Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades`(后 7 个为 P1-2 新增)
|
||||
- Actions:`createExamAction` / `createAiExamAction` / `previewAiExamAction` / `regenerateAiQuestionAction` / `updateExamAction` / `deleteExamAction` / `duplicateExamAction` / `getExamPreviewAction` / `getSubjectsAction` / `getGradesAction` / `getExamsByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度3,按 gradeId 查询年级下所有考试 + 提交统计,EXAM_READ 权限)(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access)
|
||||
- Data-access:`getExams` / `getExamById` / `persistExamDraft` / `persistAiGeneratedExamDraft` / `buildExamDescription` / `resolveSubjectGradeNames` / `getExamCreatorId` / `updateExamWithQuestions` / `deleteExamById` / `duplicateExam` / `getExamPreview` / `getExamSubjects` / `getExamGrades` / `getExamsByGradeId`(✅ v4-P2-7 新增:年级仪表盘维度3,exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分,支持 scope 行级过滤)(后 8 个为 P1-2 新增)/ `getExamsForGradeEntry`(✅ 2026-06-24 新增:按 scope 过滤试卷列表,只返回有题目的试卷,供成绩录入页试卷选择器使用,返回 id/title/subjectName/gradeName/questionCount/totalScore)/ `getExamForGradeEntry`(✅ 2026-06-24 新增:获取单个试卷详情含题目列表,innerJoin questions 获取 type,含 scope 校验,返回 id/title/subjectId/gradeId/totalScore/questions[{id,order,score,type}],供 grades 模块按试卷录入成绩使用)
|
||||
- AI Pipeline:`generateAiCreateDraftFromSource` / `generateAiPreviewData` / `regenerateAiQuestionByInstruction`
|
||||
- Utils:`normalizeStructure`(v3 新增:将持久化的 `exam.structure` unknown JSON 运行时校验并归一化为类型安全的 `ExamNode[]`,类型守卫模式无 `as` 断言,从 `teacher/exams/[id]/build/page.tsx` 提取)
|
||||
- Stats-service(V3-8 新增):`getExamAnalytics`(cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型
|
||||
- Types(✅ 2026-06-24 新增成绩录入相关类型):`ExamQuestionItem`(试卷中单个题目的精简结构 { id, order, score, type })/ `ExamForGradeEntry`(成绩录入用的试卷详情,含题目列表)/ `ExamOptionForEntry`(成绩录入页试卷选择器选项 { id, title, subjectName, gradeName, questionCount, totalScore })
|
||||
- Components(V3-8 新增):`ExamAnalyticsDashboard`(考试分析仪表盘:汇总卡片+分数段分布+逐题分析表)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`questions`(✅ P0-1 已修复:通过 data-access.createQuestionWithRelations)、`classes`(✅ P0-2 已修复:通过 data-access.getClassGradeIdsByClassIds)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`homework`(V3-8 新增:stats-service 通过 `homework/data-access.getHomeworkAssignmentsByExamId` / `getGradedSubmissionsByExamId` 获取作业与提交数据,合理跨模块调用)
|
||||
- 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(通过 data-access,P0-4 已修复)、`proctoring`(✅ P1-1 已修复:通过 exams data-access)、`diagnostic`(✅ P1-1 已修复:通过 exams data-access)
|
||||
- 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(通过 data-access,P0-4 已修复)、`proctoring`(✅ P1-1 已修复:通过 exams data-access)、`diagnostic`(✅ P1-1 已修复:通过 exams data-access)、`grades`(✅ 2026-06-24 新增:通过 data-access.getExamsForGradeEntry/getExamForGradeEntry 获取试卷列表和详情供按试卷录入成绩使用)
|
||||
|
||||
**已知问题**:
|
||||
- ✅ P0-1 已修复:~~`persistAiGeneratedExamDraft` 直接 insert 到 `questions` 表~~ 改为调用 `questions/data-access.createQuestionWithRelations`,通过 ID 映射保持 structure 引用一致
|
||||
@@ -565,9 +566,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|------|------|------|
|
||||
| `actions.ts` | 691 | 10 个 Server Action(P1-2 已修复,无直接 DB 操作) |
|
||||
| `ai-pipeline.ts` | 857 | AI 出题管线(超限) |
|
||||
| `data-access.ts` | 473 | 考试 CRUD(含 P1-2 新增 7 个写/查询函数,P0-1/P0-2 已修复:通过 questions/classes data-access 跨模块通信) |
|
||||
| `data-access.ts` | 560+ | 考试 CRUD(含 P1-2 新增 7 个写/查询函数,P0-1/P0-2 已修复:通过 questions/classes data-access 跨模块通信;v4-P2-7 新增 getExamsByGradeId;2026-06-24 新增 getExamsForGradeEntry/getExamForGradeEntry 供 grades 模块按试卷录入成绩) |
|
||||
| `stats-service.ts` | - | V3-8 新增:考试分析数据聚合(`getExamAnalytics` + `ExamAnalyticsSummary` 类型) |
|
||||
| `types.ts` | 31 | 类型定义 |
|
||||
| `types.ts` | 50+ | 类型定义(2026-06-24 新增:ExamQuestionItem/ExamForGradeEntry/ExamOptionForEntry 供成绩录入使用) |
|
||||
| `hooks/use-exam-preview.ts` | 295 | 预览 Hook |
|
||||
| `utils/normalize-structure.ts` | 57 | v3 新增:exam.structure 运行时校验与归一化(从 build/page.tsx 提取) |
|
||||
| `components/exam-analytics-dashboard.tsx` | - | V3-8 新增:考试分析仪表盘组件 |
|
||||
@@ -725,16 +726,16 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:成绩分析(录入/查询/统计/导出/趋势对比分析)。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction`(v4-P1-6 增强:成绩录入后通知学生和家长,调用 `notifyGradeEntered`)/ `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction`(v4-P1-12 增强:新增可选 `studentId` 参数,支持按学生导出,家长视角调用 `exportStudentGradeRecordsToExcel`,校验 studentId 属于家长子女)/ `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`(v4-P1-6 增强:批量成绩录入后通知学生和家长)/ `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 新增:成绩录入草稿 CRUD,upsert + 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)
|
||||
- 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 参数)
|
||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction`(v4-P1-6 增强:成绩录入后通知学生和家长,调用 `notifyGradeEntered`)/ `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction`(v4-P1-12 增强:新增可选 `studentId` 参数,支持按学生导出,家长视角调用 `exportStudentGradeRecordsToExcel`,校验 studentId 属于家长子女)/ `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getGradeDistributionByGradeIdAction`(✅ v4-P2-7 新增:年级仪表盘维度1,按 gradeId 查询年级整体 + 按班级拆分的成绩分布,GRADE_RECORD_READ 权限)/ `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`(v4-P1-6 增强:批量成绩录入后通知学生和家长)/ `batchCreateGradeRecordsByExamAction`(✅ 2026-06-24 新增:按试卷批量录入每题得分,流程 requirePermission → getExamForGradeEntry scope 校验 → assertClassInScope → safeJsonParse → BatchGradeEntryByExamSchema 校验 → batchCreateGradeRecordsByExam 单事务写入 → updateMasteryFromExamScore → notifyGradeEntered → revalidatePath,返回 gradeRecordId 列表供撤销)/ `saveGradeDraftAction` / `getGradeDraftAction` / `deleteGradeDraftAction`(✅ v3-P2 新增:成绩录入草稿 Server Actions,分别使用 GRADE_RECORD_MANAGE/GRADE_RECORD_READ/GRADE_RECORD_MANAGE 权限)。注:`assertClassInScope` 原位于 actions.ts(✅ P3 新增导出:班级 scope 校验工具,供 actions-analytics 复用),✅ v4-P2-6 修复:因 "use server" 文件要求所有 export 为 async,而 `assertClassInScope` 是同步函数,已迁移至独立文件 `lib/scope-check.ts`,actions.ts 与 actions-analytics.ts 均从 `./lib/scope-check` 导入
|
||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getGradeDistributionByGradeId`(✅ v4-P2-7 新增:年级仪表盘维度1,通过 getClassesByGradeId 获取年级下所有班级,inArray 查询成绩记录,复用 computeGradeDistribution/computeGradeStats 纯函数,返回整体分布 + 按班级拆分)/ `getRankingTrend` / `PaginatedGradeRecords`(✅ P3 新增:分页结果接口 `{ records, total }`)/ `saveGradeDraft` / `getGradeDraft` / `deleteGradeDraft`(✅ v3-P2 新增:成绩录入草稿 CRUD,upsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)/ `batchCreateGradeRecordsByExam`(✅ 2026-06-24 新增:按试卷批量录入成绩,单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),使错题集/成绩分析等下游模块无需改造即可读取教师录入的成绩,返回 gradeRecordId 列表供撤销)
|
||||
- Types(✅ v3-P2 新增,✅ v4-P2-7 新增年级分布类型,✅ 2026-06-24 新增按试卷录入类型):`SchoolWideGradeSummaryItem` / `SchoolWideGradeSummary` / `GradeDraftData`(草稿数据接口)/ `GradeDistributionByGradeResult`(✅ v4-P2-7 新增:年级维度成绩分布结果,含 overall 整体分布 + stats 统计 + byClass 按班级拆分数组)/ `GradeDistributionByGradeClassItem`(✅ v4-P2-7 新增:按班级拆分的分布项:classId/className/distribution/stats)/ `GradeRecordAnswer`(✅ 2026-06-24 新增:成绩记录-题目得分明细,对应 grade_record_answers 表)/ `BatchGradeEntryByExamQuestion`(✅ 2026-06-24 新增:按试卷录入时单个题目得分 { questionId, score })/ `BatchGradeEntryByExamItem`(✅ 2026-06-24 新增:按试卷录入时单个学生所有题目得分 { studentId, answers })
|
||||
- Lib(✅ P1-2 新增,✅ P3 更新签名,✅ P3-26 拆分,✅ v4-P2-6 新增 scope-check):`toNumber` / `normalize`(位于 `lib/grade-utils.ts`);`buildScopeClassFilter(scope, currentUserId?)`(P3-26 从 grade-utils.ts 迁移至 `lib/scope-filter.ts`,P3 修复:`class_members` scope 内置 studentId 过滤,需传入 currentUserId 参数);`assertClassInScope(scope: DataScope, classId: string): string | null`(✅ v4-P2-6 从 actions.ts 迁移至 `lib/scope-check.ts`:校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用。迁移原因:actions.ts 是 "use server" 文件要求所有 export 为 async,而 assertClassInScope 是同步函数)
|
||||
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
||||
- Export(✅ v4-P1-12 新增):`exportGradeRecordsToExcel` / `exportClassGradeReportToExcel` / `exportStudentGradeRecordsToExcel`(v4-P1-12 新增:导出单个学生成绩单家长视角,仅含成绩明细 + 个人统计,不含班级数据,scope 为 children 自动按 studentId 过滤)/ `formatDateForFile`(已迁移至 shared/lib/utils)
|
||||
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)/ `SchoolWideSummaryCard`(✅ v3-P2 新增:管理员全校成绩汇总卡片,4 个统计卡片 + 各年级对比表格)/ `ScoreCell`(✅ v4-P1-7 新增:成绩单元格组件,根据得分率着色——红<60%/黄60-84%/绿≥85%,使用语义化 Tailwind 类名避免动态拼接,fullScore<=0 时不着色)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId/getClassGradeIdsByClassIds)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)、`exams`(✅ 2026-06-24 新增:通过 exams data-access.getExamsForGradeEntry/getExamForGradeEntry 获取试卷列表和详情供按试卷录入成绩使用)
|
||||
- 被依赖:`parent`(通过 data-access,合理)、`dashboard`
|
||||
|
||||
**已知问题**:
|
||||
@@ -783,21 +784,24 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ v3-P3-1 改进(2026-06-23):`batch-grade-entry.tsx` 新增"下载模板"按钮,客户端生成 CSV 模板(含学生姓名/分数/备注列头 + BOM 支持 Excel UTF-8),教师可下载填好后粘贴到录入表格
|
||||
- ✅ v3-P3-2 改进(2026-06-23):`grade-record-list.tsx` 新增多选复选框(全选/单选)+ 批量删除工具栏 + 批量删除确认对话框;新增 `bulkDeleteGradeRecords` data-access 函数(使用 inArray 一次性删除避免 N+1)+ `bulkDeleteGradeRecordsAction` Server Action(限制单次最多 500 条)
|
||||
- ✅ v4-P3-2 改进(2026-06-23):`batch-grade-entry.tsx` 顶部新增可折叠新手引导提示框(4 步使用说明),使用 localStorage 记住用户关闭状态避免重复显示
|
||||
- ✅ v4-P2-6 修复(2026-06-23):~~`assertClassInScope` 是同步函数但位于 "use server" 文件 actions.ts 中~~ Next.js 要求 "use server" 文件中所有 export 必须为 async,同步 export 会导致构建错误。修复:将 `assertClassInScope` 迁移至独立文件 `lib/scope-check.ts`(含 `import "server-only"`),actions.ts 与 actions-analytics.ts 均从 `./lib/scope-check` 导入
|
||||
- ✅ 2026-06-24 重新设计:批量录入从"只录总分"改为"按试卷录入每题得分"。新增 `grade_record_answers` 表存储每题得分明细(迁移 0010_grade_record_answers.sql)。`batchCreateGradeRecordsByExam` data-access 单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions(status=graded) + submission_answers(answerContent=null),使错题集/成绩分析等下游模块无需改造即可读取教师录入的成绩(学生只看到对错,不知道学生答案)。`BatchGradeEntryByExam` 组件完全重写为 Excel 式表格(行=学生,列=题目,末列=总分自动计算),支持多行多列粘贴、Enter 跳下一行、Tab 跳下一格、分数校验、撤销机制(sessionStorage 5 分钟有效)。新增对 `exams` 模块的依赖(getExamsForGradeEntry/getExamForGradeEntry)。i18n 新增 batchByExam 章节(zh-CN/en ~35 键)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | 670+ | 19 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页;v3-P2 新增:saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction;v4-P1-6:createGradeRecordAction/batchCreateGradeRecordsAction 新增通知;v4-P1-12:exportGradesAction 新增 studentId 参数;v3-P3-2 新增:bulkDeleteGradeRecordsAction 批量删除) |
|
||||
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验) |
|
||||
| `data-access.ts` | 450+ | 成绩 CRUD + 统计 + 草稿(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P2 新增:saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口;v3-P3-2 新增:bulkDeleteGradeRecords 使用 inArray 批量删除) |
|
||||
| `actions.ts` | 770+ | 20 个 Server Action(含 Zod 校验,含 v2-P1-5 安全修复:assertClassInScope + 行级 scope 校验;P3 修复:handleActionError + safeJsonParse + scope 传递 + DB 层分页;v3-P2 新增:saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction;v4-P1-6:createGradeRecordAction/batchCreateGradeRecordsAction 新增通知;v4-P1-12:exportGradesAction 新增 studentId 参数;v3-P3-2 新增:bulkDeleteGradeRecordsAction 批量删除;v4-P2-6:assertClassInScope 迁移至 lib/scope-check.ts;2026-06-24 新增:batchCreateGradeRecordsByExamAction 按试卷录入每题得分) |
|
||||
| `actions-analytics.ts` | 170 | 5 个分析 Action(含 Zod 校验,P3 修复:handleActionError + assertClassInScope 校验;v4-P2-6:assertClassInScope 改从 ./lib/scope-check 导入) |
|
||||
| `data-access.ts` | 600+ | 成绩 CRUD + 统计 + 草稿 + 按试卷录入(含 v2-P2-9 修复:recorderName 批量查询;P3 修复:PaginatedGradeRecords 接口 + DB 层分页 + 事务 + 存在性检查 + scope 过滤 + 并列排名;v3-P2 新增:saveGradeDraft/getGradeDraft/deleteGradeDraft + GradeDraftData 接口;v3-P3-2 新增:bulkDeleteGradeRecords 使用 inArray 批量删除;2026-06-24 新增:batchCreateGradeRecordsByExam 单事务写入 grade_records + grade_record_answers + 投影到 exam_submissions/submission_answers) |
|
||||
| `data-access-analytics.ts` | 200+ | 趋势/对比分析(P3 修复:getClassComparison 应用 buildScopeClassFilter;v3-P2 新增:getExamOptionsForGrades/getSchoolWideGradeSummary;getGradeTrend/getClassComparison/getSubjectComparison/getGradeDistribution 新增 semester/examId 可选参数) |
|
||||
| `data-access-ranking.ts` | 83 | 排名查询(P3 修复:getRankingTrend 接受 scope 参数 + class_taught 校验) |
|
||||
| `stats-service.ts` | 285 | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口;P3-10:createDefaultBuckets 改为内部函数;P3-24:buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) |
|
||||
| `export.ts` | 290+ | Excel 导出(v2-P1-5 修复:传递 currentUserId 到 data-access;P3 修复:适配 PaginatedGradeRecords 结构 + 传递 scope;P3-6:复用 stats-service.computeAverageScore 替代局部 avg;P3-7:硬编码中文改用 next-intl getTranslations;v4-P1-12 新增:exportStudentGradeRecordsToExcel 家长视角单学生导出) |
|
||||
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行) |
|
||||
| `schema.ts` | 130+ | Zod 校验(含 12 个查询 schema;P3 修复:score .max(1000) + records .max(500) + 补全查询字段;v3-P2 新增:grade_drafts 表定义第 1444-1469 行;2026-06-24 新增:BatchGradeEntryByExamSchema 按试卷录入校验 + BatchGradeEntryByExamQuestionSchema/ItemSchema 子 schema,导出 BatchGradeEntryByExamInput 类型) |
|
||||
| `lib/grade-utils.ts` | 20 | 公共工具函数(toNumber/normalize;P3-26:buildScopeClassFilter 迁移至 scope-filter.ts) |
|
||||
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤(buildScopeClassFilter;P3-26 从 grade-utils.ts 迁移;v2-P2-2 修复:改用 classes data-access 子查询;P3 修复:新增 currentUserId 参数) |
|
||||
| `types.ts` | 168+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary) |
|
||||
| `lib/scope-check.ts` | 34 | v4-P2-6 新增:班级 scope 校验工具(assertClassInScope 同步函数,从 actions.ts 迁移至此独立文件以避开 "use server" 文件要求 export 必须为 async 的限制;含 `import "server-only"`) |
|
||||
| `types.ts` | 200+ | 类型定义(v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary;2026-06-24 新增:GradeRecordAnswer/BatchGradeEntryByExamQuestion/BatchGradeEntryByExamItem) |
|
||||
| `components/widget-boundary.tsx` | 136 | Widget 边界组件(P1-5 新增,v2-P1-1 已在 3 个页面应用) |
|
||||
| `components/school-wide-summary-card.tsx` | - | v3-P2 新增:管理员全校成绩汇总卡片(4 个统计卡片 + 各年级对比表格) |
|
||||
| `components/score-cell.tsx` | 41 | v4-P1-7 新增:成绩单元格组件,根据得分率着色(红<60%/黄60-84%/绿≥85%),使用语义化 Tailwind 类名 |
|
||||
@@ -808,7 +812,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `components/class-comparison-chart.tsx` | 194 | 班级对比图(v2-P1-4:i18n;v3-P3-5 新增:显著性分析区域,基于极差和样本量的经验规则判断班级间差异,含可折叠详细分析) |
|
||||
| `components/grade-trend-chart.tsx` | 59 | 趋势图(v2-P1-4:i18n) |
|
||||
| `components/grade-record-form.tsx` | 177 | 录入表单(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall 包装提交) |
|
||||
| `components/batch-grade-entry.tsx` | 500+ | 批量录入(v2-P2-7 修复:Label htmlFor;v2-P1-4:i18n;P3 修复:safeActionCall + localStorage 安全检查 + 区分未录入与录入 0;v3-P2 新增:接入服务端草稿 saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftAction;v3-P3-1 新增:下载 CSV 录入模板按钮含学生姓名列表;v4-P3-2 新增:可折叠新手引导提示框,localStorage 记住关闭状态) |
|
||||
| `components/batch-grade-entry.tsx` | 600+ | 2026-06-24 完全重写:导出名改为 BatchGradeEntryByExam,按试卷录入每题得分。Excel 式表格(行=学生,列=题目,末列=总分自动计算)。交互:试卷选择器(按 scope 过滤)→ 班级选择器(按试卷 gradeId 过滤);多行多列 Excel 粘贴(Tab 分隔);Enter 跳下一行同一列;Tab 跳下一格;实时统计(已录入/总数/均分/最高/最低);分数校验(超过题目满分标红);撤销机制(sessionStorage 5 分钟有效)。Props: exams/classes/classGradeMap/exam/students/defaultExamId?/defaultClassId?(原 v2-P2-7 Label htmlFor;v2-P1-4 i18n;P3 safeActionCall + localStorage 安全检查;v3-P2 服务端草稿;v3-P3-1 下载 CSV 模板;v4-P3-2 新手引导均已替换) |
|
||||
| `components/grade-filters.tsx` | 76 | 过滤器(v2-P1-4:i18n) |
|
||||
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要(v2-P1-4:i18n) |
|
||||
| `components/export-button.tsx` | 79 | 导出按钮(v2-P1-4:i18n;P3 修复:safeActionCall 包装导出操作) |
|
||||
@@ -870,7 +874,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
**导出函数**:
|
||||
- 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` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`)
|
||||
- Data-access:只读查询(`getSchools` / `getGrades` / `getDepartments` / `getAcademicYears` / `getStaffOptions` / `getGradesForStaff` / `getOrgTree` / `getSubjectOptions` / `getGradeOptions` / `getSubjectNameMapByIds`(P1-1 新增:批量科目名称映射,供 homework/data-access-classes 调用)/ `getGradeOverviewStats`(✅ v4-P2-6 新增:年级概览统计,返回 `GradeOverviewStats[]`,每个年级的 classCount/studentCount/teacherCount,供年级管理卡片视图使用,动态导入 classes/data-access 避免循环依赖))+ 写操作(`create/update/delete` × `Department/School/Grade/AcademicYear`)+ `promoteGrades(schoolId)` 年级升级(order +1 + 名称升级,辅助函数 `promoteGradeName`)
|
||||
- Types(✅ v4-P2-6 新增):`GradeOverviewStats`(年级概览统计接口:{ gradeId, classCount, studentCount, teacherCount },位于 data-access.ts)
|
||||
- Components:`SchoolsClient` / `SchoolFormDialog` / `SchoolDeleteDialog` / `SchoolListToolbar` / `SchoolErrorBoundary` / `SchoolListSkeleton` / `SchoolCardSkeleton` / `OrgTreeNav` / `GradesClient`(✅ v4-P2-6 更新:新增 `gradeStats: GradeOverviewStats[]` prop,渲染年级概览卡片视图——每个年级卡片展示班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口)/ `GradeInsightsFilters`(✅ v4-P2-6 新增:年级洞察筛选器,使用 ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新)/ `GradeDistributionPanel` / `GradeHomeworkPanel` / `GradeExamsPanel` / `GradeProgressPanel`(✅ v4-P2-7 新增:年级仪表盘 4 个维度面板组件,位于 `components/grade-dashboard/` 子目录,服务端组件直接渲染数据,无客户端交互)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`users`(⚠️ `getStaffOptions` 直查 users/roles,可接受)
|
||||
@@ -892,6 +898,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ 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 实体审计日志策略一致
|
||||
- ✅ v4-P2-6 改进(2026-06-23):年级管理体验增强——新增 `getGradeOverviewStats()` data-access 函数(返回 `GradeOverviewStats[]`:每个年级的 classCount/studentCount/teacherCount,动态导入 classes/data-access 避免循环依赖);`GradesClient` 新增 `gradeStats` prop,渲染年级概览卡片视图(班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口);admin/school/grades/page.tsx 新增 `getGradeOverviewStats()` 查询并传入 GradesClient
|
||||
- ✅ v4-P2-6 改进(2026-06-23):新增 `GradeInsightsFilters` 组件(使用 ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新);admin/school/grades/insights/page.tsx 与 management/grade/insights/page.tsx 重写,使用 `GradeInsightsFilters` 替代原生 form get,添加 i18n,表格添加 `overflow-x-auto` 水平滚动
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
@@ -907,6 +915,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| components/school-error-boundary.tsx | 72 | 共享 Error Boundary(P1-3 修复) |
|
||||
| components/school-skeleton.tsx | 69 | 共享骨架屏(P1-3 修复) |
|
||||
| components/org-tree-nav.tsx | 134 | 学校→年级→班级三级树形导航(P2-2 修复:搜索过滤 + 选中高亮 + 展开折叠 + 节点类型图标) |
|
||||
| components/grades-view.tsx | 920+ | 年级管理客户端(v4-P2-6 更新:新增 gradeStats prop + 年级概览卡片视图,展示班级/学生/教师数 + 年级主任/教学主任 + 快捷操作入口) |
|
||||
| components/grade-insights-filters.tsx | 48 | v4-P2-6 新增:年级洞察筛选器(ChipNav 替代原生 form get,点击 chip 即时通过 URL 参数切换,无整页刷新) |
|
||||
| hooks/use-school-data.ts | 40 | 学校数据管理 hook(P2-1 修复) |
|
||||
|
||||
---
|
||||
@@ -1327,8 +1337,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:课程计划 CRUD + 周计划项 CRUD + 排序。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getCoursePlansAction` / `getCoursePlanByIdAction` / `createCoursePlanAction` / `updateCoursePlanAction` / `deleteCoursePlanAction` / `createCoursePlanItemAction` / `updateCoursePlanItemAction` / `deleteCoursePlanItemAction` / `toggleCoursePlanItemCompletedAction`
|
||||
- Data-access:与 actions 对应
|
||||
- Actions:`getCoursePlansAction` / `getCoursePlanByIdAction` / `createCoursePlanAction` / `updateCoursePlanAction` / `deleteCoursePlanAction` / `createCoursePlanItemAction` / `updateCoursePlanItemAction` / `deleteCoursePlanItemAction` / `toggleCoursePlanItemCompletedAction` / `getGradeCoursePlanProgressAction`(✅ v4-P2-7 新增:年级仪表盘维度4,按 gradeId 查询年级下所有班级的教学计划进度,COURSE_PLAN_READ 权限)
|
||||
- Data-access:与 actions 对应 + `getGradeCoursePlanProgress`(✅ v4-P2-7 新增:通过 getClassesByGradeId 获取年级下所有班级,inArray 查询 course_plans + course_plan_items,返回整体进度汇总 + 按班级/科目拆分的进度矩阵)
|
||||
- Types(✅ v4-P2-7 新增):`GradeCoursePlanProgressItem`(年级进度项:planId/classId/className/subjectId/subjectName/teacherName/semester/totalHours/completedHours/progressRate/status/itemCount/completedItemCount)/ `GradeCoursePlanProgressResult`(年级进度结果:gradeId + overall 汇总 + items 数组)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(合理,getAdminClasses/getStaffOptions)、`school`(合理,getAcademicYears)
|
||||
@@ -1621,23 +1632,23 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
---
|
||||
|
||||
## 2.23 settings(设置模块)
|
||||
## 2.23 settings(设置模块)— V3 AI 配置统一入口
|
||||
|
||||
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)。
|
||||
**职责**:系统设置(学校信息/安全策略/文件上传/通知配置)+ AI Provider 管理 + 密码修改 + 个人资料 + 主题偏好 + 通知偏好 + 个人信息页(学生/教师概览)。V3 将 AI Provider 配置统一到 `/admin/ai-settings` 独立页面,移除 `/settings?tab=ai` 标签页和考试页面内嵌弹窗。V3.1 新增 public/private 可见性模型:管理员发布 public Provider 全员可用,普通用户可创建 private Provider 仅本人可见。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
||||
- Actions:`getAiProviderSummaries`(V3.1 增强:按用户身份过滤 public + own private)/ `upsertAiProviderAction`(V3.1 增强:visibility 字段,非管理员强制 private)/ `testAiProviderAction` / `deleteAiProviderAction`(V3 新增删除能力)/ `canConfigurePublicAiProvider`(V3.1 新增:检查当前用户是否拥有 AI_CONFIGURE 权限)
|
||||
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
||||
- Actions-avatar:`updateUserAvatarAction` / `removeUserAvatarAction`(✅ P2-8 新增:头像上传/删除,复用 `/api/upload` 路由)
|
||||
- Actions-notifications:`sendTestNotificationAction`(✅ P2-10 新增:发送测试通知,占位实现待接入真实通知服务)
|
||||
- Actions-system-settings:`getAdminSystemSettingsAction` / `saveAdminSystemSettingsAction`(✅ P0-3 新增:管理员系统设置 CRUD,4 分类 Zod 校验)
|
||||
- Actions-security:`getSecurityCenterAction` / `toggleTwoFactorAction`(✅ P2-9 新增:2FA 状态查询/切换 + 最近登录历史)
|
||||
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
||||
- Data-access:`getAiProviderSummaries`(管理员视图,返回全部)/ `getAiProviderSummariesForUser`(V3.1 新增:用户视图,返回 public + own private)/ `countDefaultAiProviders` / `getAiProviderForUpdate`(V3.1 增强:可选 userId 做所有权校验)/ `updateAiProvider`(V3.1 增强:visibility 字段)/ `createAiProvider`(V3.1 增强:visibility 字段)/ `deleteAiProvider`(V3 新增:事务删除 + 自动转移默认;V3.1 增强:可选 userId 做所有权校验)/ `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
||||
- Data-access-system-settings:`getSystemSettingsByCategory` / `getAllSystemSettings` / `getSystemSetting` / `upsertSystemSetting` / `upsertSystemSettings`(✅ P0-3 新增:system_settings 表 CRUD,键值对存储模式)
|
||||
- Components:`SettingsView`(统一设置页布局,5 标签页 General/Notifications/Appearance/Security/AI;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏;AI 标签页条件渲染需 `AI_CONFIGURE` 权限)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(✅ P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表)、`AvatarUpload`(✅ P2-8 新增:头像上传/预览/删除客户端组件,文件验证 + i18n)、`SecurityCenterCard`(✅ P2-9 新增:2FA 开关 + 最近登录历史卡片)、`ThemePreferencesCard`(✅ P2-11 已增强:集成 `LocaleSwitcher` 语言切换)
|
||||
- Components:`SettingsView`(统一设置页布局,V3 移除 AI 标签页后为 4 标签页 General/Notifications/Appearance/Security;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(✅ P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表)、`AvatarUpload`(✅ P2-8 新增:头像上传/预览/删除客户端组件,文件验证 + i18n)、`SecurityCenterCard`(✅ P2-9 新增:2FA 开关 + 最近登录历史卡片)、`ThemePreferencesCard`(✅ P2-11 已增强:集成 `LocaleSwitcher` 语言切换)、`AiProviderSettingsCard`(V3 增强:新增删除按钮 + AlertDialog 确认;V3.1 增强:visibility 选择器 + 可见性/归属 Badge,isAdmin prop 控制公开选项,currentUserId prop 标识"我的")
|
||||
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
||||
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
||||
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
||||
- Types:`AiProviderSummary`(V3.1 增强:新增 visibility/createdBy 字段)/ `AiProviderName` / `AiProviderVisibility`(V3.1 新增:'public' | 'private')/ `AiProviderExisting`(V3.1 增强:新增 visibility/createdBy 字段)/ `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`(含 `shared/lib/bcrypt-utils`)、`@/auth`、`messaging`(页面层通过 `SettingsService` 接口注入,组件层不直接 import)、`users`(页面层通过 `SettingsService` 接口注入)、`classes` / `homework` / `dashboard`(ProfileStudentOverview 异步组件获取学生概览数据)、`notifications`(页面层获取通知偏好)
|
||||
@@ -1812,17 +1823,17 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- `FlowEdge`:流程连线(教学节点 → 教学节点)
|
||||
|
||||
**导出函数**:
|
||||
- Data-access(`data-access.ts`):`getLessonPlans` / `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById`
|
||||
- Data-access(`data-access.ts`):`getLessonPlans`(V4:查询后按 textbookId+chapterId+creatorId 聚合版本,返回代表项 + versionCount + versions 摘要数组)/ `getLessonPlanById` / `createLessonPlan` / `updateLessonPlanContent` / `softDeleteLessonPlan` / `duplicateLessonPlan` / `getTemplateById` / `buildInitialContent` / `migrateV1ToV2` / `normalizeDocument`(v3 规范化,兼容 v1/v2 旧数据)/ `buildDefaultSkeleton`(v3 默认 10 节点骨架)/ `getTextbooksForPicker` / `getChaptersForPicker` / `findChapterById` / `publishLessonPlan`(V3 新增,设置 status=published)/ `unpublishLessonPlan`(V3 新增,设置 status=draft,仅 published 课案)
|
||||
- Lib(`lib/document-migration.ts`):`defaultDataForType` / `migrateV1ToV2` / `migrateV2ToV3` / `normalizeDocument` / `buildInitialContent` / `buildDefaultSkeleton` / `isTextbookContentNode` / `isAnchorEdge` / `getAnchorsForNode` / `getActiveAnchorIds` / `getAnchorEdges`
|
||||
- Lib(`lib/anchor-injector.ts`):`markdownToPlainText` / `injectPlaceholders` / `parseAnchoredText` / `toCircledNumber` / `getNextPointIndex` / `relocateAnchors` / `getAnchorColor`
|
||||
- Lib(`lib/node-summary.ts`):`getNodeSummary` / `getTextbookContentSummary` / `getNodeColor` / `NODE_COLORS`
|
||||
- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点)/ `toRfEdges`(区分 anchor/flow 边透明度)/ `fromRfEdges`
|
||||
- Lib(`lib/rf-mappers.ts`):`toRfNodes`(支持 textbook_content 节点;V3:ctx 新增 `anchorableNodes` 和 `onCreateNewNode` 字段)/ `toRfEdges`(区分 anchor/flow 边透明度;V3:锚点边颜色使用 `getNodeColor(anchor.nodeId)` 替代硬编码,anchorId 存入 edge.data)/ `fromRfEdges`(V3:从 `e.data.anchorId` 读取 anchorId,回退到 className 判断)
|
||||
- Data-access-versions(`data-access-versions.ts`):`getLessonPlanVersions` / `createLessonPlanVersion` / `getVersionContent` / `revertToVersion` / `pruneAutoVersions`
|
||||
- Data-access-templates(`data-access-templates.ts`):`getLessonPlanTemplates` / `saveAsTemplate` / `deletePersonalTemplate`
|
||||
- Data-access-knowledge(`data-access-knowledge.ts`):`getLessonPlansByKnowledgePoint` / `getLessonPlansByQuestion`
|
||||
- Publish-service(`publish-service.ts`):`publishLessonPlanHomework`
|
||||
- AI-suggest(`ai-suggest.ts`):`suggestKnowledgePoints`
|
||||
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction`
|
||||
- Actions:`getLessonPlansAction` / `getLessonPlanByIdAction` / `createLessonPlanAction` / `updateLessonPlanAction` / `saveLessonPlanVersionAction` / `getLessonPlanVersionsAction` / `revertLessonPlanVersionAction` / `deleteLessonPlanAction` / `duplicateLessonPlanAction` / `getLessonPlanTemplatesAction` / `saveAsTemplateAction` / `deleteTemplateAction` / `suggestKnowledgePointsAction` / `publishLessonPlanHomeworkAction` / `getKnowledgePointOptionsAction` / `getTextbooksForPickerAction` / `getChaptersForPickerAction` / `publishLessonPlanAction`(V3 新增,requirePermission(LESSON_PLAN_PUBLISH))/ `unpublishLessonPlanAction`(V3 新增,requirePermission(LESSON_PLAN_PUBLISH))
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`shared/lib/ai`、`@xyflow/react`(节点图编辑器)、`textbooks`(只读章节/知识点树)、`questions`(创建/查询题目)、`exams`(创建 exam 草稿)、`homework`(创建作业下发)、`classes`(查询教师班级)、`files`(附件)
|
||||
@@ -1855,52 +1866,94 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
> - **统一错误处理**:所有 Server Action catch 块改用 `handleActionError`;`JSON.parse` 改用 `safeJsonParse`
|
||||
> - **block-renderer 拖拽 BUG 修复**:修复拖拽时节点位置计算错误
|
||||
|
||||
> 架构变更(2026-06-23,V3 多角色课案查看):
|
||||
> - **V3-1 课案发布/撤回**:新增 `publishLessonPlan(planId, userId)` / `unpublishLessonPlan(planId, userId)` data-access 函数 + `publishLessonPlanAction(planId)` / `unpublishLessonPlanAction(planId)` Server Actions(均 requirePermission(LESSON_PLAN_PUBLISH)),支持课案 status=published 供学生/家长/教研组长只读查看
|
||||
> - **V3-2 只读画布组件**:新增 `LessonPlanReadonlyView`,复用 React Flow(nodesDraggable=false, nodesConnectable=false),供学生/家长/管理员/教研组长查看已发布课案
|
||||
> - **V3-3 多角色视图**:`LessonPlanCard` 新增 viewMode prop(teacher/student/parent/admin/gradeHead),动态跳转链接 + 发布/撤回按钮;`LessonPlanEditor` 新增 initialStatus prop + 发布/撤回按钮(AlertDialog 确认);`LessonPlanList` 新增 viewMode prop
|
||||
> - **V3-4 锚点选择器重写**:`TextbookContentNode` 新增 props: `anchorableNodes`, `onCreateNewNode`;AnchorNodeSelector 重写为节点列表+创建新节点选项;`NodeEditPanel` 选中 textbook_content 时显示操作提示 + 锚点列表(含删除功能)
|
||||
> - **V3-5 模板分区显示**:`TemplatePicker` 加载并显示个人模板(调用 getLessonPlanTemplatesAction),分区显示系统/个人模板
|
||||
> - **V3-6 rf-mappers 增强**:`toRfNodes` ctx 新增 `anchorableNodes` 和 `onCreateNewNode` 字段;`toRfEdges` 锚点边颜色使用 `getNodeColor(anchor.nodeId)` 替代硬编码,anchorId 存入 edge.data;`fromRfEdges` 从 `e.data.anchorId` 读取 anchorId,回退到 className 判断
|
||||
> - **V3-7 权限扩展**:student/parent/grade_head/teaching_head 角色新增 `LESSON_PLAN_READ` 权限,可查看已发布课案
|
||||
> - **V3-8 DataScope 扩展**:`class_members` 和 `children` 新增可选 `gradeIds?: string[]` 字段;auth-guard `resolveDataScope` 中 student 通过 `classEnrollments.innerJoin(classes)` 预解析 gradeIds,parent 通过孩子的 classEnrollments.innerJoin(classes) 预解析 gradeIds
|
||||
> - **V3-9 新增路由**:`/student/lesson-plans`、`/student/lesson-plans/[planId]/view`、`/parent/lesson-plans`、`/parent/lesson-plans/[planId]/view`、`/admin/lesson-plans`、`/admin/lesson-plans/[planId]/view`
|
||||
> - **V3-10 导航变更**:admin 导航新增「课案管理」(/admin/lesson-plans);student 导航新增「我的课案」(/student/lesson-plans);parent 导航新增「孩子课案」(/parent/lesson-plans)
|
||||
|
||||
> 架构变更(2026-06-24,V3 深度审计修复):
|
||||
> - **P0-1 admin 直查 DB 修复**:`admin/lesson-plans/page.tsx` 移除 `import { db }` 和 `import { lessonPlans }`,改用 data-access 新增的 `getLessonPlanStats()` 函数(返回 total/published/draft/archived 统计)
|
||||
> - **P0-2 publish-service 硬编码中文 + as 断言修复**:`publish-service.ts` 移除 `as ExerciseBlockData`/`as typeof validTypes[number]` 断言,改用 `lib/type-guards.ts` 的 `isExerciseBlockData`/`isValidQuestionType` 类型守卫;移除硬编码中文标题/描述,改为接受 `homeworkTitle`/`homeworkDescription` 参数由 actions 层 i18n 翻译后传入
|
||||
> - **P0-3 duplicateLessonPlan 硬编码修复**:`duplicateLessonPlan` 接受 `duplicateSuffix` 参数(默认 " - Copy"),由 actions 层传入 `t("error.duplicateSuffix")`
|
||||
> - **P0-4 schema Zod 错误消息 i18n 化**:`schema.ts` 所有错误消息从硬编码中文改为 i18n 键(如 `error.titleRequired`);新增 `lib/i18n-errors.ts`(`translateFieldErrors`/`safeParseWithI18n`)在 actions 层翻译 Zod 错误
|
||||
> - **P0-5 loading/error 边界补全**:为 6 个路由新增 12 个 `loading.tsx` 和 `error.tsx` 文件(teacher/lesson-plans、teacher/lesson-plans/new、teacher/lesson-plans/[planId]/edit、admin/lesson-plans、admin/lesson-plans/[planId]/view、student/lesson-plans、student/lesson-plans/[planId]/view、parent/lesson-plans、parent/lesson-plans/[planId]/view)
|
||||
> - **P1-2 组件完全通过 service 调用**:`lesson-plan-card.tsx`/`lesson-plan-list.tsx`/`lesson-plan-editor.tsx` 移除所有直接 `import { xxxAction } from "../actions"`,改为通过 `useLessonPlanContextSafe()` 获取 service 调用;`LessonPlanDataService` 接口扩展 5 个方法(getLessonPlanById/updateLessonPlan/saveLessonPlanVersion/publishLessonPlan/unpublishLessonPlan),`default-data-service.ts` 实现扩展
|
||||
> - **P1-3 as 断言修复**:新增 `lib/type-guards.ts` 集中类型守卫(11 种 BlockData 类型守卫 + 节点/题目类型守卫);`block-registry.tsx` 所有 `as XxxBlockData` 替换为类型守卫;`node-edit-panel.tsx` 移除冗余 `as LessonPlanNode`(TypeScript 判别联合自动收窄);`node-editor.tsx` MiniMap `nodeColor` 使用类型守卫替代 `as { node?: AnyLessonPlanNode }`;`rf-mappers.ts` 移除冗余 `as TextbookContentNode`/`as LessonPlanNode`;`use-lesson-plan-editor.ts` `updateNode` patch 类型改为 `Omit<Partial<Block>, "type">` 防止类型变更
|
||||
> - **P1-4 MiniMap 颜色硬编码修复**:`lesson-plan-readonly-view.tsx` MiniMap 移除硬编码 `#455a64`/`#1976d2`,改用 `getNodeColor(nodeData.type)` 与编辑器保持一致
|
||||
> - **i18n 错误码扩展**:`zh-CN/lesson-preparation.json` 和 `en/lesson-preparation.json` 新增 `publish.homeworkTitle`/`publish.homeworkDescription`/`error.invalidQuestionType`/`error.duplicateSuffix`/`error.titleRequired`/`error.titleTooLong`/`error.templateRequired`/`error.invalidDate`/`error.classRequired` 等键
|
||||
|
||||
> 架构变更(2026-06-24,V3 深度审计修复续):
|
||||
> - **P0 硬编码中文修复(6处)**:`actions.ts` 的 `getLessonPlanByIdAction`/`revertLessonPlanVersionAction` 错误消息改为 i18n 键(`t("error.notFound")`/`t("error.versionNotFound")`);`data-access-versions.ts` 的 `revertToVersion` 接受 `revertLabel` 参数替代硬编码 `` `回退到 v${versionNo}` ``;`lesson-plan-error-boundary.tsx` 重写为包装组件模式(内部类组件接受 `errorText`/`retryText` props,外部函数组件通过 `useTranslations` 注入 i18n 文案);`lesson-plan-provider.tsx` 的 `useLessonPlanContext` 错误消息改为英文
|
||||
> - **P1 组件完全通过 service 调用(5个组件迁移)**:`version-history-drawer.tsx`/`template-picker.tsx`/`knowledge-point-picker.tsx`/`publish-homework-dialog.tsx`/`question-bank-picker.tsx` 移除所有直接 `import { xxxAction } from "../actions"`,改用 `useLessonPlanContextSafe()` 获取 service 调用;`LessonPlanDataService` 接口扩展 7 个方法(createLessonPlan/getTextbooksForPicker/getChaptersForPicker/getLessonPlanTemplates/getKnowledgePointOptions/publishLessonPlanHomework/getQuestions)+ 6 个导出类型(TextbookPickerOption/ChapterPickerOption/KnowledgePointOption/PublishHomeworkInput/QuestionPickerParams/QuestionPickerItem);`default-data-service.ts` 实现扩展;新增 `providers/lesson-plan-provider-setup.tsx`(页面层 Provider 设置包装组件,自动注入默认数据服务和角色配置);3 个页面(teacher/lesson-plans/page、teacher/lesson-plans/new/page、teacher/lesson-plans/[planId]/edit/page)用 `LessonPlanProviderSetup` 包裹;`question-bank-picker.tsx` 新增 `isQuestionType` 类型守卫替代 `as QuestionType` 断言
|
||||
> - **P2 文件拆分**:`textbook-content-node.tsx` 从 578 行拆分为 3 个文件——主文件(471行)+ `anchor-node-selector.tsx`(AnchorNodeSelector 组件,62行)+ `textbook-segments.tsx`(renderSegments 函数,75行)
|
||||
> - **P3 as 断言修复(8处)**:`textbook-content-node.tsx` 的 `as unknown as TextbookContentNodeProps` 替换为 `isTextbookContentNodePropsData` 类型守卫;7 个 block 组件(blackboard/import/exercise/objective/key-point/homework/reflection)的 select onChange `as` 断言替换为类型守卫——`lib/type-guards.ts` 新增 7 个字段值类型守卫(isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `types.ts` | 类型定义(含 v1/v2/v3 文档类型、TextbookContentNode、LessonPlanNode、NodeAnchor、AnchorEdge、FlowEdge、11 种 BlockData 接口) |
|
||||
| `constants.ts` | 常量定义 |
|
||||
| `schema.ts` | Zod 验证 |
|
||||
| `schema.ts` | Zod 验证(V3:错误消息改为 i18n 键,如 `error.titleRequired`) |
|
||||
| `lib/type-guards.ts` | **集中类型守卫(V3 新增)**:11 种 BlockData 类型守卫(isRichTextBlockData/isTextStudyBlockData/isExerciseBlockData/isObjectiveBlockData/isKeyPointBlockData/isImportBlockData/isNewTeachingBlockData/isSummaryBlockData/isHomeworkBlockData/isBlackboardBlockData/isReflectionBlockData)+ 节点类型守卫(isTextbookContentNode/isLessonPlanNode)+ 题目类型守卫(isValidQuestionType)+ 基础类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope/isBlockType)+ **Block 字段值类型守卫(V3 续新增)**:isBlackboardLayout/isImportMethod/isExercisePurpose/isObjectiveDimension/isKeyPointType/isHomeworkType/isReflectionAspect(用于 select onChange 替代 `as` 断言) |
|
||||
| `lib/i18n-errors.ts` | **Zod 错误 i18n 翻译辅助(V3 新增)**:`translateFieldErrors`(将 Zod fieldErrors 中的 i18n 键翻译为实际消息)/ `safeParseWithI18n`(安全解析 Zod 结果并返回带翻译的 ActionState 错误格式) |
|
||||
| `lib/document-migration.ts` | **纯函数**:v1→v2(migrateV1ToV2)/ v2→v3(migrateV2ToV3)/ 规范化(normalizeDocument,兼容 v1/v2/v3)/ 初始内容(buildInitialContent)/ 默认骨架(buildDefaultSkeleton,10 节点 + 正文节点)/ defaultDataForType / 工具函数(isTextbookContentNode/isAnchorEdge/getAnchorsForNode/getActiveAnchorIds/getAnchorEdges) |
|
||||
| `lib/anchor-injector.ts` | **纯函数**:锚点注入算法(markdownToPlainText/injectPlaceholders/parseAnchoredText/toCircledNumber/getNextPointIndex/relocateAnchors/getAnchorColor) |
|
||||
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(支持 11 种节点类型)+ getTextbookContentSummary + NODE_COLORS + getNodeColor |
|
||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(支持 textbook_content 节点 + 锚点回调)/ toRfEdges(区分 anchor/flow 边透明度)/ fromRfEdges |
|
||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(switch 渲染 11 种定制节点 + textbook_content) |
|
||||
| `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4/V2-6)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe(V2-6 新增,返回 noopTracker 不抛错) |
|
||||
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock |
|
||||
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3;V2-1:抛出 `LessonPlanDataError` 错误码;V2-3:mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫) |
|
||||
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理;V2-3:mapRowToVersion 显式映射) |
|
||||
| `data-access-templates.ts` | 个人模板 CRUD(V2-3:mapRowToTemplate 显式映射 + 类型守卫) |
|
||||
| `data-access-knowledge.ts` | 按知识点/题目反查课案(V2-3:显式字段映射替代 `as unknown as`) |
|
||||
| `actions.ts` | 课案 CRUD/版本/模板 Server Actions(V2-1:getTranslations i18n + 错误码捕获;V2-2:createLessonPlanAction 传入 translateTitle 翻译 SYSTEM_TEMPLATES) |
|
||||
| `actions-publish.ts` | 发布作业 Server Action(V2-1:getTranslations i18n + PUBLISH_ERROR_KEY_MAP 错误码映射) |
|
||||
| `actions-ai.ts` | AI 知识点建议 Server Action(V2-1:i18n + 错误码) |
|
||||
| `actions-kp.ts` | 知识点选项 Server Action(V2-1:i18n + 错误码) |
|
||||
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes,通过对方 data-access 调用,无直查跨模块表;V2-1:抛出 `PublishServiceError` 错误码;V2-3:显式字段映射替代 `as unknown as`) |
|
||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes(V3:移除冗余 `as` 断言,TypeScript 判别联合自动收窄)/ toRfEdges / fromRfEdges |
|
||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + BlockRenderer(V3:使用 `lib/type-guards.ts` 类型守卫替代所有 `as XxxBlockData` 断言) |
|
||||
| `providers/lesson-plan-provider.tsx` | **Provider + Context**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口(V3:扩展 5 个方法 getLessonPlanById/updateLessonPlan/saveLessonPlanVersion/publishLessonPlan/unpublishLessonPlan;**V3 续扩展 7 个方法 createLessonPlan/getTextbooksForPicker/getChaptersForPicker/getLessonPlanTemplates/getKnowledgePointOptions/publishLessonPlanHomework/getQuestions + 6 个导出类型**)、4 个角色配置、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe |
|
||||
| `providers/lesson-plan-provider-setup.tsx` | **页面层 Provider 设置包装组件(V3 续新增)**:LessonPlanProviderSetup 自动注入默认数据服务(createDefaultDataService)和角色配置(TEACHER_ROLE_CONFIG),3 个 teacher 页面用此组件包裹 |
|
||||
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现(V3:实现扩展的 5 个方法;**V3 续扩展 7 个方法 createLessonPlan/getTextbooksForPicker/getChaptersForPicker/getLessonPlanTemplates/getKnowledgePointOptions/publishLessonPlanHomework/getQuestions**),测试可替换为 mock |
|
||||
| `data-access.ts` | 课案 CRUD + 模板查询(V3:新增 `getLessonPlanStats()` 统计函数 + `LessonPlanStats` 接口;`duplicateLessonPlan` 接受 `duplicateSuffix` 参数消除硬编码) |
|
||||
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
|
||||
| `data-access-templates.ts` | 个人模板 CRUD |
|
||||
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
|
||||
| `actions.ts` | 课案 CRUD/版本/模板 Server Actions(V3:所有 action 使用 `translateFieldErrors` 翻译 Zod 错误;`duplicateLessonPlanAction` 传入 i18n 翻译的副本后缀;`getLessonPlanByIdAction` 返回类型改为 `ActionState<{ plan: LessonPlan }>`) |
|
||||
| `actions-publish.ts` | 发布作业 Server Action(V3:actions 层注入 i18n 翻译的作业标题/描述/日期标签;新增 `INVALID_QUESTION_TYPE` 错误码映射) |
|
||||
| `actions-ai.ts` | AI 知识点建议 Server Action |
|
||||
| `actions-kp.ts` | 知识点选项 Server Action |
|
||||
| `publish-service.ts` | 发布作业服务(V3:移除 `as` 断言改用类型守卫;移除硬编码中文,接受 `homeworkTitle`/`homeworkDescription` 参数;新增 `INVALID_QUESTION_TYPE` 错误码) |
|
||||
| `ai-suggest.ts` | AI 知识点建议服务 |
|
||||
| `seed-templates.ts` | 模板种子数据 |
|
||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(基于 zustand,支持 nodes/edges/anchors 操作:addNode/updateNode/updateNodePosition/removeNode/connect/disconnect/setEdges/selectNode + 锚点操作 addAnchor/removeAnchor/updateAnchor + 正文节点操作 updateTextbookContent/getTextbookContentNode;实时拖动) |
|
||||
| `components/lesson-plan-list.tsx` | 课案列表(i18n 已接入) |
|
||||
| `components/lesson-plan-card.tsx` | 课案卡片(i18n 已接入;V2-6:duplicate/archive 调用 tracker.track) |
|
||||
| `components/lesson-plan-filters.tsx` | 课案筛选器(i18n 已接入;V2-5:3 个表单元素 label htmlFor 关联) |
|
||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPanel,i18n 已接入;V2-6:handleManualSave 调用 tracker.track;V3:顶部工具栏显示教材/章节标题指示器) |
|
||||
| `components/node-editor.tsx` | **节点图画布**(React Flow,使用 lib/rf-mappers + lib/node-summary 纯函数,i18n 已接入;V2-4:MiniMap 复用 getNodeColor;V2-5:role=application + 键盘导航配置;V3:注册 textbook_content 节点类型 + 锚点回调 + 实时拖动) |
|
||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(配置驱动渲染 Block,通过 BlockRenderer + LessonPlanErrorBoundary 包裹,i18n 已接入;V3:处理 textbook_content 节点,教学节点类型收窄) |
|
||||
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件**(使用 lib/node-summary 的 getNodeSummary/getNodeColor,i18n 已接入) |
|
||||
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 新增):ReactMarkdown 渲染正文 + 锚点注入 + 文本选择(range 锚定)+ 点击位置(point 锚定)+ 缩放控制 + 锚点浮动菜单 |
|
||||
| `components/lesson-plan-error-boundary.tsx` | **错误边界**:LessonPlanErrorBoundary 类组件,支持 fallback 和 onError 回调 |
|
||||
| `hooks/use-lesson-plan-editor.ts` | 课案编辑器 Hook(V3:`updateNode` patch 类型改为 `Omit<Partial<Block>, "type">` 防止类型变更) |
|
||||
| `components/lesson-plan-list.tsx` | 课案列表(V3:完全通过 service 调用,移除直接 import `getLessonPlansAction`) |
|
||||
| `components/lesson-plan-card.tsx` | 课案卡片(V3:完全通过 service 调用,移除直接 import actions) |
|
||||
| `components/lesson-plan-filters.tsx` | 课案筛选器 |
|
||||
| `components/lesson-plan-editor.tsx` | 课案编辑器(V3:完全通过 service 调用,移除直接 import 5 个 actions;所有操作改为 `service.updateLessonPlan`/`service.saveLessonPlanVersion`/`service.getLessonPlanById`/`service.publishLessonPlan`/`service.unpublishLessonPlan`) |
|
||||
| `components/lesson-plan-readonly-view.tsx` | **只读画布组件**(V3:MiniMap 使用 `getNodeColor` 替代硬编码颜色) |
|
||||
| `components/node-editor.tsx` | **节点图画布**(V3:MiniMap `nodeColor` 使用类型守卫替代 `as { node?: AnyLessonPlanNode }` 断言) |
|
||||
| `components/node-edit-panel.tsx` | **侧边内容编辑面板**(V3:移除冗余 `as LessonPlanNode` 断言,TypeScript 判别联合自动收窄) |
|
||||
| `components/nodes/lesson-node.tsx` | **自定义教学节点组件** |
|
||||
| `components/nodes/textbook-content-node.tsx` | **正文节点组件**(V3 续:拆分为 3 文件,主文件 471 行;`as unknown as` 替换为 `isTextbookContentNodePropsData` 类型守卫) |
|
||||
| `components/nodes/anchor-node-selector.tsx` | **锚点节点选择器(V3 续新增,从 textbook-content-node.tsx 抽取)**:渲染可锚定教学节点列表 + 关联到选中节点 + 创建新节点选项 |
|
||||
| `components/nodes/textbook-segments.tsx` | **锚点段落渲染函数(V3 续新增,从 textbook-content-node.tsx 抽取)**:renderSegments 遍历 segments 数组渲染文本/区间锚点/点锚点 |
|
||||
| `components/lesson-plan-error-boundary.tsx` | **错误边界**(V3 续:重写为包装组件模式——内部类组件接受 `errorText`/`retryText` props,外部函数组件通过 `useTranslations` 注入 i18n 文案) |
|
||||
| `components/lesson-plan-skeleton.tsx` | **骨架屏**:VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton |
|
||||
| `components/block-renderer.tsx` | ⚠️ @deprecated Block 渲染器(已被 NodeEditor 替代,保留向后兼容) |
|
||||
| `components/template-picker.tsx` | 模板选择器(i18n 已接入;V2-6:create 调用 tracker.track) |
|
||||
| `components/version-history-drawer.tsx` | 版本历史抽屉(i18n 已接入;V2-6:revert 调用 tracker.track) |
|
||||
| `components/knowledge-point-picker.tsx` | 知识点选择器(i18n 已接入) |
|
||||
| `components/question-bank-picker.tsx` | 题库选择器(i18n 已接入) |
|
||||
| `components/inline-question-editor.tsx` | 内联题目编辑器(i18n 已接入;V2-5:type/difficulty select label htmlFor 关联) |
|
||||
| `components/publish-homework-dialog.tsx` | 发布作业对话框(i18n 已接入;V2-6:publish 调用 tracker.track) |
|
||||
| `components/blocks/rich-text-block.tsx` | 富文本 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||
| `components/blocks/text-study-block.tsx` | 课文研读 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||
| `components/blocks/exercise-block.tsx` | 练习 Block(被 NodeEditPanel 复用,使用 router.refresh 替代 window.location.reload,i18n 已接入;V2-5:purpose select label 关联 + 题目列表 ul/li 语义化) |
|
||||
| `components/blocks/reflection-block.tsx` | 反思 Block(被 NodeEditPanel 复用,i18n 已接入) |
|
||||
| `components/template-picker.tsx` | 模板选择器(V3 续:完全通过 service 调用,移除直接 import 4 个 actions) |
|
||||
| `components/version-history-drawer.tsx` | 版本历史抽屉(V3 续:完全通过 service 调用,移除直接 import 2 个 actions) |
|
||||
| `components/knowledge-point-picker.tsx` | 知识点选择器(V3 续:完全通过 service 调用,移除直接 import actions-kp) |
|
||||
| `components/question-bank-picker.tsx` | 题库选择器(V3 续:完全通过 service 调用,移除跨模块直接 import `@/modules/questions/actions`;新增 `isQuestionType` 类型守卫替代 `as QuestionType`) |
|
||||
| `components/inline-question-editor.tsx` | 内联题目编辑器 |
|
||||
| `components/publish-homework-dialog.tsx` | 发布作业对话框(V3 续:完全通过 service 调用,移除直接 import actions-publish) |
|
||||
| `components/blocks/rich-text-block.tsx` | 富文本 Block |
|
||||
| `components/blocks/text-study-block.tsx` | 课文研读 Block |
|
||||
| `components/blocks/objective-block.tsx` | 教学目标 Block(V3 续:select onChange 使用 `isObjectiveDimension` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/key-point-block.tsx` | 重难点 Block(V3 续:select onChange 使用 `isKeyPointType` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/import-block.tsx` | 导入 Block(V3 续:select onChange 使用 `isImportMethod` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/new-teaching-block.tsx` | 新授 Block |
|
||||
| `components/blocks/summary-block.tsx` | 总结 Block |
|
||||
| `components/blocks/homework-block.tsx` | 作业 Block(V3 续:select onChange 使用 `isHomeworkType` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/blackboard-block.tsx` | 板书 Block(V3 续:select onChange 使用 `isBlackboardLayout` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/exercise-block.tsx` | 练习 Block(V3 续:select onChange 使用 `isExercisePurpose` 类型守卫替代 `as` 断言) |
|
||||
| `components/blocks/reflection-block.tsx` | 反思 Block(V3 续:select onChange 使用 `isReflectionAspect` 类型守卫替代 `as` 断言) |
|
||||
|
||||
---
|
||||
|
||||
@@ -1949,33 +2002,42 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- `collectFromExamSubmission`:从考试提交记录中筛选得分 < 满分的题目,去重后批量插入
|
||||
- `collectFromHomeworkSubmission`:从作业提交记录中筛选错题,去重后批量插入
|
||||
- 自动关联知识点(通过 `questionsToKnowledgePoints` 表)
|
||||
- subjectId 来源:`questions` 表无 subjectId 字段,考试错题从 `exams.subjectId` 获取;作业错题从 `homeworkAssignments.sourceExamId` 关联到源试卷的 `exams.subjectId` 获取(独立作业无学科归属则为 null)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | ~180 | 9 个 Server Actions,全部使用 `requirePermission()` + `ActionState<T>` |
|
||||
| `data-access.ts` | ~960 | 16 个数据访问函数 + 自动采集逻辑(SM-2 算法已提取到独立模块) |
|
||||
| `data-access.ts` | ~1280 | 19 个数据访问函数 + 自动采集逻辑(SM-2 算法已提取到独立模块,支持 subjectId 过滤、章节维度、班级分组、学科概览) |
|
||||
| `sm2-algorithm.ts` | ~180 | SM-2 间隔重复算法(独立纯函数模块,可替换,支持时间注入测试) |
|
||||
| `sm2-algorithm.test.ts` | ~280 | SM-2 算法单元测试(39 个测试用例,覆盖所有函数和边界条件) |
|
||||
| `schema.ts` | ~60 | 4 个 Zod 验证 schema |
|
||||
| `types.ts` | ~120 | 6 个类型定义 + 状态映射常量 + 错误标签常量 |
|
||||
| `types.ts` | ~245 | 11 个类型定义 + 状态映射常量 + 错误标签常量(新增 ChapterWeakness/ClassErrorOverview/SubjectErrorOverview) |
|
||||
| `components/error-book-stats-cards.tsx` | ~80 | 5 个统计卡片(总数/待学习/学习中/已掌握/待复习) |
|
||||
| `components/analytics-stats-cards.tsx` | ~110 | 教师/管理员分析视图 5 个统计卡片(覆盖学生/错题总数/平均掌握率/待复习/知识点数) |
|
||||
| `components/error-book-filters.tsx` | ~100 | 筛选栏(搜索/状态/来源/待复习),使用 nuqs |
|
||||
| `components/error-book-item-card.tsx` | ~150 | 错题卡片(预览/标签/笔记/掌握度/操作) |
|
||||
| `components/review-buttons.tsx` | ~80 | 4 按钮复习面板(again/hard/good/easy) |
|
||||
| `components/error-book-detail-dialog.tsx` | ~250 | 详情对话框(题目/答案/复习/笔记/历史) |
|
||||
| `components/error-book-list.tsx` | ~60 | 网格列表 |
|
||||
| `components/add-error-book-dialog.tsx` | ~180 | 手动添加对话框(题库选择 + 标签) |
|
||||
| `components/class-error-overview.tsx` | ~200 | 班级错题概览(教师/管理员视图) |
|
||||
| `components/subject-tabs.tsx` | ~95 | 学科切换 Tab(显示每个学科错题数概览,URL 参数持久化) |
|
||||
| `components/class-filter.tsx` | ~85 | 班级筛选器(显示每个班级错题数和待复习数,URL 参数持久化) |
|
||||
| `components/class-error-bar-chart.tsx` | ~115 | 班级错题数对比柱状图(recharts,tooltip 显示学生数/人均/掌握率) |
|
||||
| `components/subject-distribution-chart.tsx` | ~110 | 学科错题分布柱状图(管理员视图,recharts) |
|
||||
| `components/knowledge-point-weakness-chart.tsx` | ~150 | 知识点薄弱度横向柱状图(按掌握率红/黄/绿着色,显示章节归属) |
|
||||
| `components/chapter-weakness-chart.tsx` | ~165 | 章节错题分布横向柱状图(哪些课在错,含 Top 3 薄弱知识点) |
|
||||
| `components/grouped-student-error-table.tsx` | ~180 | 按班级分组的学生错题表格(可展开/折叠,显示每个学生的错题详情) |
|
||||
| `components/class-error-overview.tsx` | ~200 | 班级错题概览(旧版,保留兼容) |
|
||||
| `components/top-wrong-questions.tsx` | ~80 | 高频错题列表(Top 10) |
|
||||
|
||||
**路由清单**:
|
||||
| 路由 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/student/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 学生错题本(统计/筛选/列表/手动添加/详情复习) |
|
||||
| `/teacher/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 教师错题分析(班级概览/薄弱知识点/学科分布/高频错题) |
|
||||
| `/teacher/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 教师错题分析(学科Tab/班级筛选/统计卡片/班级对比图/章节错题图/知识点薄弱度图/按班级分组学生表/高频错题) |
|
||||
| `/parent/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 家长错题本(子女错题统计/薄弱知识点/高频错题) |
|
||||
| `/admin/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 管理员错题分析(全校错题统计/薄弱知识点/学科分布/高频错题) |
|
||||
| `/admin/error-book` | `page.tsx` + `loading.tsx` + `error.tsx` | 管理员错题分析(学科Tab/统计卡片/学科分布图/章节错题图/知识点薄弱度图/按班级分组学生Top50/高频错题) |
|
||||
|
||||
**数据库表**:
|
||||
| 表 | 说明 |
|
||||
@@ -2127,6 +2189,138 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
---
|
||||
|
||||
## 2.30 adaptive-practice(专项练习模块)— ✅ 新增(核心教学链路闭环)
|
||||
|
||||
**职责**:实现"错题集 → 知识点 → 专项出题 → 学生答题 → 自动判分 → 教师/年级主任宏观数据分析"的完整闭环。支持四种出题策略:错题变式、知识点专项、薄弱章节、AI 推荐。提供教师端与年级主任端的宏观数据分析工作台。
|
||||
|
||||
**架构定位**:
|
||||
- 位于 `modules/` 层,严格遵循三层架构
|
||||
- 通过 `data-access.ts` 提供 CRUD 操作,通过 `data-access-strategy.ts` 实现出题策略,通过 `data-access-analytics.ts` 提供教师/年级分析查询
|
||||
- 跨模块通信:通过 `modules/error-book/data-access` 获取错题数据,通过 `modules/questions/data-access` 获取题目数据,通过 `modules/classes/data-access` 获取班级/学生数据,通过 `modules/users/data-access` 获取用户数据
|
||||
- Server Actions 通过 `requirePermission()` 校验 `ADAPTIVE_PRACTICE_READ` / `ADAPTIVE_PRACTICE_MANAGE` 权限
|
||||
|
||||
**核心导出**:
|
||||
|
||||
| 类型 | 名称 | 文件 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **DB Schema** | `practiceSessions` | `shared/db/schema.ts` | 练习会话表(学生 ID/类型/状态/统计) |
|
||||
| **DB Schema** | `practiceAnswers` | `shared/db/schema.ts` | 练习答题记录表(含变式题支持) |
|
||||
| **Server Actions** | `getPracticeSessionsAction` | `modules/adaptive-practice/actions.ts` | 获取练习列表(权限:ADAPTIVE_PRACTICE_READ) |
|
||||
| **Server Actions** | `getPracticeSessionDetailAction` | `modules/adaptive-practice/actions.ts` | 获取练习详情(权限:ADAPTIVE_PRACTICE_READ) |
|
||||
| **Server Actions** | `getPracticeStatsAction` | `modules/adaptive-practice/actions.ts` | 获取练习统计(权限:ADAPTIVE_PRACTICE_READ) |
|
||||
| **Server Actions** | `createPracticeSessionAction` | `modules/adaptive-practice/actions.ts` | 创建练习会话(权限:ADAPTIVE_PRACTICE_MANAGE) |
|
||||
| **Server Actions** | `submitPracticeAnswerAction` | `modules/adaptive-practice/actions.ts` | 提交答案+自动判分(权限:ADAPTIVE_PRACTICE_MANAGE) |
|
||||
| **Server Actions** | `completePracticeSessionAction` | `modules/adaptive-practice/actions.ts` | 完成练习(权限:ADAPTIVE_PRACTICE_MANAGE) |
|
||||
| **Server Actions** | `abandonPracticeSessionAction` | `modules/adaptive-practice/actions.ts` | 放弃练习(权限:ADAPTIVE_PRACTICE_MANAGE) |
|
||||
| **Data Access** | `getPracticeSessions` / `getPracticeSessionById` | `data-access.ts` | 学生端 CRUD |
|
||||
| **Data Access** | `createPracticeSession` | `data-access.ts` | 创建会话(事务:会话+答题记录) |
|
||||
| **Data Access** | `submitPracticeAnswer` | `data-access.ts` | 提交答案+自动判分 |
|
||||
| **Data Access** | `autoGradeAnswer` | `data-access.ts` | 自动判分逻辑(选择题/判断题自动,填空题返回 null) |
|
||||
| **Strategy** | `selectQuestionsForPractice` | `data-access-strategy.ts` | 出题策略主入口 |
|
||||
| **Strategy** | `selectForErrorVariant` | `data-access-strategy.ts` | 错题变式策略 |
|
||||
| **Strategy** | `selectForKnowledgePoint` | `data-access-strategy.ts` | 知识点专项策略 |
|
||||
| **Strategy** | `selectForWeakChapter` | `data-access-strategy.ts` | 薄弱章节策略 |
|
||||
| **Strategy** | `selectForAiRecommended` | `data-access-strategy.ts` | AI 推荐策略 |
|
||||
| **Analytics** | `getClassPracticeStats` | `data-access-analytics.ts` | 班级练习统计 |
|
||||
| **Analytics** | `getClassStudentPracticeSummaries` | `data-access-analytics.ts` | 班级学生练习摘要 |
|
||||
| **Analytics** | `getGradePracticeStats` | `data-access-analytics.ts` | 年级练习统计 |
|
||||
| **Analytics** | `getPracticeTypeBreakdown` | `data-access-analytics.ts` | 练习类型分布 |
|
||||
| **Analytics** | `getStudentsWithoutPractice` | `data-access-analytics.ts` | 识别未参与练习学生 |
|
||||
| **Analytics** | `getTeacherClassPracticeOverviews` | `data-access-analytics.ts` | 教师所教班级练习概览 |
|
||||
| **Analytics** | `getClassKnowledgePointWeakness` | `data-access-analytics.ts` | 班级知识点薄弱度(基于练习答题) |
|
||||
| **Analytics** | `getGradeClassPracticeComparison` | `data-access-analytics.ts` | 年级各班级练习对比 |
|
||||
| **Analytics** | `getClassLearningProfile` | `data-access-analytics.ts` | 班级综合学习画像(跨模块整合) |
|
||||
| **Analytics** | `getGradePracticeOverview` | `data-access-analytics.ts` | 年级综合练习统计 |
|
||||
| **Component** | `PracticeStarter` | `components/practice-starter.tsx` | 练习发起器(四种模式选择) |
|
||||
| **Component** | `PracticeSessionView` | `components/practice-session-view.tsx` | 答题界面(逐题作答+自动判分) |
|
||||
| **Component** | `PracticeHistory` | `components/practice-history.tsx` | 练习历史列表 |
|
||||
| **Component** | `PracticeStatsCards` | `components/practice-stats-cards.tsx` | 学生端统计卡片 |
|
||||
| **Component** | `PracticeOverviewStatsCards` | `components/practice-overview-stats-cards.tsx` | 教师/年级端统计卡片 — 新增 |
|
||||
| **Component** | `ClassPracticeComparisonTable` | `components/class-practice-comparison-table.tsx` | 班级练习对比表格 — 新增 |
|
||||
| **Component** | `PracticeTypeBreakdownChart` | `components/practice-type-breakdown-chart.tsx` | 练习类型分布柱状图 — 新增 |
|
||||
| **Component** | `ClassKnowledgePointWeaknessChart` | `components/class-knowledge-point-weakness-chart.tsx` | 知识点薄弱度柱状图 — 新增 |
|
||||
| **Component** | `StudentPracticeRankingTable` | `components/student-practice-ranking-table.tsx` | 学生练习排名表格 — 新增 |
|
||||
| **Component** | `InactiveStudentsAlert` | `components/inactive-students-alert.tsx` | 未参与练习学生提醒 — 新增 |
|
||||
|
||||
**集成点**:
|
||||
|
||||
| 业务模块 | 集成组件 | 页面 | 说明 |
|
||||
|---------|---------|------|------|
|
||||
| error-book | `createPracticeSessionAction` | `student/error-book` 详情弹窗 | 错题详情页"发起变式练习"按钮 |
|
||||
| student | `PracticeStarter` / `PracticeSessionView` | `student/practice` / `student/practice/[sessionId]` | 学生端练习入口与答题 |
|
||||
| teacher | `PracticeOverviewStatsCards` 等 | `teacher/practice` | 教师端专项练习分析 — 新增 |
|
||||
| management | `PracticeOverviewStatsCards` 等 | `management/grade/practice` | 年级主任宏观数据分析 — 新增 |
|
||||
|
||||
**依赖关系**:
|
||||
- `modules/adaptive-practice` → `shared/db`(schema: practiceSessions/practiceAnswers/questions/knowledgePoints 等)
|
||||
- `modules/adaptive-practice` → `shared/lib/auth-guard`(权限校验)
|
||||
- `modules/adaptive-practice` → `shared/types/permissions`(权限常量)
|
||||
- `modules/adaptive-practice` → `shared/types/action-state`(返回值类型)
|
||||
- `modules/adaptive-practice` → `modules/classes/data-access`(getActiveStudentIdsByClassId/getClassNameById/getClassesByGradeId/getClassIdsByGradeIds/getStudentIdsByClassIds)
|
||||
- `modules/adaptive-practice` → `modules/users/data-access`(getUserIdsByGradeId/getUserNamesByIds)
|
||||
- `modules/adaptive-practice` → `modules/questions/data-access`(题目查询,出题策略使用)
|
||||
- `modules/adaptive-practice` → `modules/error-book/data-access`(错题数据,错题变式策略使用)
|
||||
- `app/(dashboard)/student/practice` → `modules/adaptive-practice`(学生端页面)
|
||||
- `app/(dashboard)/teacher/practice` → `modules/adaptive-practice`(教师端分析页面)— 新增
|
||||
- `app/(dashboard)/management/grade/practice` → `modules/adaptive-practice`(年级主任分析页面)— 新增
|
||||
- `modules/error-book/components/error-book-detail-dialog` → `modules/adaptive-practice/actions`(变式练习入口)
|
||||
|
||||
**权限点**:
|
||||
- `ADAPTIVE_PRACTICE_READ`:查看练习数据(admin/teacher/student/parent/grade_head/teaching_head 均有)
|
||||
- `ADAPTIVE_PRACTICE_MANAGE`:创建/提交/完成/放弃练习(仅 student 有)
|
||||
|
||||
**自动判分逻辑**:
|
||||
- `single_choice`:学生答案 ID 与正确答案 ID 完全匹配
|
||||
- `multiple_choice`:学生答案 ID 集合与正确答案 ID 集合完全匹配(集合比对)
|
||||
- `judgment`:学生布尔答案与正确布尔答案一致
|
||||
- `text`(填空题):不自动判分,返回 `null`,待教师批阅
|
||||
|
||||
**出题策略**:
|
||||
- `error_variant`:按难度升序选取原题(确保 AI 不可用时也能练习)
|
||||
- `knowledge_point`:按知识点+难度筛选,随机抽取
|
||||
- `weak_chapter`:排除已答题目,避免重复
|
||||
- `ai_recommended`:从 AI 推荐知识点抽题
|
||||
|
||||
**文件清单**:
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `modules/adaptive-practice/types.ts` | ~143 | 类型定义(PracticeType/PracticeSourceMeta 联合类型等) |
|
||||
| `modules/adaptive-practice/schema.ts` | ~75 | Zod 验证 schema |
|
||||
| `modules/adaptive-practice/data-access.ts` | ~470 | CRUD 操作+自动判分逻辑 |
|
||||
| `modules/adaptive-practice/data-access-strategy.ts` | ~310 | 四种出题策略实现 |
|
||||
| `modules/adaptive-practice/data-access-analytics.ts` | ~630 | 教师/年级宏观数据分析查询 |
|
||||
| `modules/adaptive-practice/actions.ts` | ~230 | 7 个 Server Actions(含权限校验) |
|
||||
| `modules/adaptive-practice/components/practice-starter.tsx` | ~270 | 练习发起器 |
|
||||
| `modules/adaptive-practice/components/practice-session-view.tsx` | ~420 | 答题界面 |
|
||||
| `modules/adaptive-practice/components/practice-history.tsx` | ~100 | 练习历史列表 |
|
||||
| `modules/adaptive-practice/components/practice-stats-cards.tsx` | ~80 | 学生端统计卡片 |
|
||||
| `modules/adaptive-practice/components/practice-overview-stats-cards.tsx` | ~95 | 教师/年级端统计卡片 — 新增 |
|
||||
| `modules/adaptive-practice/components/class-practice-comparison-table.tsx` | ~95 | 班级练习对比表格 — 新增 |
|
||||
| `modules/adaptive-practice/components/practice-type-breakdown-chart.tsx` | ~120 | 练习类型分布柱状图 — 新增 |
|
||||
| `modules/adaptive-practice/components/class-knowledge-point-weakness-chart.tsx` | ~150 | 知识点薄弱度柱状图 — 新增 |
|
||||
| `modules/adaptive-practice/components/student-practice-ranking-table.tsx` | ~130 | 学生练习排名表格 — 新增 |
|
||||
| `modules/adaptive-practice/components/inactive-students-alert.tsx` | ~75 | 未参与练习学生提醒 — 新增 |
|
||||
| `app/(dashboard)/student/practice/page.tsx` | - | 学生端练习列表页 |
|
||||
| `app/(dashboard)/student/practice/[sessionId]/page.tsx` | - | 学生端答题页 |
|
||||
| `app/(dashboard)/teacher/practice/page.tsx` | ~250 | 教师端专项练习分析页 — 新增 |
|
||||
| `app/(dashboard)/teacher/practice/loading.tsx` | - | 教师端加载骨架 — 新增 |
|
||||
| `app/(dashboard)/teacher/practice/error.tsx` | - | 教师端错误边界 — 新增 |
|
||||
| `app/(dashboard)/management/grade/practice/page.tsx` | ~140 | 年级主任宏观数据分析页 — 新增 |
|
||||
| `app/(dashboard)/management/grade/practice/loading.tsx` | - | 年级端加载骨架 — 新增 |
|
||||
| `app/(dashboard)/management/grade/practice/error.tsx` | - | 年级端错误边界 — 新增 |
|
||||
|
||||
**i18n**:
|
||||
- 翻译文件:`shared/i18n/messages/{locale}/practice.json`
|
||||
- 命名空间:`practice`
|
||||
- 键:`starter.*`、`session.*`、`result.*`、`history.*`、`stats.*`、`types.*`、`status.*`、`teacher.*`(含 overview/classComparison/typeBreakdown/knowledgePointWeakness/studentRanking/inactiveStudents)、`grade.*`(含 overview/classComparison)
|
||||
|
||||
**数据库表**:
|
||||
- `practice_sessions`:练习会话表(id/studentId/subjectId/practiceType/sourceMeta/status/totalQuestions/answeredQuestions/correctCount/startedAt/completedAt)
|
||||
- `practice_answers`:练习答题记录表(id/sessionId/studentId/questionId/variantContent/isVariant/orderIndex/status/studentAnswer/isCorrect/score/maxScore)
|
||||
|
||||
---
|
||||
|
||||
# 第三部分:已知架构问题和技术债
|
||||
|
||||
## 3.1 P0 严重问题(必须立即修复)
|
||||
@@ -2429,7 +2623,7 @@ shared/lib/{audit-logger, change-logger, auth-guard} → @/auth → shared/lib/*
|
||||
- `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`(✅ v4-P2-6 从 `actions.ts` 迁移至 `lib/scope-check.ts`:校验 classId 是否在 scope 允许范围内,供 actions.ts 与 actions-analytics.ts 复用。迁移原因:actions.ts 是 "use server" 文件要求所有 export 为 async,而 assertClassInScope 是同步函数)
|
||||
- `PaginatedGradeRecords` 接口(`data-access.ts`,`{ records: GradeRecordListItem[]; total: number }`,配合 DB 层分页)
|
||||
|
||||
### 3.6.3 homework 模块签名变更
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
# 备课模块使用问题修复设计
|
||||
|
||||
> **日期**:2026-06-23
|
||||
> **前置文档**:[2026-06-22-lesson-preparation-anchor-canvas-design.md](./2026-06-22-lesson-preparation-anchor-canvas-design.md)
|
||||
> **审计来源**:深度分析备课模块现状后发现的使用问题
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
备课模块 v3 锚点画布重构已完成,但实际使用中存在 15 个问题(3 个 P0 阻塞性 + 6 个 P1 严重 + 6 个 P2 一般),导致核心流程断裂、角色使用不完整。
|
||||
|
||||
### 核心闭环断裂
|
||||
|
||||
```
|
||||
教师备课 → 发布课案 → 学生查看 → 家长查看
|
||||
↑ ❌ P0-1 ❌ P0-2/3 ❌ P0-2/3
|
||||
无"发布"action 无权限/路由 无权限/路由
|
||||
```
|
||||
|
||||
## 二、功能定位
|
||||
|
||||
### 备课模块核心闭环
|
||||
|
||||
1. **教师备课**:选定教材+章节 → 锚点画布编辑 → 保存版本 → **发布**
|
||||
2. **学生查看**:按班级可见 published 课案,只读画布视图
|
||||
3. **家长查看**:通过孩子关系自动可见 published 课案
|
||||
4. **教研监督**:教研组长/年级主任查看本年级教师备课(只读)
|
||||
5. **管理员管理**:全校课案列表 + 统计
|
||||
|
||||
## 三、角色使用矩阵
|
||||
|
||||
| 角色 | 权限 | 路由 | 视图 |
|
||||
|------|------|------|------|
|
||||
| 教师 | CREATE/READ/UPDATE/DELETE/PUBLISH | `/teacher/lesson-plans` | 完整编辑画布 + 发布按钮 |
|
||||
| 学生 | READ(仅 published) | `/student/lesson-plans/[planId]/view` | 只读画布视图 |
|
||||
| 家长 | READ(仅孩子的 published) | `/parent/lesson-plans/[planId]/view` | 只读画布视图 |
|
||||
| 管理员 | 全部 | `/admin/lesson-plans` | 全校课案列表 + 统计 |
|
||||
| 教研组长 | READ(本年级教师) | `/grade-head/lesson-plans` | 本年级教师课案列表(只读) |
|
||||
| 年级主任 | READ(本年级教师) | `/teaching-head/lesson-plans` | 同上 |
|
||||
|
||||
### 学生查看视图设计
|
||||
|
||||
**只读画布视图**:复用 React Flow,配置:
|
||||
- `nodesDraggable={false}`
|
||||
- `nodesConnectable={false}`
|
||||
- `elementsSelectable={true}`(可点击查看节点详情)
|
||||
- `panOnDrag={true}`、`zoomOnScroll={true}`(可缩放平移)
|
||||
|
||||
保留正文+节点+锚点的完整视觉关系,学生可缩放平移但不可编辑。
|
||||
|
||||
### 发布机制
|
||||
|
||||
- 发布到班级所有学生,家长通过孩子关系自动可见
|
||||
- 状态流转:`draft` → `published`(发布)→ `draft`(撤回)或 `archived`(归档)
|
||||
- 发布后学生/家长立即可见,撤回后立即不可见
|
||||
|
||||
## 四、修复范围(P0+P1 优先)
|
||||
|
||||
### P0 阻塞性问题
|
||||
|
||||
| 编号 | 问题 | 修复方案 |
|
||||
|------|------|----------|
|
||||
| P0-1 | 无"发布课案"action | 新增 `publishLessonPlanAction` / `unpublishLessonPlanAction` |
|
||||
| P0-2 | 学生/家长无权限 | 给 student/parent 添加 `LESSON_PLAN_READ` 权限 |
|
||||
| P0-3 | 无学生/家长/管理员路由 | 新增路由 + 导航入口 |
|
||||
|
||||
### P1 严重问题
|
||||
|
||||
| 编号 | 问题 | 修复方案 |
|
||||
|------|------|----------|
|
||||
| P1-1 | 锚点节点选择器占位 | 完善 `AnchorNodeSelector`,接收节点列表 + 实现"锚定到新节点" |
|
||||
| P1-2 | 锚点偏移计算不可靠 | 改用 `markdownToPlainText` 搜索定位,支持跨段落 |
|
||||
| P1-3 | 边回写丢失 anchorId | anchorId 存入 `edge.data`,`fromRfEdges` 从 data 读取 |
|
||||
| P1-4 | 锚点边颜色无区分 | 使用 `getNodeColor(anchor.nodeId)` |
|
||||
| P1-5 | 教研组长无权限 | 给 grade_head/teaching_head 添加 `LESSON_PLAN_READ` 权限 |
|
||||
| P1-6 | 个人模板不显示 | TemplatePicker 调用 `getLessonPlanTemplatesAction` 合并显示 |
|
||||
|
||||
### P2 一般问题(本次顺带修复)
|
||||
|
||||
| 编号 | 问题 | 修复方案 |
|
||||
|------|------|----------|
|
||||
| P2-1 | 正文节点侧边面板提示误导 | 改为"请在画布上直接操作正文"或显示锚点列表 |
|
||||
| P2-5 | 默认骨架节点位置重叠 | 增大列间距,避免与正文节点重叠 |
|
||||
|
||||
## 五、实施阶段
|
||||
|
||||
### 阶段 1:权限与路由基础(P0-2、P0-3、P1-5)
|
||||
|
||||
**权限修改**(`shared/lib/permissions.ts`):
|
||||
- student 数组添加 `Permissions.LESSON_PLAN_READ`
|
||||
- parent 数组添加 `Permissions.LESSON_PLAN_READ`
|
||||
- grade_head 数组添加 `Permissions.LESSON_PLAN_READ`
|
||||
- teaching_head 数组添加 `Permissions.LESSON_PLAN_READ`
|
||||
|
||||
**路由新增**:
|
||||
- `app/(dashboard)/student/lesson-plans/page.tsx` — 课案列表(按班级 published)
|
||||
- `app/(dashboard)/student/lesson-plans/[planId]/view/page.tsx` — 只读画布
|
||||
- `app/(dashboard)/parent/lesson-plans/page.tsx` — 课案列表(按孩子 published)
|
||||
- `app/(dashboard)/parent/lesson-plans/[planId]/view/page.tsx` — 只读画布
|
||||
- `app/(dashboard)/admin/lesson-plans/page.tsx` — 全校课案列表 + 统计
|
||||
- `app/(dashboard)/admin/lesson-plans/[planId]/view/page.tsx` — 查看任意课案
|
||||
|
||||
**导航入口**(`shared/lib/navigation.ts` 或 `modules/layout/config/navigation.ts`):
|
||||
- student 导航添加"我的课案"
|
||||
- parent 导航添加"孩子课案"
|
||||
- admin 导航添加"课案管理"
|
||||
|
||||
**只读画布组件**:
|
||||
- 新建 `components/lesson-plan-readonly-view.tsx`
|
||||
- 复用 React Flow,配置只读模式
|
||||
- 复用 `toRfNodes` / `toRfEdges` 渲染
|
||||
|
||||
### 阶段 2:发布机制(P0-1)
|
||||
|
||||
**data-access 新增**(`data-access.ts`):
|
||||
```typescript
|
||||
export async function publishLessonPlan(planId: string, userId: string): Promise<void>
|
||||
export async function unpublishLessonPlan(planId: string, userId: string): Promise<void>
|
||||
```
|
||||
|
||||
**actions 新增**(`actions.ts`):
|
||||
```typescript
|
||||
export async function publishLessonPlanAction(planId: string): Promise<ActionState<string>>
|
||||
export async function unpublishLessonPlanAction(planId: string): Promise<ActionState<string>>
|
||||
```
|
||||
|
||||
**UI 修改**:
|
||||
- `LessonPlanEditor` 工具栏添加"发布"/"撤回发布"按钮
|
||||
- `LessonPlanCard` 添加"发布"/"撤回"操作菜单项
|
||||
- 发布时显示确认对话框
|
||||
|
||||
### 阶段 3:锚点交互完善(P1-1、P1-2、P1-3、P1-4)
|
||||
|
||||
**P1-1 完善 AnchorNodeSelector**:
|
||||
- 接收 `doc.nodes` 列表(过滤掉 textbook_content)
|
||||
- 渲染节点下拉选择器(显示节点标题 + 颜色标识)
|
||||
- 实现"锚定到新节点":弹出节点类型选择 → 创建节点 → 自动锚定
|
||||
|
||||
**P1-2 修复锚点偏移计算**:
|
||||
- `handleMouseUp` 改用 `Range.toString()` 获取选中文本
|
||||
- 在 `markdownToPlainText(content)` 中搜索定位 start/end 偏移
|
||||
- 支持跨段落、跨行内元素的选择
|
||||
|
||||
**P1-3 修复 fromRfEdges anchorId 丢失**:
|
||||
- `toRfEdges` 时将 `anchorId` 存入 `edge.data.anchorId`
|
||||
- `fromRfEdges` 从 `e.data?.anchorId` 读取
|
||||
|
||||
**P1-4 修复锚点边颜色**:
|
||||
- `rf-mappers.ts` 导入 `getNodeColor`
|
||||
- `toRfEdges` 中 `stroke: getNodeColor(anchor.nodeId)`
|
||||
|
||||
### 阶段 4:模板与体验(P1-6 + P2)
|
||||
|
||||
**P1-6 TemplatePicker 显示个人模板**:
|
||||
- 初始化时调用 `getLessonPlanTemplatesAction()`
|
||||
- 合并系统模板和个人模板展示
|
||||
- 个人模板标记"个人"徽章
|
||||
|
||||
**P2-1 修复正文节点侧边面板**:
|
||||
- `NodeEditPanel` 选中 textbook_content 时显示锚点列表
|
||||
- 或显示提示"请在画布上直接操作正文,点击正文选中文本可创建锚点"
|
||||
|
||||
**P2-5 优化默认骨架节点位置**:
|
||||
- 增大列间距,左列 x=80,右列 x=900
|
||||
- 正文节点居中 (500, 250)
|
||||
|
||||
## 六、数据模型变更
|
||||
|
||||
无数据模型变更。发布机制仅修改 `lessonPlans.status` 字段(已存在)。
|
||||
|
||||
## 七、i18n 键新增
|
||||
|
||||
### zh-CN / en grades 命名空间(lesson-preparation.json)
|
||||
|
||||
- `action.publish` / `action.unpublish`
|
||||
- `action.publishConfirm` / `action.unpublishConfirm`
|
||||
- `action.publishSuccess` / `action.unpublishSuccess`
|
||||
- `status.published` / `status.draft` / `status.archived`
|
||||
- `readonly.title` / `readonly.hint`
|
||||
- `admin.title` / `admin.stats` / `admin.totalPlans` / `admin.publishedPlans`
|
||||
- `gradeHead.title`
|
||||
- `anchor.selectNode` / `anchor.createNode` / `anchor.nodeTypePrompt`
|
||||
|
||||
## 八、架构文档同步
|
||||
|
||||
- `004_architecture_impact_map.md`:补充路由、权限、新组件
|
||||
- `005_architecture_data.json`:补充 routes、permissions、exports
|
||||
|
||||
## 九、验证标准
|
||||
|
||||
- `npx tsc --noEmit` 零错误
|
||||
- `npm run lint` 零错误
|
||||
- 教师可发布课案,学生/家长可查看 published 课案
|
||||
- 教研组长可查看本年级教师备课
|
||||
- 锚点交互完整可用(选中文本 → 选择节点 → 创建锚点)
|
||||
- 锚点边颜色与关联节点一致
|
||||
14
drizzle/0010_grade_record_answers.sql
Normal file
14
drizzle/0010_grade_record_answers.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `grade_record_answers` (
|
||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
||||
`grade_record_id` varchar(128) NOT NULL,
|
||||
`question_id` varchar(128) NOT NULL,
|
||||
`score` decimal(6, 2) NOT NULL,
|
||||
`full_score` decimal(6, 2) NOT NULL,
|
||||
`feedback` text,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
`updated_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `gra_gr_fk` FOREIGN KEY (`grade_record_id`) REFERENCES `grade_records`(`id`) ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT `gra_q_fk` FOREIGN KEY (`question_id`) REFERENCES `questions`(`id`) ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
CREATE INDEX `grade_record_answers_record_idx` ON `grade_record_answers`(`grade_record_id`);
|
||||
CREATE INDEX `grade_record_answers_question_idx` ON `grade_record_answers`(`question_id`);
|
||||
@@ -46,6 +46,8 @@ const eslintConfig = defineConfig([
|
||||
"test-results/**",
|
||||
// Debug scripts using CommonJS
|
||||
"tests/webapp/debug_drizzle.js",
|
||||
// Migration/maintenance scripts using CommonJS require()
|
||||
"scripts/**/*.js",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
1206
package-lock.json
generated
1206
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@
|
||||
"dr:failover": "bash scripts/failover.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
"@alicloud/dysmsapi20170525": "^4.5.1",
|
||||
"@alicloud/openapi-client": "^0.4.15",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -82,6 +85,7 @@
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.13",
|
||||
"nuqs": "^2.8.5",
|
||||
"openai": "^6.25.0",
|
||||
"otplib": "^13.4.1",
|
||||
@@ -98,6 +102,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.254",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -111,6 +116,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
118
scripts/add-ai-provider-visibility.js
Normal file
118
scripts/add-ai-provider-visibility.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* V3: 为 ai_providers 表添加 visibility 列(public/private)
|
||||
*
|
||||
* 背景:
|
||||
* - 管理员可发布 public provider,全员可用
|
||||
* - 普通用户(teacher/student 等)可创建 private provider,仅本人可见
|
||||
*
|
||||
* 缺失列清单:
|
||||
* - ai_providers.visibility (可见性:public | private,默认 private)
|
||||
* - 索引 ai_provider_visibility_idx (visibility)
|
||||
* - 索引 ai_provider_created_by_idx (created_by)
|
||||
*
|
||||
* 注意:默认值设为 'private',保证历史记录不会意外对全员公开。
|
||||
* 管理员可在 UI 中将需要共享的 provider 改为 public。
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
const ALTER_OPERATIONS = [
|
||||
{
|
||||
table: "ai_providers",
|
||||
description: "添加 visibility 列(public/private 可见性)",
|
||||
sql: [
|
||||
"ALTER TABLE `ai_providers` ADD COLUMN `visibility` ENUM('public','private') NOT NULL DEFAULT 'private' AFTER `is_default`",
|
||||
"ALTER TABLE `ai_providers` ADD INDEX `ai_provider_visibility_idx` (`visibility`)",
|
||||
"ALTER TABLE `ai_providers` ADD INDEX `ai_provider_created_by_idx` (`created_by`)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function columnExists(conn, table, column) {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
[table, column],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(conn, table, indexName) {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT INDEX_NAME FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
|
||||
LIMIT 1`,
|
||||
[table, indexName],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL });
|
||||
console.log("✅ 已连接数据库\n");
|
||||
|
||||
for (const op of ALTER_OPERATIONS) {
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
console.log(`表: ${op.table} — ${op.description}`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
for (const sql of op.sql) {
|
||||
try {
|
||||
const isColumn = sql.includes("ADD COLUMN");
|
||||
const isIndex = sql.includes("ADD INDEX");
|
||||
|
||||
if (isColumn) {
|
||||
const match = sql.match(/ADD COLUMN `(\w+)`/);
|
||||
if (match && await columnExists(conn, op.table, match[1])) {
|
||||
console.log(` ⏭️ 跳过已存在的列: ${match[1]}`);
|
||||
continue;
|
||||
}
|
||||
} else if (isIndex) {
|
||||
const match = sql.match(/ADD INDEX `(\w+)`/);
|
||||
if (match && await indexExists(conn, op.table, match[1])) {
|
||||
console.log(` ⏭️ 跳过已存在的索引: ${match[1]}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await conn.execute(sql);
|
||||
console.log(` ✅ 执行成功: ${sql.slice(0, 100)}...`);
|
||||
} catch (err) {
|
||||
if (err.code === "ER_DUP_FIELDNAME" || err.code === "ER_DUP_KEYNAME" || err.errno === 1060 || err.errno === 1061) {
|
||||
console.log(` ⏭️ 已存在,跳过: ${err.message}`);
|
||||
} else {
|
||||
console.error(` ❌ 执行失败: ${err.message}`);
|
||||
console.error(` SQL: ${sql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// 验证最终列结构
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
console.log("最终验证 — 检查 visibility 列是否已添加");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const checks = [
|
||||
["ai_providers", "visibility"],
|
||||
];
|
||||
|
||||
let allOk = true;
|
||||
for (const [table, col] of checks) {
|
||||
const exists = await columnExists(conn, table, col);
|
||||
const status = exists ? "✅" : "❌";
|
||||
if (!exists) allOk = false;
|
||||
console.log(` ${status} ${table}.${col}`);
|
||||
}
|
||||
|
||||
console.log(allOk ? "\n✅ visibility 列已就绪" : "\n❌ 仍有缺失列,请检查错误");
|
||||
|
||||
await conn.end();
|
||||
process.exitCode = allOk ? 0 : 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("致命错误:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
146
scripts/add-missing-columns.js
Normal file
146
scripts/add-missing-columns.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 为已存在的表添加缺失的列(因 drizzle-kit push 阻塞未同步的 schema 变更)
|
||||
*
|
||||
* 缺失列清单:
|
||||
* - learning_diagnostic_reports.class_id (v4-P1-4 班级报告关联)
|
||||
* - announcements.is_pinned (V2-P2-13d 公告置顶)
|
||||
* - messages.is_starred (V2-P2-13c 消息星标)
|
||||
* - message_notifications.priority (V2-P2-13b 通知优先级)
|
||||
* - message_notifications.is_archived (V2-P2-13b 通知归档)
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
// 每个 ALTER 操作:[表名, 描述, SQL语句数组]
|
||||
const ALTER_OPERATIONS = [
|
||||
{
|
||||
table: "learning_diagnostic_reports",
|
||||
description: "添加 class_id 列(班级报告关联)",
|
||||
sql: [
|
||||
"ALTER TABLE `learning_diagnostic_reports` ADD COLUMN `class_id` varchar(128) NULL AFTER `generated_by`",
|
||||
"ALTER TABLE `learning_diagnostic_reports` ADD INDEX `diagnostic_class_idx` (`class_id`)",
|
||||
"ALTER TABLE `learning_diagnostic_reports` ADD CONSTRAINT `diagnostic_class_fk` FOREIGN KEY (`class_id`) REFERENCES `classes` (`id`) ON DELETE SET NULL",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "announcements",
|
||||
description: "添加 is_pinned 列(公告置顶)",
|
||||
sql: [
|
||||
"ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean NOT NULL DEFAULT false AFTER `published_at`",
|
||||
"ALTER TABLE `announcements` ADD INDEX `announcements_status_pinned_idx` (`status`, `is_pinned`)",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "messages",
|
||||
description: "添加 is_starred 列(消息星标)",
|
||||
sql: [
|
||||
"ALTER TABLE `messages` ADD COLUMN `is_starred` boolean NOT NULL DEFAULT false AFTER `receiver_deleted_at`",
|
||||
"ALTER TABLE `messages` ADD INDEX `messages_receiver_starred_idx` (`receiver_id`, `is_starred`)",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "message_notifications",
|
||||
description: "添加 priority 和 is_archived 列(通知优先级与归档)",
|
||||
sql: [
|
||||
"ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) NOT NULL DEFAULT 'normal' AFTER `is_read`",
|
||||
"ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean NOT NULL DEFAULT false AFTER `priority`",
|
||||
"ALTER TABLE `message_notifications` ADD INDEX `message_notifications_priority_idx` (`priority`)",
|
||||
"ALTER TABLE `message_notifications` ADD INDEX `message_notifications_user_archived_idx` (`user_id`, `is_archived`)",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function columnExists(conn, table, column) {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT COLUMN_NAME FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
[table, column],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(conn, table, indexName) {
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT INDEX_NAME FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
|
||||
LIMIT 1`,
|
||||
[table, indexName],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL });
|
||||
console.log("✅ 已连接数据库\n");
|
||||
|
||||
for (const op of ALTER_OPERATIONS) {
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
console.log(`表: ${op.table} — ${op.description}`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
for (const sql of op.sql) {
|
||||
try {
|
||||
// 检查是否已存在(ADD COLUMN / ADD INDEX / ADD CONSTRAINT)
|
||||
const isColumn = sql.includes("ADD COLUMN");
|
||||
const isIndex = sql.includes("ADD INDEX");
|
||||
const isConstraint = sql.includes("ADD CONSTRAINT");
|
||||
|
||||
if (isColumn) {
|
||||
const match = sql.match(/ADD COLUMN `(\w+)`/);
|
||||
if (match && await columnExists(conn, op.table, match[1])) {
|
||||
console.log(` ⏭️ 跳过已存在的列: ${match[1]}`);
|
||||
continue;
|
||||
}
|
||||
} else if (isIndex) {
|
||||
const match = sql.match(/ADD INDEX `(\w+)`/);
|
||||
if (match && await indexExists(conn, op.table, match[1])) {
|
||||
console.log(` ⏭️ 跳过已存在的索引: ${match[1]}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await conn.execute(sql);
|
||||
console.log(` ✅ 执行成功: ${sql.slice(0, 100)}...`);
|
||||
} catch (err) {
|
||||
if (err.code === "ER_DUP_FIELDNAME" || err.code === "ER_DUP_KEYNAME" || err.errno === 1060 || err.errno === 1061) {
|
||||
console.log(` ⏭️ 已存在,跳过: ${err.message}`);
|
||||
} else {
|
||||
console.error(` ❌ 执行失败: ${err.message}`);
|
||||
console.error(` SQL: ${sql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// 验证最终列结构
|
||||
console.log(`${"=".repeat(60)}`);
|
||||
console.log("最终验证 — 检查缺失列是否已添加");
|
||||
console.log("=".repeat(60));
|
||||
|
||||
const checks = [
|
||||
["learning_diagnostic_reports", "class_id"],
|
||||
["announcements", "is_pinned"],
|
||||
["messages", "is_starred"],
|
||||
["message_notifications", "priority"],
|
||||
["message_notifications", "is_archived"],
|
||||
];
|
||||
|
||||
let allOk = true;
|
||||
for (const [table, col] of checks) {
|
||||
const exists = await columnExists(conn, table, col);
|
||||
const status = exists ? "✅" : "❌";
|
||||
if (!exists) allOk = false;
|
||||
console.log(` ${status} ${table}.${col}`);
|
||||
}
|
||||
|
||||
console.log(allOk ? "\n✅ 所有缺失列已就绪" : "\n❌ 仍有缺失列,请检查错误");
|
||||
|
||||
await conn.end();
|
||||
process.exitCode = allOk ? 0 : 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("致命错误:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
15
scripts/clear-error-book.ts
Normal file
15
scripts/clear-error-book.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import "dotenv/config"
|
||||
import { db } from "../src/shared/db"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
async function clear() {
|
||||
await db.execute(sql`DELETE FROM error_book_reviews`)
|
||||
await db.execute(sql`DELETE FROM error_book_items`)
|
||||
console.log("✓ 已清空 error_book_reviews 和 error_book_items 表")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
clear().catch((e) => {
|
||||
console.error("❌ 清空失败:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
229
scripts/create-missing-tables.js
Normal file
229
scripts/create-missing-tables.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 直接创建 4 个失败模块依赖的数据库表。
|
||||
*
|
||||
* 背景:drizzle-kit push 因 exam_questions 表的 FK 约束冲突(ER_DROP_INDEX_FK)
|
||||
* 无法执行,导致以下表始终未创建:
|
||||
* - announcements / announcement_reads (公告模块)
|
||||
* - message_notifications (消息模块)
|
||||
* - learning_diagnostic_reports (学情诊断模块)
|
||||
* - error_book_items / error_book_reviews (错题分析模块)
|
||||
*
|
||||
* 本脚本使用 CREATE TABLE IF NOT EXISTS 直接建表,绕过 drizzle-kit 的全量 diff。
|
||||
* 表结构严格对齐 src/shared/db/schema.ts 中的 Drizzle 定义。
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
const TABLES_TO_CHECK = [
|
||||
"announcements",
|
||||
"announcement_reads",
|
||||
"message_notifications",
|
||||
"learning_diagnostic_reports",
|
||||
"error_book_items",
|
||||
"error_book_reviews",
|
||||
];
|
||||
|
||||
// 按依赖顺序排列:父表在前,子表在后
|
||||
const CREATE_STATEMENTS = [
|
||||
// --- 1. announcements ---
|
||||
`CREATE TABLE IF NOT EXISTS \`announcements\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`title\` varchar(255) NOT NULL,
|
||||
\`content\` text NOT NULL,
|
||||
\`type\` enum('school','grade','class') NOT NULL DEFAULT 'school',
|
||||
\`status\` enum('draft','published','archived') NOT NULL DEFAULT 'draft',
|
||||
\`target_grade_id\` varchar(128),
|
||||
\`target_class_id\` varchar(128),
|
||||
\`author_id\` varchar(128) NOT NULL,
|
||||
\`published_at\` datetime,
|
||||
\`is_pinned\` boolean NOT NULL DEFAULT false,
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`announcements_author_idx\` (\`author_id\`),
|
||||
INDEX \`announcements_status_idx\` (\`status\`),
|
||||
INDEX \`announcements_type_idx\` (\`type\`),
|
||||
INDEX \`announcements_target_grade_idx\` (\`target_grade_id\`),
|
||||
INDEX \`announcements_target_class_idx\` (\`target_class_id\`),
|
||||
INDEX \`announcements_status_pinned_idx\` (\`status\`, \`is_pinned\`),
|
||||
CONSTRAINT \`announcements_author_fk\` FOREIGN KEY (\`author_id\`) REFERENCES \`users\` (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
|
||||
// --- 2. announcement_reads ---
|
||||
`CREATE TABLE IF NOT EXISTS \`announcement_reads\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`announcement_id\` varchar(128) NOT NULL,
|
||||
\`user_id\` varchar(128) NOT NULL,
|
||||
\`read_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
UNIQUE INDEX \`announcement_reads_unique_idx\` (\`announcement_id\`, \`user_id\`),
|
||||
INDEX \`announcement_reads_announcement_idx\` (\`announcement_id\`),
|
||||
INDEX \`announcement_reads_user_idx\` (\`user_id\`),
|
||||
CONSTRAINT \`announcement_reads_announcement_fk\` FOREIGN KEY (\`announcement_id\`) REFERENCES \`announcements\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`announcement_reads_user_fk\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
|
||||
// --- 3. message_notifications ---
|
||||
`CREATE TABLE IF NOT EXISTS \`message_notifications\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`user_id\` varchar(128) NOT NULL,
|
||||
\`type\` varchar(128) NOT NULL,
|
||||
\`title\` varchar(255) NOT NULL,
|
||||
\`content\` text,
|
||||
\`link\` varchar(512),
|
||||
\`is_read\` boolean NOT NULL DEFAULT false,
|
||||
\`priority\` varchar(16) NOT NULL DEFAULT 'normal',
|
||||
\`is_archived\` boolean NOT NULL DEFAULT false,
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`message_notifications_user_idx\` (\`user_id\`),
|
||||
INDEX \`message_notifications_is_read_idx\` (\`is_read\`),
|
||||
INDEX \`message_notifications_user_read_idx\` (\`user_id\`, \`is_read\`),
|
||||
INDEX \`message_notifications_created_at_idx\` (\`created_at\`),
|
||||
INDEX \`message_notifications_priority_idx\` (\`priority\`),
|
||||
INDEX \`message_notifications_user_archived_idx\` (\`user_id\`, \`is_archived\`),
|
||||
CONSTRAINT \`message_notifications_user_fk\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
|
||||
// --- 4. learning_diagnostic_reports ---
|
||||
`CREATE TABLE IF NOT EXISTS \`learning_diagnostic_reports\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`student_id\` varchar(128),
|
||||
\`generated_by\` varchar(128),
|
||||
\`class_id\` varchar(128),
|
||||
\`report_type\` enum('individual','class','grade') NOT NULL DEFAULT 'individual',
|
||||
\`period\` varchar(50),
|
||||
\`summary\` text,
|
||||
\`strengths\` json,
|
||||
\`weaknesses\` json,
|
||||
\`recommendations\` json,
|
||||
\`overall_score\` decimal(5,2),
|
||||
\`status\` enum('draft','published','archived') NOT NULL DEFAULT 'draft',
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`diagnostic_student_idx\` (\`student_id\`),
|
||||
INDEX \`diagnostic_generated_by_idx\` (\`generated_by\`),
|
||||
INDEX \`diagnostic_status_idx\` (\`status\`),
|
||||
INDEX \`diagnostic_report_type_idx\` (\`report_type\`),
|
||||
INDEX \`diagnostic_class_idx\` (\`class_id\`),
|
||||
CONSTRAINT \`diagnostic_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`diagnostic_generated_by_fk\` FOREIGN KEY (\`generated_by\`) REFERENCES \`users\` (\`id\`) ON DELETE SET NULL,
|
||||
CONSTRAINT \`diagnostic_class_fk\` FOREIGN KEY (\`class_id\`) REFERENCES \`classes\` (\`id\`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
|
||||
// --- 5. error_book_items ---
|
||||
`CREATE TABLE IF NOT EXISTS \`error_book_items\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`student_id\` varchar(128) NOT NULL,
|
||||
\`question_id\` varchar(128) NOT NULL,
|
||||
\`source_type\` enum('exam','homework','manual') NOT NULL DEFAULT 'manual',
|
||||
\`source_id\` varchar(128),
|
||||
\`student_answer\` json,
|
||||
\`correct_answer\` json,
|
||||
\`subject_id\` varchar(128),
|
||||
\`knowledge_point_ids\` json,
|
||||
\`status\` enum('new','learning','mastered','archived') NOT NULL DEFAULT 'new',
|
||||
\`mastery_level\` int NOT NULL DEFAULT 0,
|
||||
\`next_review_at\` timestamp NULL,
|
||||
\`review_interval\` int NOT NULL DEFAULT 1,
|
||||
\`review_count\` int NOT NULL DEFAULT 0,
|
||||
\`correct_streak\` int NOT NULL DEFAULT 0,
|
||||
\`note\` text,
|
||||
\`error_tags\` json,
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`updated_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`eb_item_student_idx\` (\`student_id\`),
|
||||
INDEX \`eb_item_student_status_idx\` (\`student_id\`, \`status\`),
|
||||
INDEX \`eb_item_student_review_idx\` (\`student_id\`, \`next_review_at\`),
|
||||
INDEX \`eb_item_question_idx\` (\`question_id\`),
|
||||
INDEX \`eb_item_subject_idx\` (\`subject_id\`),
|
||||
INDEX \`eb_item_source_idx\` (\`source_type\`, \`source_id\`),
|
||||
CONSTRAINT \`eb_item_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`eb_item_question_fk\` FOREIGN KEY (\`question_id\`) REFERENCES \`questions\` (\`id\`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
|
||||
// --- 6. error_book_reviews ---
|
||||
`CREATE TABLE IF NOT EXISTS \`error_book_reviews\` (
|
||||
\`id\` varchar(128) NOT NULL,
|
||||
\`item_id\` varchar(128) NOT NULL,
|
||||
\`student_id\` varchar(128) NOT NULL,
|
||||
\`result\` enum('again','hard','good','easy') NOT NULL,
|
||||
\`reviewed_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
\`new_interval\` int,
|
||||
\`new_mastery_level\` int,
|
||||
\`created_at\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (\`id\`),
|
||||
INDEX \`eb_review_item_idx\` (\`item_id\`),
|
||||
INDEX \`eb_review_student_idx\` (\`student_id\`),
|
||||
INDEX \`eb_review_student_reviewed_idx\` (\`student_id\`, \`reviewed_at\`),
|
||||
CONSTRAINT \`eb_review_item_fk\` FOREIGN KEY (\`item_id\`) REFERENCES \`error_book_items\` (\`id\`) ON DELETE CASCADE,
|
||||
CONSTRAINT \`eb_review_student_fk\` FOREIGN KEY (\`student_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("❌ DATABASE_URL 未设置");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection({ uri: url });
|
||||
console.log("✅ 已连接数据库");
|
||||
|
||||
// 1. 检查哪些表已存在
|
||||
const [rows] = await conn.execute(
|
||||
`SELECT TABLE_NAME FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (${TABLES_TO_CHECK.map(() => "?").join(",")})`,
|
||||
TABLES_TO_CHECK,
|
||||
);
|
||||
const existing = new Set(rows.map((r) => r.TABLE_NAME));
|
||||
console.log(`📋 已存在的表: ${[...existing].join(", ") || "(无)"}`);
|
||||
|
||||
// 2. 按顺序创建缺失的表
|
||||
for (const sql of CREATE_STATEMENTS) {
|
||||
const match = sql.match(/CREATE TABLE IF NOT EXISTS `(\w+)`/);
|
||||
const tableName = match ? match[1] : "(unknown)";
|
||||
if (existing.has(tableName)) {
|
||||
console.log(`⏭️ 跳过已存在的表: ${tableName}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await conn.execute(sql);
|
||||
console.log(`✅ 创建表成功: ${tableName}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ 创建表失败: ${tableName}`);
|
||||
console.error(` 错误: ${err.message}`);
|
||||
console.error(` SQL: ${sql.slice(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 最终验证
|
||||
const [finalRows] = await conn.execute(
|
||||
`SELECT TABLE_NAME FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (${TABLES_TO_CHECK.map(() => "?").join(",")})`,
|
||||
TABLES_TO_CHECK,
|
||||
);
|
||||
const finalExisting = new Set(finalRows.map((r) => r.TABLE_NAME));
|
||||
const missing = TABLES_TO_CHECK.filter((t) => !finalExisting.has(t));
|
||||
|
||||
console.log("\n📊 最终状态:");
|
||||
console.log(` 已存在: ${[...finalExisting].join(", ")}`);
|
||||
if (missing.length > 0) {
|
||||
console.log(` 仍缺失: ${missing.join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(" ✅ 所有目标表均已就绪");
|
||||
}
|
||||
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("致命错误:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
181
scripts/diagnose-error-book.ts
Normal file
181
scripts/diagnose-error-book.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 诊断脚本:检查错题本数据状态
|
||||
* 用法:npx tsx scripts/diagnose-error-book.ts
|
||||
*/
|
||||
import "dotenv/config"
|
||||
import { db } from "../src/shared/db"
|
||||
import {
|
||||
errorBookItems,
|
||||
errorBookReviews,
|
||||
examSubmissions,
|
||||
homeworkSubmissions,
|
||||
users,
|
||||
usersToRoles,
|
||||
roles,
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
grades,
|
||||
exams,
|
||||
homeworkAssignments,
|
||||
} from "../src/shared/db/schema"
|
||||
import { eq, inArray, count } from "drizzle-orm"
|
||||
|
||||
async function diagnose() {
|
||||
console.log("🔍 错题本数据诊断开始...\n")
|
||||
|
||||
// 1. 检查表是否存在且有数据
|
||||
const [itemsCount] = await db.select({ value: count() }).from(errorBookItems)
|
||||
const [reviewsCount] = await db.select({ value: count() }).from(errorBookReviews)
|
||||
console.log(`📊 错题本表数据:`)
|
||||
console.log(` error_book_items: ${itemsCount.value} 行`)
|
||||
console.log(` error_book_reviews: ${reviewsCount.value} 行`)
|
||||
|
||||
if (Number(itemsCount.value) === 0) {
|
||||
console.log("\n❌ error_book_items 表为空!需要运行 seed-error-book.ts")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检查错题的 subjectId 分布
|
||||
const subjectDist = await db
|
||||
.select({
|
||||
subjectId: errorBookItems.subjectId,
|
||||
count: count(),
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.groupBy(errorBookItems.subjectId)
|
||||
console.log(`\n📊 错题 subjectId 分布:`)
|
||||
for (const row of subjectDist) {
|
||||
console.log(` subjectId=${row.subjectId ?? "NULL"}: ${row.count} 条`)
|
||||
}
|
||||
|
||||
// 3. 检查错题的 sourceType 分布
|
||||
const sourceDist = await db
|
||||
.select({
|
||||
sourceType: errorBookItems.sourceType,
|
||||
count: count(),
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.groupBy(errorBookItems.sourceType)
|
||||
console.log(`\n📊 错题 sourceType 分布:`)
|
||||
for (const row of sourceDist) {
|
||||
console.log(` ${row.sourceType}: ${row.count} 条`)
|
||||
}
|
||||
|
||||
// 4. 检查有错题的学生
|
||||
const studentsWithErrors = await db
|
||||
.select({
|
||||
studentId: errorBookItems.studentId,
|
||||
itemCount: count(),
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.groupBy(errorBookItems.studentId)
|
||||
console.log(`\n📊 有错题的学生 (${studentsWithErrors.length} 名):`)
|
||||
for (const row of studentsWithErrors) {
|
||||
console.log(` ${row.studentId}: ${row.itemCount} 条`)
|
||||
}
|
||||
|
||||
// 5. 检查教师角色用户
|
||||
const teacherRole = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, "teacher")).limit(1)
|
||||
if (teacherRole.length > 0) {
|
||||
const teachers = await db
|
||||
.select({ userId: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.where(eq(usersToRoles.roleId, teacherRole[0].id))
|
||||
console.log(`\n📊 教师用户 (${teachers.length} 名):`)
|
||||
for (const t of teachers) {
|
||||
const user = await db.select({ name: users.name }).from(users).where(eq(users.id, t.userId)).limit(1)
|
||||
// 查询该教师能访问的班级
|
||||
const homeroomClasses = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.teacherId, t.userId))
|
||||
const subjectClasses = await db
|
||||
.select({ classId: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, t.userId))
|
||||
const allClassIds = [...new Set([...homeroomClasses.map((c) => c.id), ...subjectClasses.map((c) => c.classId)])]
|
||||
|
||||
// 查询这些班级的学生
|
||||
let studentCount = 0
|
||||
let errorCount = 0
|
||||
if (allClassIds.length > 0) {
|
||||
const students = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, allClassIds))
|
||||
const studentIds = [...new Set(students.map((s) => s.studentId))]
|
||||
studentCount = studentIds.length
|
||||
|
||||
if (studentIds.length > 0) {
|
||||
const errorItems = await db
|
||||
.select({ value: count() })
|
||||
.from(errorBookItems)
|
||||
.where(inArray(errorBookItems.studentId, studentIds))
|
||||
errorCount = Number(errorItems[0]?.value ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ${user[0]?.name ?? t.userId} (${t.userId}): 班级=${allClassIds.length}, 学生=${studentCount}, 错题=${errorCount}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 检查年级组长
|
||||
const gradeHeadRole = await db.select({ id: roles.id }).from(roles).where(eq(roles.name, "grade_head")).limit(1)
|
||||
if (gradeHeadRole.length > 0) {
|
||||
const gradeHeads = await db
|
||||
.select({ userId: usersToRoles.userId })
|
||||
.from(usersToRoles)
|
||||
.where(eq(usersToRoles.roleId, gradeHeadRole[0].id))
|
||||
console.log(`\n📊 年级组长 (${gradeHeads.length} 名):`)
|
||||
for (const gh of gradeHeads) {
|
||||
const user = await db.select({ name: users.name }).from(users).where(eq(users.id, gh.userId)).limit(1)
|
||||
const managedGrades = await db.select({ id: grades.id, name: grades.name }).from(grades).where(eq(grades.gradeHeadId, gh.userId))
|
||||
console.log(` ${user[0]?.name ?? gh.userId} (${gh.userId}): 管理年级=${managedGrades.length}`)
|
||||
for (const g of managedGrades) {
|
||||
const gradeClasses = await db.select({ id: classes.id }).from(classes).where(eq(classes.gradeId, g.id))
|
||||
console.log(` 年级 ${g.name}: ${gradeClasses.length} 个班级`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 检查已批改的提交数量
|
||||
const gradedExams = await db.select({ value: count() }).from(examSubmissions).where(eq(examSubmissions.status, "graded"))
|
||||
const gradedHw = await db.select({ value: count() }).from(homeworkSubmissions).where(eq(homeworkSubmissions.status, "graded"))
|
||||
console.log(`\n📊 已批改提交:`)
|
||||
console.log(` 考试提交: ${gradedExams[0]?.value ?? 0}`)
|
||||
console.log(` 作业提交: ${gradedHw[0]?.value ?? 0}`)
|
||||
|
||||
// 8. 检查 exams 表的 subjectId 是否有值
|
||||
const examSubjectStats = await db
|
||||
.select({
|
||||
subjectId: exams.subjectId,
|
||||
count: count(),
|
||||
})
|
||||
.from(exams)
|
||||
.groupBy(exams.subjectId)
|
||||
console.log(`\n📊 exams 表 subjectId 分布:`)
|
||||
for (const row of examSubjectStats) {
|
||||
console.log(` subjectId=${row.subjectId ?? "NULL"}: ${row.count} 个考试`)
|
||||
}
|
||||
|
||||
// 9. 检查 homeworkAssignments 是否有 subjectId 字段(应该没有)
|
||||
console.log(`\n📊 homeworkAssignments 表结构检查:`)
|
||||
try {
|
||||
const hwRows = await db.select().from(homeworkAssignments).limit(1)
|
||||
if (hwRows.length > 0) {
|
||||
const keys = Object.keys(hwRows[0])
|
||||
console.log(` 字段列表: ${keys.join(", ")}`)
|
||||
console.log(` 是否有 subjectId 字段: ${keys.includes("subjectId") ? "是" : "否"}`)
|
||||
} else {
|
||||
console.log(` 表为空`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` 查询失败: ${(e as Error).message}`)
|
||||
}
|
||||
|
||||
console.log("\n✅ 诊断完成")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
diagnose().catch((e) => {
|
||||
console.error("❌ 诊断失败:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
65
scripts/diagnose-tables.js
Normal file
65
scripts/diagnose-tables.js
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 诊断 4 个失败模块的数据库表结构和查询问题
|
||||
*/
|
||||
require("dotenv/config");
|
||||
const mysql = require("mysql2/promise");
|
||||
|
||||
async function main() {
|
||||
const conn = await mysql.createConnection({ uri: process.env.DATABASE_URL });
|
||||
|
||||
const tables = [
|
||||
"learning_diagnostic_reports",
|
||||
"announcements",
|
||||
"messages",
|
||||
"message_notifications",
|
||||
"error_book_items",
|
||||
"error_book_reviews",
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
console.log(`\n${"=".repeat(60)}`);
|
||||
console.log(`表: ${table}`);
|
||||
console.log("=".repeat(60));
|
||||
|
||||
// 检查表是否存在
|
||||
const [exists] = await conn.execute(
|
||||
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
||||
[table],
|
||||
);
|
||||
|
||||
if (exists.length === 0) {
|
||||
console.log(" ❌ 表不存在!");
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(" ✅ 表存在");
|
||||
|
||||
// 获取列信息
|
||||
const [cols] = await conn.execute(
|
||||
`SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_TYPE
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
|
||||
ORDER BY ORDINAL_POSITION`,
|
||||
[table],
|
||||
);
|
||||
console.log(" 列:");
|
||||
for (const c of cols) {
|
||||
console.log(` - ${c.COLUMN_NAME}: ${c.COLUMN_TYPE} (NULL=${c.IS_NULLABLE}, DEFAULT=${c.COLUMN_DEFAULT})`);
|
||||
}
|
||||
|
||||
// 获取行数
|
||||
try {
|
||||
const [count] = await conn.execute(`SELECT COUNT(*) as cnt FROM \`${table}\``);
|
||||
console.log(` 行数: ${count[0].cnt}`);
|
||||
} catch (err) {
|
||||
console.log(` 行数查询失败: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("致命错误:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
413
scripts/seed-error-book.ts
Normal file
413
scripts/seed-error-book.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 错题本种子数据脚本
|
||||
*
|
||||
* 从现有的考试/作业提交中自动采集错题,并模拟部分复习记录。
|
||||
* 直接操作数据库,不依赖 data-access.ts(避免 server-only 包冲突)。
|
||||
* 用法:npx tsx scripts/seed-error-book.ts
|
||||
*/
|
||||
|
||||
import "dotenv/config"
|
||||
import { db } from "../src/shared/db"
|
||||
import {
|
||||
examSubmissions,
|
||||
homeworkSubmissions,
|
||||
submissionAnswers,
|
||||
homeworkAnswers,
|
||||
examQuestions,
|
||||
homeworkAssignmentQuestions,
|
||||
errorBookItems,
|
||||
errorBookReviews,
|
||||
questionsToKnowledgePoints,
|
||||
exams,
|
||||
homeworkAssignments,
|
||||
} from "../src/shared/db/schema"
|
||||
import { eq, and, inArray, sql } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
// SM-2 算法常量(与 sm2-algorithm.ts 保持一致)
|
||||
const REVIEW_INTERVALS = {
|
||||
again: { interval: 1, masteryDelta: -1, streakDelta: -999 },
|
||||
hard: { interval: 2, masteryDelta: 0, streakDelta: 0 },
|
||||
good: { interval: 4, masteryDelta: 1, streakDelta: 1 },
|
||||
easy: { interval: 7, masteryDelta: 2, streakDelta: 1 },
|
||||
} as const
|
||||
|
||||
const INTERVAL_MULTIPLIERS = {
|
||||
again: 1,
|
||||
hard: 1.2,
|
||||
good: 1.5,
|
||||
easy: 2,
|
||||
} as const
|
||||
|
||||
type ReviewResult = keyof typeof REVIEW_INTERVALS
|
||||
|
||||
function calculateNewInterval(currentInterval: number, result: ReviewResult, reviewCount: number): number {
|
||||
const base = REVIEW_INTERVALS[result]
|
||||
if (result === "again") return 1
|
||||
if (reviewCount === 0) return base.interval
|
||||
const multiplier = INTERVAL_MULTIPLIERS[result]
|
||||
return Math.max(base.interval, Math.round(currentInterval * multiplier))
|
||||
}
|
||||
|
||||
function calculateNewMastery(currentMastery: number, result: ReviewResult, correctStreak: number): number {
|
||||
const delta = REVIEW_INTERVALS[result].masteryDelta
|
||||
const newMastery = Math.max(0, Math.min(5, currentMastery + delta))
|
||||
if (correctStreak >= 3) return Math.max(newMastery, 5)
|
||||
return newMastery
|
||||
}
|
||||
|
||||
function deriveStatus(masteryLevel: number, correctStreak: number): "new" | "learning" | "mastered" {
|
||||
if (masteryLevel >= 5 || correctStreak >= 3) return "mastered"
|
||||
if (masteryLevel >= 1) return "learning"
|
||||
return "new"
|
||||
}
|
||||
|
||||
function calculateNewCorrectStreak(currentStreak: number, result: ReviewResult): number {
|
||||
const streakDelta = REVIEW_INTERVALS[result].streakDelta
|
||||
if (streakDelta < 0) return 0
|
||||
return currentStreak + streakDelta
|
||||
}
|
||||
|
||||
async function collectFromExamSubmission(submissionId: string, studentId: string): Promise<number> {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(eq(examSubmissions.id, submissionId), eq(examSubmissions.studentId, studentId)),
|
||||
})
|
||||
if (!submission) return 0
|
||||
|
||||
// 查询考试以获取 subjectId
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, submission.examId),
|
||||
})
|
||||
const examSubjectId = exam?.subjectId ?? null
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: submissionAnswers.questionId,
|
||||
answerContent: submissionAnswers.answerContent,
|
||||
score: submissionAnswers.score,
|
||||
feedback: submissionAnswers.feedback,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
|
||||
const questionIds = answers.map((a) => a.questionId)
|
||||
const examQuestionScores = await db
|
||||
.select({ questionId: examQuestions.questionId, maxScore: examQuestions.score })
|
||||
.from(examQuestions)
|
||||
.where(and(eq(examQuestions.examId, submission.examId), inArray(examQuestions.questionId, questionIds)))
|
||||
|
||||
const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
const wrongAnswers = answers.filter((a) => {
|
||||
const max = maxScoreMap.get(a.questionId) ?? 0
|
||||
return (a.score ?? 0) < max
|
||||
})
|
||||
|
||||
if (wrongAnswers.length === 0) return 0
|
||||
|
||||
// 去重
|
||||
const existing = await db
|
||||
.select({ questionId: errorBookItems.questionId })
|
||||
.from(errorBookItems)
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.studentId, studentId),
|
||||
inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId))
|
||||
)
|
||||
)
|
||||
const existingSet = new Set(existing.map((e) => e.questionId))
|
||||
|
||||
// 查询知识点
|
||||
const kpRows = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId)))
|
||||
const kpMap = new Map<string, string[]>()
|
||||
for (const kp of kpRows) {
|
||||
const list = kpMap.get(kp.questionId) ?? []
|
||||
list.push(kp.knowledgePointId)
|
||||
kpMap.set(kp.questionId, list)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const toInsert = wrongAnswers
|
||||
.filter((a) => !existingSet.has(a.questionId))
|
||||
.map((a) => ({
|
||||
id: createId(),
|
||||
studentId,
|
||||
questionId: a.questionId,
|
||||
sourceType: "exam" as const,
|
||||
sourceId: submissionId,
|
||||
studentAnswer: a.answerContent,
|
||||
correctAnswer: null,
|
||||
subjectId: examSubjectId, // 从考试中获取学科
|
||||
knowledgePointIds: kpMap.get(a.questionId) ?? null,
|
||||
status: "new" as const,
|
||||
masteryLevel: 0,
|
||||
nextReviewAt: now,
|
||||
reviewInterval: 1,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
note: a.feedback ?? null,
|
||||
errorTags: null,
|
||||
}))
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await db.insert(errorBookItems).values(toInsert)
|
||||
}
|
||||
|
||||
return toInsert.length
|
||||
}
|
||||
|
||||
async function collectFromHomeworkSubmission(submissionId: string, studentId: string): Promise<number> {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: and(eq(homeworkSubmissions.id, submissionId), eq(homeworkSubmissions.studentId, studentId)),
|
||||
})
|
||||
if (!submission) return 0
|
||||
|
||||
// 查询作业以获取 subjectId。
|
||||
// homeworkAssignments 表本身没有 subjectId 字段,
|
||||
// 若作业派生自试卷(sourceExamId 不为空),则从源试卷的 subjectId 获取。
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, submission.assignmentId),
|
||||
})
|
||||
let hwSubjectId: string | null = null
|
||||
if (assignment?.sourceExamId) {
|
||||
const sourceExam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, assignment.sourceExamId),
|
||||
})
|
||||
hwSubjectId = sourceExam?.subjectId ?? null
|
||||
}
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: homeworkAnswers.questionId,
|
||||
answerContent: homeworkAnswers.answerContent,
|
||||
score: homeworkAnswers.score,
|
||||
feedback: homeworkAnswers.feedback,
|
||||
})
|
||||
.from(homeworkAnswers)
|
||||
.where(eq(homeworkAnswers.submissionId, submissionId))
|
||||
|
||||
const questionIds = answers.map((a) => a.questionId)
|
||||
const hwQuestionScores = await db
|
||||
.select({ questionId: homeworkAssignmentQuestions.questionId, maxScore: homeworkAssignmentQuestions.score })
|
||||
.from(homeworkAssignmentQuestions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId),
|
||||
inArray(homeworkAssignmentQuestions.questionId, questionIds)
|
||||
)
|
||||
)
|
||||
|
||||
const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
const wrongAnswers = answers.filter((a) => {
|
||||
const max = maxScoreMap.get(a.questionId) ?? 0
|
||||
return (a.score ?? 0) < max
|
||||
})
|
||||
|
||||
if (wrongAnswers.length === 0) return 0
|
||||
|
||||
const existing = await db
|
||||
.select({ questionId: errorBookItems.questionId })
|
||||
.from(errorBookItems)
|
||||
.where(
|
||||
and(
|
||||
eq(errorBookItems.studentId, studentId),
|
||||
inArray(errorBookItems.questionId, wrongAnswers.map((a) => a.questionId))
|
||||
)
|
||||
)
|
||||
const existingSet = new Set(existing.map((e) => e.questionId))
|
||||
|
||||
const kpRows = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.questionId, wrongAnswers.map((a) => a.questionId)))
|
||||
const kpMap = new Map<string, string[]>()
|
||||
for (const kp of kpRows) {
|
||||
const list = kpMap.get(kp.questionId) ?? []
|
||||
list.push(kp.knowledgePointId)
|
||||
kpMap.set(kp.questionId, list)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const toInsert = wrongAnswers
|
||||
.filter((a) => !existingSet.has(a.questionId))
|
||||
.map((a) => ({
|
||||
id: createId(),
|
||||
studentId,
|
||||
questionId: a.questionId,
|
||||
sourceType: "homework" as const,
|
||||
sourceId: submissionId,
|
||||
studentAnswer: a.answerContent,
|
||||
correctAnswer: null,
|
||||
subjectId: hwSubjectId, // 从作业中获取学科
|
||||
knowledgePointIds: kpMap.get(a.questionId) ?? null,
|
||||
status: "new" as const,
|
||||
masteryLevel: 0,
|
||||
nextReviewAt: now,
|
||||
reviewInterval: 1,
|
||||
reviewCount: 0,
|
||||
correctStreak: 0,
|
||||
note: a.feedback ?? null,
|
||||
errorTags: null,
|
||||
}))
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await db.insert(errorBookItems).values(toInsert)
|
||||
}
|
||||
|
||||
return toInsert.length
|
||||
}
|
||||
|
||||
async function recordReview(itemId: string, studentId: string, result: ReviewResult): Promise<void> {
|
||||
const item = await db.query.errorBookItems.findFirst({
|
||||
where: and(eq(errorBookItems.id, itemId), eq(errorBookItems.studentId, studentId)),
|
||||
})
|
||||
if (!item) throw new Error("错题不存在")
|
||||
|
||||
const newStreak = calculateNewCorrectStreak(item.correctStreak, result)
|
||||
const newInterval = calculateNewInterval(item.reviewInterval, result, item.reviewCount)
|
||||
const newMastery = calculateNewMastery(item.masteryLevel, result, newStreak)
|
||||
const newStatus = deriveStatus(newMastery, newStreak)
|
||||
const nextReviewAt = newStatus === "mastered" ? null : new Date(Date.now() + newInterval * 86400_000)
|
||||
|
||||
await db.insert(errorBookReviews).values({
|
||||
id: createId(),
|
||||
itemId,
|
||||
studentId,
|
||||
reviewResult: result,
|
||||
reviewedAt: new Date(),
|
||||
newInterval,
|
||||
newMasteryLevel: newMastery,
|
||||
})
|
||||
|
||||
await db
|
||||
.update(errorBookItems)
|
||||
.set({
|
||||
reviewInterval: newInterval,
|
||||
reviewCount: item.reviewCount + 1,
|
||||
correctStreak: newStreak,
|
||||
masteryLevel: newMastery,
|
||||
status: newStatus,
|
||||
nextReviewAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(errorBookItems.id, itemId))
|
||||
}
|
||||
|
||||
async function seedErrorBook() {
|
||||
console.log("🌱 开始生成错题本种子数据...")
|
||||
|
||||
// 1. 查询所有已批改的考试提交
|
||||
const gradedExamSubmissions = await db
|
||||
.select({
|
||||
id: examSubmissions.id,
|
||||
studentId: examSubmissions.studentId,
|
||||
examId: examSubmissions.examId,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.status, "graded"))
|
||||
|
||||
console.log(`📋 找到 ${gradedExamSubmissions.length} 份已批改考试提交`)
|
||||
|
||||
// 2. 从考试提交中自动采集错题
|
||||
let examCollected = 0
|
||||
for (const sub of gradedExamSubmissions) {
|
||||
try {
|
||||
const count = await collectFromExamSubmission(sub.id, sub.studentId)
|
||||
examCollected += count
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 考试提交 ${sub.id} 采集失败:`, (e as Error).message)
|
||||
}
|
||||
}
|
||||
console.log(`✓ 从考试提交中采集 ${examCollected} 条错题`)
|
||||
|
||||
// 3. 查询所有已批改的作业提交
|
||||
const gradedHomeworkSubmissions = await db
|
||||
.select({
|
||||
id: homeworkSubmissions.id,
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.status, "graded"))
|
||||
|
||||
console.log(`📋 找到 ${gradedHomeworkSubmissions.length} 份已批改作业提交`)
|
||||
|
||||
// 4. 从作业提交中自动采集错题
|
||||
let homeworkCollected = 0
|
||||
for (const sub of gradedHomeworkSubmissions) {
|
||||
try {
|
||||
const count = await collectFromHomeworkSubmission(sub.id, sub.studentId)
|
||||
homeworkCollected += count
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 作业提交 ${sub.id} 采集失败:`, (e as Error).message)
|
||||
}
|
||||
}
|
||||
console.log(`✓ 从作业提交中采集 ${homeworkCollected} 条错题`)
|
||||
|
||||
// 5. 模拟复习记录:为每个学生的前 3 条错题添加复习
|
||||
const allErrorItems = await db
|
||||
.select({
|
||||
id: errorBookItems.id,
|
||||
studentId: errorBookItems.studentId,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
.where(eq(errorBookItems.status, "new"))
|
||||
|
||||
const byStudent = new Map<string, string[]>()
|
||||
for (const item of allErrorItems) {
|
||||
const list = byStudent.get(item.studentId) ?? []
|
||||
list.push(item.id)
|
||||
byStudent.set(item.studentId, list)
|
||||
}
|
||||
|
||||
let reviewCount = 0
|
||||
const reviewResults: ReviewResult[] = ["again", "hard", "good"]
|
||||
for (const [studentId, itemIds] of byStudent) {
|
||||
// 为前 3 条错题添加复习记录
|
||||
for (let i = 0; i < Math.min(3, itemIds.length); i++) {
|
||||
const itemId = itemIds[i]
|
||||
const result = reviewResults[i % 3]
|
||||
try {
|
||||
await recordReview(itemId, studentId, result)
|
||||
reviewCount++
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 复习记录 ${itemId} 创建失败:`, (e as Error).message)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`✓ 创建 ${reviewCount} 条复习记录`)
|
||||
|
||||
// 6. 统计结果
|
||||
const [totalItems] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(errorBookItems)
|
||||
const [totalReviews] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(errorBookReviews)
|
||||
const [statusStats] = await db
|
||||
.select({
|
||||
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)`,
|
||||
})
|
||||
.from(errorBookItems)
|
||||
|
||||
console.log("\n📊 错题本数据统计:")
|
||||
console.log(` 总错题数: ${totalItems.count}`)
|
||||
console.log(` 总复习记录: ${totalReviews.count}`)
|
||||
console.log(` 状态分布: new=${statusStats.newCount}, learning=${statusStats.learningCount}, mastered=${statusStats.masteredCount}`)
|
||||
console.log("\n✅ 错题本种子数据生成完成")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
seedErrorBook().catch((e) => {
|
||||
console.error("❌ 错题本种子数据生成失败:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
151
scripts/test-failing-modules.py
Normal file
151
scripts/test-failing-modules.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
检查学情诊断、错题分析、公告、消息 4 个模块的实际渲染状态
|
||||
截取截图 + 捕获控制台错误 + 检查页面内容
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE_URL = "http://localhost:3000"
|
||||
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
||||
TEACHER_PASSWORD = "123456"
|
||||
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots")
|
||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||
|
||||
ROUTES = [
|
||||
("/teacher/diagnostic", "学情诊断"),
|
||||
("/teacher/error-book", "错题分析"),
|
||||
("/announcements", "公告"),
|
||||
("/messages", "消息"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
|
||||
# Login
|
||||
print(">>> 登录...")
|
||||
page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(3000)
|
||||
page.locator('input[name="email"]').fill(TEACHER_EMAIL)
|
||||
page.locator('input[type="password"]').first.fill(TEACHER_PASSWORD)
|
||||
page.evaluate("""() => {
|
||||
const form = document.querySelector('form');
|
||||
if (form) {
|
||||
const event = new Event('submit', { cancelable: true, bubbles: true });
|
||||
form.dispatchEvent(event);
|
||||
}
|
||||
}""")
|
||||
page.wait_for_timeout(5000)
|
||||
print(f"登录后 URL: {page.url}")
|
||||
|
||||
for route, name in ROUTES:
|
||||
print(f"\n{'='*60}")
|
||||
print(f">>> 测试: {name} ({route})")
|
||||
print(f"{'='*60}")
|
||||
|
||||
console_errors = []
|
||||
console_warnings = []
|
||||
|
||||
def on_console(msg):
|
||||
if msg.type == "error":
|
||||
console_errors.append(msg.text)
|
||||
elif msg.type == "warning":
|
||||
console_warnings.append(msg.text)
|
||||
|
||||
def on_page_error(err):
|
||||
console_errors.append(f"PageError: {str(err)[:300]}")
|
||||
|
||||
page.on("console", on_console)
|
||||
page.on("pageerror", on_page_error)
|
||||
|
||||
try:
|
||||
response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(3000) # Wait for client-side JS to execute
|
||||
|
||||
print(f" HTTP Status: {response.status if response else 'N/A'}")
|
||||
print(f" Final URL: {page.url}")
|
||||
|
||||
# Check for error indicators on page
|
||||
body_text = page.locator("body").text_content() or ""
|
||||
print(f" Body text length: {len(body_text)}")
|
||||
|
||||
# Check for common error patterns
|
||||
error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500, .text-red-600')
|
||||
error_count = error_elements.count()
|
||||
if error_count > 0:
|
||||
print(f" Error elements on page: {error_count}")
|
||||
for i in range(min(error_count, 5)):
|
||||
try:
|
||||
text = error_elements.nth(i).text_content()
|
||||
if text and text.strip():
|
||||
print(f" [{i}] {text.strip()[:200]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check for loading spinners still visible
|
||||
spinners = page.locator('.animate-spin, [role="status"], .loading')
|
||||
spinner_count = spinners.count()
|
||||
if spinner_count > 0:
|
||||
print(f" Loading spinners still visible: {spinner_count}")
|
||||
|
||||
# Check for empty state
|
||||
if "暂无" in body_text or "No data" in body_text or "没有" in body_text:
|
||||
# Find the context
|
||||
for keyword in ["暂无", "No data", "没有"]:
|
||||
idx = body_text.find(keyword)
|
||||
if idx >= 0:
|
||||
context_text = body_text[max(0, idx-30):idx+80]
|
||||
print(f" Empty state: ...{context_text}...")
|
||||
break
|
||||
|
||||
# Check for "加载失败" or "error" text
|
||||
for keyword in ["加载失败", "load", "error", "错误", "失败", "Error"]:
|
||||
if keyword.lower() in body_text.lower():
|
||||
idx = body_text.lower().find(keyword.lower())
|
||||
context_text = body_text[max(0, idx-30):idx+100]
|
||||
print(f" Found '{keyword}': ...{context_text}...")
|
||||
break
|
||||
|
||||
# Take screenshot
|
||||
screenshot_name = route.replace("/", "_").strip("_") + ".png"
|
||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
||||
page.screenshot(path=screenshot_path, full_page=True)
|
||||
print(f" Screenshot: {screenshot_path}")
|
||||
|
||||
# Print console errors
|
||||
if console_errors:
|
||||
print(f"\n Console Errors ({len(console_errors)}):")
|
||||
for err in console_errors[:10]:
|
||||
print(f" - {err[:300]}")
|
||||
else:
|
||||
print(f" Console Errors: 0")
|
||||
|
||||
if console_warnings:
|
||||
print(f"\n Console Warnings ({len(console_warnings)}):")
|
||||
for w in console_warnings[:5]:
|
||||
print(f" - {w[:200]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" EXCEPTION: {type(e).__name__}: {str(e)[:300]}")
|
||||
screenshot_name = route.replace("/", "_").strip("_") + "_error.png"
|
||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
||||
try:
|
||||
page.screenshot(path=screenshot_path, full_page=True)
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
page.remove_listener("console", on_console)
|
||||
page.remove_listener("pageerror", on_page_error)
|
||||
|
||||
browser.close()
|
||||
print(f"\n{'='*60}")
|
||||
print("测试完成!")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
395
scripts/test-teacher-pages.py
Normal file
395
scripts/test-teacher-pages.py
Normal file
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
教师端全功能 Web 测试 (Post-Audit)
|
||||
测试范围: 所有教师端页面路由 + 详情页发现 + 控制台错误捕获
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE_URL = "http://localhost:3000"
|
||||
TEACHER_EMAIL = "t_chinese_1@xiaoxue.edu.cn"
|
||||
TEACHER_PASSWORD = "123456"
|
||||
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "bugs", "screenshots")
|
||||
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
|
||||
|
||||
# 教师端所有路由
|
||||
TEACHER_ROUTES = [
|
||||
{"category": "Dashboard", "routes": ["/teacher/dashboard"]},
|
||||
{"category": "Textbooks", "routes": ["/teacher/textbooks"]},
|
||||
{"category": "Questions", "routes": ["/teacher/questions"]},
|
||||
{"category": "Exams", "routes": ["/teacher/exams", "/teacher/exams/all", "/teacher/exams/create"]},
|
||||
{"category": "Homework", "routes": ["/teacher/homework", "/teacher/homework/assignments", "/teacher/homework/assignments/create", "/teacher/homework/submissions"]},
|
||||
{"category": "Grades", "routes": ["/teacher/grades", "/teacher/grades/entry", "/teacher/grades/stats", "/teacher/grades/analytics"]},
|
||||
{"category": "Classes", "routes": ["/teacher/classes", "/teacher/classes/my", "/teacher/classes/students", "/teacher/classes/schedule"]},
|
||||
{"category": "Course Plans", "routes": ["/teacher/course-plans"]},
|
||||
{"category": "Lesson Plans", "routes": ["/teacher/lesson-plans", "/teacher/lesson-plans/new"]},
|
||||
{"category": "Attendance", "routes": ["/teacher/attendance", "/teacher/attendance/sheet", "/teacher/attendance/stats"]},
|
||||
{"category": "Schedule Changes", "routes": ["/teacher/schedule-changes"]},
|
||||
{"category": "Diagnostic", "routes": ["/teacher/diagnostic"]},
|
||||
{"category": "Elective", "routes": ["/teacher/elective"]},
|
||||
{"category": "Error Book", "routes": ["/teacher/error-book"]},
|
||||
]
|
||||
|
||||
# 详情页发现模式
|
||||
DETAIL_PATTERNS = [
|
||||
{"category": "Textbooks Detail", "listRoute": "/teacher/textbooks", "linkPattern": "/teacher/textbooks/"},
|
||||
{"category": "Classes Detail", "listRoute": "/teacher/classes/my", "linkPattern": "/teacher/classes/my/"},
|
||||
{"category": "Course Plans Detail", "listRoute": "/teacher/course-plans", "linkPattern": "/teacher/course-plans/"},
|
||||
{"category": "Lesson Plans Detail", "listRoute": "/teacher/lesson-plans", "linkPattern": "/teacher/lesson-plans/"},
|
||||
{"category": "Homework Detail", "listRoute": "/teacher/homework/assignments", "linkPattern": "/teacher/homework/assignments/"},
|
||||
{"category": "Exams Detail", "listRoute": "/teacher/exams/all", "linkPattern": "/teacher/exams/"},
|
||||
]
|
||||
|
||||
|
||||
class TestResult:
|
||||
def __init__(self, url, category):
|
||||
self.url = url
|
||||
self.category = category
|
||||
self.status = "unknown"
|
||||
self.http_status = None
|
||||
self.final_url = url
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
self.screenshot = None
|
||||
|
||||
|
||||
all_results = []
|
||||
|
||||
|
||||
def add_result(result):
|
||||
all_results.append(result)
|
||||
|
||||
|
||||
def test_single_page(page, route, category):
|
||||
result = TestResult(route, category)
|
||||
console_errors = []
|
||||
|
||||
def on_console(msg):
|
||||
if msg.type == "error":
|
||||
console_errors.append(msg.text)
|
||||
|
||||
page.on("console", on_console)
|
||||
|
||||
# Also capture page errors (uncaught exceptions)
|
||||
def on_page_error(err):
|
||||
console_errors.append(f"PageError: {str(err)[:200]}")
|
||||
|
||||
page.on("pageerror", on_page_error)
|
||||
|
||||
try:
|
||||
response = page.goto(f"{BASE_URL}{route}", timeout=30000, wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(2000) # Wait for JS to execute and render
|
||||
|
||||
result.http_status = response.status if response else None
|
||||
result.final_url = page.url
|
||||
|
||||
http_status = result.http_status
|
||||
final_url = result.final_url
|
||||
|
||||
if http_status and http_status >= 500:
|
||||
result.status = "failed"
|
||||
result.errors.append(f"HTTP {http_status} error")
|
||||
elif http_status and http_status >= 400:
|
||||
result.status = "warning"
|
||||
result.warnings.append(f"HTTP {http_status} error")
|
||||
elif "/login" in final_url:
|
||||
result.status = "failed"
|
||||
result.errors.append("Redirected to login - auth issue")
|
||||
elif final_url.rstrip("/").endswith("/error") or "/500" in final_url:
|
||||
result.status = "failed"
|
||||
result.errors.append("Redirected to error page")
|
||||
else:
|
||||
result.status = "passed"
|
||||
|
||||
# Check for error elements on page
|
||||
error_elements = page.locator('[role="alert"], .text-destructive, .text-red-500')
|
||||
error_count = error_elements.count()
|
||||
if error_count > 0:
|
||||
for i in range(min(error_count, 3)):
|
||||
try:
|
||||
text = error_elements.nth(i).text_content()
|
||||
if text and text.strip():
|
||||
result.warnings.append(f"Error text: {text.strip()[:100]}")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if page is empty
|
||||
body_text = page.locator("body").text_content() or ""
|
||||
if len(body_text.strip()) < 50:
|
||||
result.warnings.append("Page appears empty")
|
||||
|
||||
if console_errors:
|
||||
result.errors.extend(console_errors[:5])
|
||||
|
||||
# Take screenshot for failed/warning pages
|
||||
if result.status in ("failed", "warning"):
|
||||
screenshot_name = route.replace("/", "_").strip("_") + ".png"
|
||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
||||
try:
|
||||
page.screenshot(path=screenshot_path, full_page=True)
|
||||
result.screenshot = screenshot_path
|
||||
except:
|
||||
pass
|
||||
|
||||
icon = "PASS" if result.status == "passed" else ("WARN" if result.status == "warning" else "FAIL")
|
||||
print(f" [{icon}] {result.status} (HTTP {result.http_status}) - {route}")
|
||||
|
||||
except Exception as e:
|
||||
result.status = "failed"
|
||||
err_msg = str(e)[:200]
|
||||
result.errors.append(f"Exception: {err_msg}")
|
||||
print(f" [FAIL] ERROR: {err_msg[:100]} - {route}")
|
||||
|
||||
# Try screenshot even on failure
|
||||
try:
|
||||
screenshot_name = route.replace("/", "_").strip("_") + "_error.png"
|
||||
screenshot_path = os.path.join(SCREENSHOT_DIR, screenshot_name)
|
||||
page.screenshot(path=screenshot_path, full_page=True)
|
||||
result.screenshot = screenshot_path
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
page.remove_listener("console", on_console)
|
||||
page.remove_listener("pageerror", on_page_error)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def discover_detail_links(page, list_route, link_pattern):
|
||||
urls = []
|
||||
try:
|
||||
page.goto(f"{BASE_URL}{list_route}", timeout=25000, wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
links = page.locator(f'a[href*="{link_pattern}"]')
|
||||
count = links.count()
|
||||
seen = set()
|
||||
for i in range(min(count, 10)):
|
||||
href = links.nth(i).get_attribute("href")
|
||||
if href and link_pattern in href and href not in seen:
|
||||
# Avoid the list page itself
|
||||
if href != list_route:
|
||||
seen.add(href)
|
||||
urls.append(href)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to discover links on {list_route}: {e}")
|
||||
|
||||
return urls[:3] # Max 3 detail pages per category
|
||||
|
||||
|
||||
def generate_report():
|
||||
lines = []
|
||||
passed = sum(1 for r in all_results if r.status == "passed")
|
||||
failed = sum(1 for r in all_results if r.status == "failed")
|
||||
warnings = sum(1 for r in all_results if r.status == "warning")
|
||||
total = len(all_results)
|
||||
|
||||
lines.append("# 教师端 Web 功能测试报告 (Post-Audit)")
|
||||
lines.append("")
|
||||
lines.append(f"> 测试日期: {time.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
lines.append("> 测试范围: 所有教师端页面功能 (审计修复后)")
|
||||
lines.append("> 测试工具: Playwright + Chromium (Python)")
|
||||
lines.append(f"> 测试账号: {TEACHER_EMAIL}")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 一、测试概览")
|
||||
lines.append("")
|
||||
lines.append("| 指标 | 数值 |")
|
||||
lines.append("|------|------|")
|
||||
lines.append(f"| 总测试页面数 | {total} |")
|
||||
lines.append(f"| PASS | {passed} |")
|
||||
lines.append(f"| FAIL | {failed} |")
|
||||
lines.append(f"| WARN | {warnings} |")
|
||||
pass_rate = f"{(passed / total * 100):.1f}%" if total > 0 else "N/A"
|
||||
lines.append(f"| 通过率 | {pass_rate} |")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 二、页面测试详情")
|
||||
lines.append("")
|
||||
|
||||
# Group by category
|
||||
by_category = {}
|
||||
for r in all_results:
|
||||
if r.category not in by_category:
|
||||
by_category[r.category] = []
|
||||
by_category[r.category].append(r)
|
||||
|
||||
for category, results in by_category.items():
|
||||
lines.append(f"### {category}")
|
||||
lines.append("")
|
||||
lines.append("| 页面 | HTTP状态 | 结果 | 备注 |")
|
||||
lines.append("|------|----------|------|------|")
|
||||
|
||||
for r in results:
|
||||
icon = "PASS" if r.status == "passed" else ("WARN" if r.status == "warning" else "FAIL")
|
||||
notes = []
|
||||
if r.final_url != f"{BASE_URL}{r.url}" and r.final_url != r.url:
|
||||
notes.append(f"重定向: {r.final_url}")
|
||||
if r.errors:
|
||||
notes.append(f"错误: {'; '.join(r.errors[:2])}")
|
||||
if r.warnings:
|
||||
notes.append(f"警告: {'; '.join(r.warnings[:2])}")
|
||||
note_str = "<br>".join(notes) if notes else "-"
|
||||
|
||||
lines.append(f"| {icon} `{r.url}` | {r.http_status or '-'} | {r.status} | {note_str} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Failed details
|
||||
failed_results = [r for r in all_results if r.status == "failed"]
|
||||
if failed_results:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 三、失败页面详情")
|
||||
lines.append("")
|
||||
for r in failed_results:
|
||||
lines.append(f"### FAIL `{r.url}`")
|
||||
lines.append("")
|
||||
lines.append(f"- **分类**: {r.category}")
|
||||
lines.append(f"- **HTTP状态**: {r.http_status or '-'}")
|
||||
if r.final_url != r.url:
|
||||
lines.append(f"- **重定向**: {r.final_url}")
|
||||
if r.errors:
|
||||
lines.append("- **错误信息**:")
|
||||
for e in r.errors:
|
||||
lines.append(f" - {e}")
|
||||
if r.screenshot:
|
||||
lines.append(f"- **截图**: {r.screenshot}")
|
||||
lines.append("")
|
||||
|
||||
# Warning details
|
||||
warning_results = [r for r in all_results if r.status == "warning"]
|
||||
if warning_results:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## 四、警告页面")
|
||||
lines.append("")
|
||||
for r in warning_results:
|
||||
lines.append(f"### WARN `{r.url}`")
|
||||
lines.append("")
|
||||
lines.append(f"- **分类**: {r.category}")
|
||||
if r.warnings:
|
||||
for w in r.warnings:
|
||||
lines.append(f" - {w}")
|
||||
if r.screenshot:
|
||||
lines.append(f"- **截图**: {r.screenshot}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"*报告自动生成于 {time.strftime('%Y-%m-%d %H:%M:%S')}*")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
context = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = context.new_page()
|
||||
|
||||
# ====== Step 1: Login ======
|
||||
print("\n>>> 登录教师账号...")
|
||||
page.goto(f"{BASE_URL}/login", wait_until="domcontentloaded")
|
||||
page.wait_for_timeout(3000) # Wait for form to fully render
|
||||
|
||||
# Fill login form
|
||||
email_input = page.locator('input[name="email"]')
|
||||
if email_input.count() == 0:
|
||||
email_input = page.locator('input[type="email"]').first
|
||||
|
||||
email_input.fill(TEACHER_EMAIL)
|
||||
page.locator('input[type="password"], input[name="password"]').first.fill(TEACHER_PASSWORD)
|
||||
|
||||
# Submit form via JavaScript (avoids Next.js dev overlay intercepting clicks)
|
||||
page.evaluate("""() => {
|
||||
const form = document.querySelector('form');
|
||||
if (form) {
|
||||
const event = new Event('submit', { cancelable: true, bubbles: true });
|
||||
form.dispatchEvent(event);
|
||||
}
|
||||
}""")
|
||||
|
||||
page.wait_for_timeout(5000) # Wait for redirect after login
|
||||
|
||||
current_url = page.url
|
||||
print(f"登录后 URL: {current_url}")
|
||||
|
||||
if "/login" in current_url:
|
||||
print("FAIL: 登录失败,仍在登录页!")
|
||||
# Take screenshot of login failure
|
||||
page.screenshot(path=os.path.join(SCREENSHOT_DIR, "login_failure.png"), full_page=True)
|
||||
browser.close()
|
||||
return
|
||||
|
||||
print("PASS: 登录成功!")
|
||||
|
||||
# ====== Step 2: Test all routes ======
|
||||
print("\n>>> 测试所有教师端路由...")
|
||||
for group in TEACHER_ROUTES:
|
||||
category = group["category"]
|
||||
routes = group["routes"]
|
||||
print(f"\n === {category} ===")
|
||||
for route in routes:
|
||||
print(f" 测试: {route}")
|
||||
result = test_single_page(page, route, category)
|
||||
add_result(result)
|
||||
|
||||
# ====== Step 3: Discover and test detail pages ======
|
||||
print("\n\n>>> 发现详情页链接...")
|
||||
for pattern in DETAIL_PATTERNS:
|
||||
category = pattern["category"]
|
||||
list_route = pattern["listRoute"]
|
||||
link_pattern = pattern["linkPattern"]
|
||||
print(f"\n 发现: {category} (from {list_route})")
|
||||
detail_urls = discover_detail_links(page, list_route, link_pattern)
|
||||
if detail_urls:
|
||||
print(f" 找到 {len(detail_urls)} 个详情页")
|
||||
for detail_url in detail_urls:
|
||||
result = test_single_page(page, detail_url, category)
|
||||
add_result(result)
|
||||
else:
|
||||
print(f" 未发现详情页链接")
|
||||
|
||||
# ====== Step 4: Generate report ======
|
||||
report = generate_report()
|
||||
bugs_dir = os.path.join(os.path.dirname(__file__), "..", "bugs")
|
||||
os.makedirs(bugs_dir, exist_ok=True)
|
||||
output_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.md")
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
|
||||
# Also save JSON results
|
||||
json_path = os.path.join(bugs_dir, "teacher_web_test_post_audit.json")
|
||||
json_data = []
|
||||
for r in all_results:
|
||||
json_data.append({
|
||||
"url": r.url,
|
||||
"category": r.category,
|
||||
"status": r.status,
|
||||
"http_status": r.http_status,
|
||||
"final_url": r.final_url,
|
||||
"errors": r.errors,
|
||||
"warnings": r.warnings,
|
||||
})
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
passed = sum(1 for r in all_results if r.status == "passed")
|
||||
failed = sum(1 for r in all_results if r.status == "failed")
|
||||
warnings = sum(1 for r in all_results if r.status == "warning")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"测试完成: 总计 {len(all_results)}, PASS {passed}, FAIL {failed}, WARN {warnings}")
|
||||
print(f"报告已写入: {output_path}")
|
||||
print(f"JSON 结果: {json_path}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
browser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
53
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
import { AiUsageDashboard } from "@/modules/ai/components/ai-usage-dashboard"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("ai")
|
||||
return {
|
||||
title: `${t("admin.settings.title")} - Next_Edu`,
|
||||
description: t("admin.settings.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* AI 统一配置页
|
||||
*
|
||||
* 作为 AI 模块的独立配置入口,取代:
|
||||
* - /settings?tab=ai(已移除 AI 标签页)
|
||||
* - 考试页面内嵌的 AI 配置弹窗(已移除)
|
||||
*
|
||||
* 权限规则:
|
||||
* - AI_CHAT 用户均可访问(管理自己的 private provider)
|
||||
* - AI_CONFIGURE 用户(管理员)可额外管理 public provider 与他人 private provider
|
||||
*/
|
||||
export default async function AiSettingsPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("ai")
|
||||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||||
const isAdmin = ctx.permissions.includes(Permissions.AI_CONFIGURE)
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t("admin.settings.title")}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isAdmin
|
||||
? t("admin.settings.description")
|
||||
: t("admin.settings.userDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<AiProviderSettingsCard isAdmin={isAdmin} currentUserId={ctx.userId} />
|
||||
{isAdmin ? <AiUsageDashboard /> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -13,9 +14,12 @@ import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-t
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { DataChangeAction } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数据变更日志 - Next_Edu",
|
||||
description: "追踪系统所有数据变更(增删改),保障合规",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("audit")
|
||||
return {
|
||||
title: `${t("dataChanges.title")} - Next_Edu`,
|
||||
description: t("dataChanges.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -28,6 +32,7 @@ export default async function DataChangeLogsPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("audit")
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
@@ -54,10 +59,8 @@ export default async function DataChangeLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">数据变更日志</h2>
|
||||
<p className="text-muted-foreground">
|
||||
追踪系统所有数据变更(增删改),保障合规。
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("dataChanges.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("dataChanges.description")}</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="dataChange" params={exportParams} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -9,9 +10,12 @@ import { LoginLogView } from "@/modules/audit/components/login-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录日志 - Next_Edu",
|
||||
description: "监控所有认证事件,包括登录、登出与注册",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("audit")
|
||||
return {
|
||||
title: `${t("loginLogs.title")} - Next_Edu`,
|
||||
description: t("loginLogs.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -27,6 +31,7 @@ export default async function LoginLogsPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("audit")
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
@@ -50,9 +55,9 @@ export default async function LoginLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">登录日志</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("loginLogs.title")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
监控所有认证事件,包括登录、登出与注册。
|
||||
{t("loginLogs.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="login" params={exportParams} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -9,9 +10,12 @@ import { AuditLogView } from "@/modules/audit/components/audit-log-view"
|
||||
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
|
||||
import type { AuditLogStatus } from "@/modules/audit/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "审计日志 - Next_Edu",
|
||||
description: "追踪系统内所有用户操作,保障安全与合规",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("audit")
|
||||
return {
|
||||
title: `${t("title")} - Next_Edu`,
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -24,6 +28,7 @@ export default async function AuditLogsPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("audit")
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
|
||||
const params = await searchParams
|
||||
@@ -51,10 +56,8 @@ export default async function AuditLogsPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<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("description")}</p>
|
||||
</div>
|
||||
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "编辑课程计划 - Next_Edu",
|
||||
description: "更新课程计划详情",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
return {
|
||||
title: `${t("edit.title")} - Next_Edu`,
|
||||
description: t("edit.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -19,6 +23,7 @@ export default async function EditCoursePlanPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
const { id } = await params
|
||||
|
||||
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
@@ -34,8 +39,8 @@ export default async function EditCoursePlanPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<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("edit.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("edit.description")}</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="edit"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "课程计划详情 - Next_Edu",
|
||||
description: "查看课程计划详情",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
return {
|
||||
title: `${t("detail.title")} - Next_Edu`,
|
||||
description: t("detail.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
||||
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "新建课程计划 - Next_Edu",
|
||||
description: "创建新的课程教学计划",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
return {
|
||||
title: `${t("create.title")} - Next_Edu`,
|
||||
description: t("create.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
const [classes, subjects, teachers, academicYears] = await Promise.all([
|
||||
getAdminClasses(),
|
||||
getSubjectOptions(),
|
||||
@@ -24,8 +29,8 @@ export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<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("create.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("create.description")}</p>
|
||||
</div>
|
||||
<CoursePlanForm
|
||||
mode="create"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import type { CoursePlanStatus } from "@/modules/course-plans/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "课程计划 - Next_Edu",
|
||||
description: "管理课程教学计划与周课时安排",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
return {
|
||||
title: `${t("title")} - Next_Edu`,
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -21,6 +25,7 @@ export default async function AdminCoursePlansPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("coursePlans")
|
||||
const sp = await searchParams
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||
@@ -30,10 +35,8 @@ export default async function AdminCoursePlansPage({
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<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("description")}</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
|
||||
@@ -1,57 +1,84 @@
|
||||
import type { JSX } from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Suspense } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
import {
|
||||
getStudentErrorBookSummaries,
|
||||
getTopWrongQuestionsByStudentIds,
|
||||
getKnowledgePointWeakness,
|
||||
getSubjectErrorDistribution,
|
||||
getStudentNameMap,
|
||||
getSubjectErrorOverviews,
|
||||
getSubjectErrorDistribution,
|
||||
getChapterWeakness,
|
||||
getAllStudentIds,
|
||||
} from "@/modules/error-book/data-access"
|
||||
import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview"
|
||||
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
|
||||
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
|
||||
import { SubjectDistributionChart } from "@/modules/error-book/components/subject-distribution-chart"
|
||||
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
|
||||
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
|
||||
import { GroupedStudentErrorTable } from "@/modules/error-book/components/grouped-student-error-table"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminErrorBookPage(): Promise<JSX.Element> {
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("errorBook")
|
||||
return {
|
||||
title: `${t("admin.title")} - Next_Edu`,
|
||||
description: t("admin.description"),
|
||||
}
|
||||
}
|
||||
|
||||
async function AdminErrorBookContent({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("errorBook")
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)
|
||||
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||
<p className="text-muted-foreground">查看全校学生的错题统计与薄弱知识点。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("admin.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("admin.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="权限不足"
|
||||
description="您没有权限查看全校错题分析数据。"
|
||||
title={t("admin.noPermissionTitle")}
|
||||
description={t("admin.noPermissionDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 通过 data-access 层查询所有学生 ID(遵循三层架构,app 层不直接访问 DB)
|
||||
const studentIds = await getAllStudentIds()
|
||||
const params = await searchParams
|
||||
|
||||
if (studentIds.length === 0) {
|
||||
// 通过 data-access 层查询所有学生 ID(遵循三层架构)
|
||||
const allStudentIds = await getAllStudentIds()
|
||||
|
||||
if (allStudentIds.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||
<p className="text-muted-foreground">查看全校学生的错题统计与薄弱知识点。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("admin.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("admin.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无学生数据"
|
||||
description="系统中还没有学生用户,无法查看错题分析。"
|
||||
title={t("admin.noStudentsTitle")}
|
||||
description={t("admin.noStudentsDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -59,21 +86,33 @@ export default async function AdminErrorBookPage(): Promise<JSX.Element> {
|
||||
}
|
||||
|
||||
// 限制查询数量,避免性能问题(取最近活跃的 500 名学生)
|
||||
const limitedStudentIds = studentIds.slice(0, 500)
|
||||
const limitedStudentIds = allStudentIds.slice(0, 500)
|
||||
|
||||
const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([
|
||||
getStudentErrorBookSummaries(limitedStudentIds),
|
||||
getTopWrongQuestionsByStudentIds(limitedStudentIds, 10),
|
||||
getKnowledgePointWeakness(limitedStudentIds, 10),
|
||||
// 解析 URL 参数:学科筛选
|
||||
const subjectParam = getParam(params, "subject")
|
||||
|
||||
// 学科概览(用于 Tab 显示,不受学科筛选影响)
|
||||
const [subjectOverviews, subjectDist] = await Promise.all([
|
||||
getSubjectErrorOverviews(limitedStudentIds),
|
||||
getSubjectErrorDistribution(limitedStudentIds),
|
||||
])
|
||||
|
||||
// 并行查询所有统计数据(按学科过滤)
|
||||
const [summaries, topWrongQuestions, weakKps, chapterWeakness, nameMap] = await Promise.all([
|
||||
getStudentErrorBookSummaries(limitedStudentIds, subjectParam),
|
||||
getTopWrongQuestionsByStudentIds(limitedStudentIds, 10, subjectParam),
|
||||
getKnowledgePointWeakness(limitedStudentIds, 10, subjectParam),
|
||||
getChapterWeakness(limitedStudentIds, 10, subjectParam),
|
||||
getStudentNameMap(limitedStudentIds),
|
||||
])
|
||||
|
||||
const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0)
|
||||
const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0)
|
||||
const totalDueReview = summaries.reduce((sum, s) => sum + s.dueReviewCount, 0)
|
||||
const averageMasteryRate = studentsWithErrorBook.length > 0
|
||||
? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length
|
||||
: 0
|
||||
const knowledgePointCount = weakKps.length
|
||||
|
||||
const sortedSummaries = [...summaries]
|
||||
.filter((s) => s.totalCount > 0)
|
||||
@@ -81,33 +120,114 @@ export default async function AdminErrorBookPage(): Promise<JSX.Element> {
|
||||
.slice(0, 50)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">全校错题分析</h1>
|
||||
<p className="text-muted-foreground">
|
||||
全校错题统计与薄弱知识点分析,辅助教学决策。
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("admin.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("admin.description")}</p>
|
||||
</div>
|
||||
|
||||
<ClassErrorBookOverview
|
||||
totalStudents={studentIds.length}
|
||||
{/* 学科 Tab */}
|
||||
{subjectOverviews.length > 0 ? (
|
||||
<Suspense fallback={<Skeleton className="h-10 w-full" />}>
|
||||
<SubjectTabs
|
||||
subjects={subjectOverviews}
|
||||
currentSubjectId={subjectParam ?? null}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<AnalyticsStatsCards
|
||||
totalStudents={limitedStudentIds.length}
|
||||
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||
totalErrorItems={totalErrorItems}
|
||||
averageMasteryRate={averageMasteryRate}
|
||||
topWeakKnowledgePoints={weakKps}
|
||||
subjectDistribution={subjectDist}
|
||||
dueReviewCount={totalDueReview}
|
||||
knowledgePointCount={knowledgePointCount}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">错题最多的学生 Top 50</h2>
|
||||
<StudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/admin/error-book"
|
||||
/>
|
||||
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
|
||||
{!subjectParam && subjectDist.length > 0 ? (
|
||||
<SubjectDistributionChart data={subjectDist} />
|
||||
) : null}
|
||||
|
||||
{/* 章节错题分布 + 知识点薄弱度(并排) */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{chapterWeakness.length > 0 ? (
|
||||
<ChapterWeaknessChart data={chapterWeakness} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("admin.noChapterDataTitle")}
|
||||
description={t("admin.noChapterDataDescription")}
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
{weakKps.length > 0 ? (
|
||||
<KnowledgePointWeaknessChart data={weakKps} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("admin.noKnowledgePointDataTitle")}
|
||||
description={t("admin.noKnowledgePointDataDescription")}
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
{/* 错题最多的学生 Top 50 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("admin.topStudents")}</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("admin.studentsWithErrors", { count: studentsWithErrorBook.length })}
|
||||
</span>
|
||||
</div>
|
||||
{sortedSummaries.length > 0 ? (
|
||||
<GroupedStudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/admin/error-book"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("admin.noStudentErrorsTitle")}
|
||||
description={t("admin.noStudentErrorsDescription")}
|
||||
className="h-[200px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高频错题 Top 10 */}
|
||||
{topWrongQuestions.length > 0 ? (
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function AdminErrorBookPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px]" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminErrorBookContent searchParams={searchParams} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -9,9 +10,12 @@ import {
|
||||
} from "@/modules/files/data-access"
|
||||
import { AdminFilesView } from "@/modules/files/components/admin-files-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "文件管理 - Next_Edu",
|
||||
description: "查看与管理系统中所有上传文件",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("files")
|
||||
return {
|
||||
title: `${t("title")} - Next_Edu`,
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): Promise<React.ReactNode> {
|
||||
await getAuthContext()
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminLessonPlanViewError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AdminLessonPlanViewLoading() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminLessonPlanViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ planId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const plan = await getLessonPlanById(planId, ctx.userId)
|
||||
if (!plan) notFound()
|
||||
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanReadonlyView
|
||||
doc={plan.content}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/admin/lesson-plans/error.tsx
Normal file
20
src/app/(dashboard)/admin/lesson-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminLessonPlansError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/admin/lesson-plans/loading.tsx
Normal file
22
src/app/(dashboard)/admin/lesson-plans/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function AdminLessonPlansLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
src/app/(dashboard)/admin/lesson-plans/page.tsx
Normal file
88
src/app/(dashboard)/admin/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans, getLessonPlanStats } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// 通过 data-access 层查询,避免 app 层直接访问数据库(P0-1 修复)
|
||||
const [items, subjects, stats] = await Promise.all([
|
||||
getLessonPlans({}, ctx.dataScope, ctx.userId),
|
||||
getSubjectOptions(),
|
||||
getLessonPlanStats(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("admin.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("admin.description")}</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-on-surface-variant">
|
||||
{t("admin.stats.total")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.total}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-on-surface-variant">
|
||||
{t("admin.stats.published")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-primary">{stats.published}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-on-surface-variant">
|
||||
{t("admin.stats.draft")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-on-surface-variant">{stats.draft}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-on-surface-variant">
|
||||
{t("admin.stats.archived")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-on-surface-variant">{stats.archived}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} viewMode="admin" />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -10,14 +11,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access"
|
||||
import { AutoSchedulePanel } from "@/modules/scheduling/components/auto-schedule-panel"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "自动排课 - Next_Edu",
|
||||
description: "基于规则与学科分配自动生成周课表",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("scheduling")
|
||||
return {
|
||||
title: `${t("auto.title")} - Next_Edu`,
|
||||
description: t("auto.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("scheduling")
|
||||
await requirePermission(Permissions.SCHEDULE_AUTO)
|
||||
const classes = await getAdminClassesForScheduling()
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
@@ -26,15 +31,13 @@ export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">自动排课</h2>
|
||||
<p className="text-muted-foreground">
|
||||
基于规则与学科分配自动生成周课表。
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("auto.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("auto.description")}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/scheduling/rules">
|
||||
<Settings2 className="mr-2 h-4 w-4" />
|
||||
配置规则
|
||||
{t("auto.configureRules")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -42,8 +45,8 @@ export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行自动排课。"
|
||||
title={t("auto.noClassesTitle")}
|
||||
description={t("auto.noClassesDescription")}
|
||||
/>
|
||||
) : (
|
||||
<AutoSchedulePanel classes={classOptions} />
|
||||
@@ -51,7 +54,7 @@ export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarClock className="h-4 w-4" />
|
||||
<span>应用新课表将替换所选班级的现有课表。</span>
|
||||
<span>{t("auto.hint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Link from "next/link"
|
||||
import Link from "next/link"
|
||||
import { PlusCircle, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -18,9 +19,12 @@ import { ScheduleConflictsView } from "@/modules/scheduling/components/schedule-
|
||||
import { ScheduleGridView } from "@/modules/scheduling/components/schedule-grid-view"
|
||||
import type { ScheduleChangeStatus } from "@/modules/scheduling/types"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "课表变更申请 - Next_Edu",
|
||||
description: "审核、批准或拒绝课表变更与代课申请",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("scheduling")
|
||||
return {
|
||||
title: `${t("changes.title")} - Next_Edu`,
|
||||
description: t("changes.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -33,6 +37,7 @@ export default async function AdminSchedulingChangesPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("scheduling")
|
||||
await requirePermission(Permissions.SCHEDULE_ADJUST)
|
||||
const sp = await searchParams
|
||||
const statusParam = getSearchParam(sp, "status")
|
||||
@@ -51,15 +56,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">课表变更申请</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("changes.title")}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
审核、批准或拒绝课表变更与代课申请。
|
||||
{t("changes.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/teacher/schedule-changes">
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
新建申请
|
||||
{t("changes.newRequest")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -67,10 +72,10 @@ export default async function AdminSchedulingChangesPage({
|
||||
{items.length === 0 && !status && !classId ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="暂无课表变更申请"
|
||||
description="系统中尚未产生任何课表变更申请。"
|
||||
title={t("changes.noChangesTitle")}
|
||||
description={t("changes.noChangesDescription")}
|
||||
action={{
|
||||
label: "新建申请",
|
||||
label: t("changes.newRequest"),
|
||||
href: "/teacher/schedule-changes",
|
||||
}}
|
||||
/>
|
||||
@@ -79,15 +84,15 @@ export default async function AdminSchedulingChangesPage({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">冲突检测</h3>
|
||||
<h3 className="text-lg font-semibold">{t("changes.conflictDetection")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
检测现有班级课表中的时间重叠。
|
||||
{t("changes.conflictDetectionDescription")}
|
||||
</p>
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再进行冲突检测。"
|
||||
title={t("changes.noClassesTitle")}
|
||||
description={t("changes.noClassesDescription")}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleConflictsView classes={classOptions} />
|
||||
@@ -95,9 +100,9 @@ export default async function AdminSchedulingChangesPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">课表网格</h3>
|
||||
<h3 className="text-lg font-semibold">{t("changes.scheduleGrid")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
按班级查看当前课表分布。
|
||||
{t("changes.scheduleGridDescription")}
|
||||
</p>
|
||||
<ScheduleGridView entries={scheduleEntries} classes={classOptions} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import { CalendarCog, ClipboardList } from "lucide-react"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -11,14 +12,18 @@ import {
|
||||
} from "@/modules/scheduling/data-access"
|
||||
import { SchedulingRulesForm } from "@/modules/scheduling/components/scheduling-rules-form"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "排课规则 - Next_Edu",
|
||||
description: "配置每日课时上限、课间窗口与均衡偏好",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("scheduling")
|
||||
return {
|
||||
title: `${t("rules.title")} - Next_Edu`,
|
||||
description: t("rules.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("scheduling")
|
||||
await requirePermission(Permissions.SCHEDULE_ADJUST)
|
||||
const [classes, existingRules] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
@@ -30,17 +35,15 @@ export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">排课规则</h2>
|
||||
<p className="text-muted-foreground">
|
||||
配置每日课时上限、课间窗口与均衡偏好。
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("rules.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("rules.description")}</p>
|
||||
</div>
|
||||
|
||||
{classOptions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title="暂无可用班级"
|
||||
description="请先创建班级,再配置排课规则。"
|
||||
title={t("rules.noClassesTitle")}
|
||||
description={t("rules.noClassesDescription")}
|
||||
/>
|
||||
) : (
|
||||
<SchedulingRulesForm classes={classOptions} existingRules={existingRules} />
|
||||
@@ -48,7 +51,7 @@ export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CalendarCog className="h-4 w-4" />
|
||||
<span>提示:未选择具体班级时保存的规则将作为全局默认。</span>
|
||||
<span>{t("rules.hint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link"
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -9,6 +10,7 @@ import { getGrades } from "@/modules/school/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
|
||||
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
|
||||
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -18,19 +20,23 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { formatDate, formatNumber } from "@/shared/lib/utils"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "年级作业洞察 - Next_Edu",
|
||||
description: "按年级聚合的作业统计与班级排名",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("school")
|
||||
return {
|
||||
title: `${t("grades.gradeInsights.title")} - Next_Edu`,
|
||||
description: t("grades.gradeInsights.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AdminGradeInsightsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||
const t = await getTranslations("school")
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||
@@ -43,15 +49,22 @@ export default async function AdminGradeInsightsPage({
|
||||
getSchoolWideGradeSummary(ctx.dataScope),
|
||||
])
|
||||
|
||||
const buildHref = (gId: string): string => {
|
||||
const p = new URLSearchParams()
|
||||
if (gId && gId !== "all") p.set("gradeId", gId)
|
||||
const qs = p.toString()
|
||||
return qs ? `/admin/school/grades/insights?${qs}` : "/admin/school/grades/insights"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">年级作业洞察</h2>
|
||||
<p className="text-muted-foreground">按年级聚合的作业统计与班级排名。</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeInsights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grades.gradeInsights.description")}</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/admin/school/grades">管理年级</Link>
|
||||
<Link href="/admin/school/grades">{t("grades.gradeInsights.manageGrades")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -60,86 +73,57 @@ export default async function AdminGradeInsightsPage({
|
||||
<SchoolWideSummaryCard summary={schoolWideSummary} />
|
||||
)}
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">筛选</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
action="/admin/school/grades/insights"
|
||||
method="get"
|
||||
className="flex flex-col gap-3 md:flex-row md:items-center"
|
||||
>
|
||||
<label htmlFor="grade-filter" className="text-sm font-medium">
|
||||
年级
|
||||
</label>
|
||||
<select
|
||||
id="grade-filter"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-80"
|
||||
>
|
||||
<option value="all">请选择年级</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.school.name} / {g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" className="md:ml-2">
|
||||
应用
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 年级筛选:ChipNav 即时切换,无整页刷新 */}
|
||||
<GradeInsightsFilters
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
|
||||
currentGradeId={selected || "all"}
|
||||
buildHref={buildHref}
|
||||
/>
|
||||
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="请选择年级以查看洞察"
|
||||
description="选择一个年级,查看最新作业与历史成绩统计。"
|
||||
title={t("grades.gradeInsights.selectToView")}
|
||||
description={t("grades.gradeInsights.selectToViewDescription")}
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : !insights ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="年级未找到"
|
||||
description="该年级可能不存在或无可访问数据。"
|
||||
title={t("grades.gradeInsights.notFound")}
|
||||
description={t("grades.gradeInsights.notFoundDescription")}
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : insights.assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="该年级暂无作业数据"
|
||||
description="尚未向该年级学生布置任何作业。"
|
||||
title={t("grades.gradeInsights.noData")}
|
||||
description={t("grades.gradeInsights.noDataDescription")}
|
||||
className="h-80 bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="班级数"
|
||||
title={t("grades.gradeInsights.classes")}
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="学生数"
|
||||
title={t("grades.gradeInsights.students")}
|
||||
value={insights.studentCounts.total}
|
||||
description={`在读 ${insights.studentCounts.active} • 停用 ${insights.studentCounts.inactive}`}
|
||||
description={`${t("grades.gradeInsights.active")} ${insights.studentCounts.active} • ${t("grades.gradeInsights.inactive")} ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="总体均分"
|
||||
title={t("grades.gradeInsights.overallAvg")}
|
||||
value={formatNumber(insights.overallScores.avg)}
|
||||
description="基于已批改作业"
|
||||
description="-"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title="最新均分"
|
||||
title={t("grades.gradeInsights.latestAvg")}
|
||||
value={formatNumber(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest?.title ?? "-"}
|
||||
valueClassName="tabular-nums"
|
||||
@@ -148,87 +132,93 @@ export default async function AdminGradeInsightsPage({
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">最新作业</CardTitle>
|
||||
<CardTitle className="text-base">{t("grades.gradeInsights.homeworkTimeline")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.assignments.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>作业</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">目标数</TableHead>
|
||||
<TableHead className="text-right">提交数</TableHead>
|
||||
<TableHead className="text-right">已批改</TableHead>
|
||||
<TableHead className="text-right">均分</TableHead>
|
||||
<TableHead className="text-right">中位数</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.assignments.map((a) => (
|
||||
<TableRow key={a.assignmentId}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
|
||||
{/* v4-P1-10: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
|
||||
<TableHead>{t("grades.gradeInsights.status")}</TableHead>
|
||||
<TableHead>{t("grades.gradeInsights.created")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.targeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.submitted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.avg")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.median")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.assignments.map((a) => (
|
||||
<TableRow key={a.assignmentId}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">班级排名</CardTitle>
|
||||
<CardTitle className="text-base">{t("grades.gradeInsights.classRanking")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.classes.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>班级</TableHead>
|
||||
<TableHead className="text-right">学生数</TableHead>
|
||||
<TableHead className="text-right">最新均分</TableHead>
|
||||
<TableHead className="text-right">上次均分</TableHead>
|
||||
<TableHead className="text-right">Δ</TableHead>
|
||||
<TableHead className="text-right">总体均分</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.classes.map((c) => (
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? (
|
||||
<span className="text-muted-foreground"> • {c.class.homeroom}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
{/* v4-P1-10: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeInsights.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.students")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.latestAvgCol")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.prevAvg")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.delta")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.overallAvgCol")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.classes.map((c) => (
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? (
|
||||
<span className="text-muted-foreground"> • {c.class.homeroom}</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { GradesClient } from "@/modules/school/components/grades-view"
|
||||
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
|
||||
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
|
||||
import { getGrades, getSchools, getStaffOptions, getGradeOverviewStats } from "@/modules/school/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -21,7 +21,12 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function AdminGradesPage(): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||
const t = await getTranslations("school")
|
||||
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
|
||||
const [grades, schools, staff, gradeStats] = await Promise.all([
|
||||
getGrades(),
|
||||
getSchools(),
|
||||
getStaffOptions(),
|
||||
getGradeOverviewStats(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
@@ -30,7 +35,7 @@ export default async function AdminGradesPage(): Promise<JSX.Element> {
|
||||
<p className="text-muted-foreground">{t("grades.description")}</p>
|
||||
</div>
|
||||
<SchoolErrorBoundary>
|
||||
<GradesClient grades={grades} schools={schools} staff={staff} />
|
||||
<GradesClient grades={grades} schools={schools} staff={staff} gradeStats={gradeStats} />
|
||||
</SchoolErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "系统设置 - Next_Edu",
|
||||
description: "管理系统基础信息与运行参数",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("settings")
|
||||
return {
|
||||
title: `${t("admin.title")} - Next_Edu`,
|
||||
description: t("admin.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Metadata } from "next"
|
||||
import { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -17,14 +18,18 @@ import {
|
||||
} from "@/shared/components/ui/table"
|
||||
import { UserImportDialog } from "@/modules/users/components/user-import-dialog"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "批量导入用户 - Next_Edu",
|
||||
description: "通过 Excel 批量导入用户",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("users")
|
||||
return {
|
||||
title: `${t("import.title")} - Next_Edu`,
|
||||
description: t("import.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function UserImportPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("users")
|
||||
await requirePermission(Permissions.USER_MANAGE)
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
@@ -34,13 +39,13 @@ export default async function UserImportPage(): Promise<JSX.Element> {
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/dashboard">
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回
|
||||
{t("import.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold tracking-tight">批量导入用户</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("import.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
通过 Excel 文件批量创建用户账号,支持学生自动加入班级。
|
||||
{t("import.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<UserImportDialog />
|
||||
@@ -51,26 +56,26 @@ export default async function UserImportPage(): Promise<JSX.Element> {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">导入说明</CardTitle>
|
||||
<CardTitle className="text-base">{t("import.instructionsTitle")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>使用 Excel 批量导入用户的步骤</CardDescription>
|
||||
<CardDescription>{t("import.instructionsDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">1</span>
|
||||
<p>点击「批量导入用户」按钮,下载导入模板。</p>
|
||||
<p>{t("import.step1")}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">2</span>
|
||||
<p>按模板格式填写用户信息(姓名、邮箱、角色、手机、班级邀请码)。</p>
|
||||
<p>{t("import.step2")}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">3</span>
|
||||
<p>上传填写好的 Excel 文件,系统将解析并预览数据。</p>
|
||||
<p>{t("import.step3")}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">4</span>
|
||||
<p>确认预览数据无误后,点击「确认导入」完成批量创建。</p>
|
||||
<p>{t("import.step4")}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -79,17 +84,17 @@ export default async function UserImportPage(): Promise<JSX.Element> {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">注意事项</CardTitle>
|
||||
<CardTitle className="text-base">{t("import.notesTitle")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>导入前请仔细阅读</CardDescription>
|
||||
<CardDescription>{t("import.notesDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>• 默认密码为 <code className="rounded bg-muted px-1 py-0.5 text-xs">123456</code>,请提示用户首次登录后修改。</p>
|
||||
<p>• 邮箱必须唯一,重复邮箱将被跳过并记录在错误报告中。</p>
|
||||
<p>• 角色可选:admin / teacher / student / parent / grade_head / teaching_head。</p>
|
||||
<p>• 班级邀请码仅对 student 角色有效,填写后学生将自动加入对应班级。</p>
|
||||
<p>• 单次最多导入 10MB 的文件,建议单次不超过 500 条记录。</p>
|
||||
<p>• 导入完成后将显示成功数、失败数及详细错误信息。</p>
|
||||
<p>{t("import.note1")}</p>
|
||||
<p>{t("import.note2")}</p>
|
||||
<p>{t("import.note3")}</p>
|
||||
<p>{t("import.note4")}</p>
|
||||
<p>{t("import.note5")}</p>
|
||||
<p>{t("import.note6")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -98,45 +103,45 @@ export default async function UserImportPage(): Promise<JSX.Element> {
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">模板字段说明</CardTitle>
|
||||
<CardTitle className="text-base">{t("import.templateTitle")}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Excel 模板各列含义与要求</CardDescription>
|
||||
<CardDescription>{t("import.templateDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>列名</TableHead>
|
||||
<TableHead>是否必填</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
<TableHead>{t("import.columnName")}</TableHead>
|
||||
<TableHead>{t("import.columnRequired")}</TableHead>
|
||||
<TableHead>{t("import.columnDescription")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">姓名</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">用户姓名</TableCell>
|
||||
<TableCell className="font-medium">{t("import.fieldName")}</TableCell>
|
||||
<TableCell>{t("import.fieldNameRequired")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("import.fieldNameDescription")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">邮箱</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">登录账号,需符合邮箱格式且唯一</TableCell>
|
||||
<TableCell className="font-medium">{t("import.fieldEmail")}</TableCell>
|
||||
<TableCell>{t("import.fieldEmailRequired")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("import.fieldEmailDescription")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">角色</TableCell>
|
||||
<TableCell>必填</TableCell>
|
||||
<TableCell className="text-muted-foreground">admin / teacher / student / parent / grade_head / teaching_head</TableCell>
|
||||
<TableCell className="font-medium">{t("import.fieldRole")}</TableCell>
|
||||
<TableCell>{t("import.fieldRoleRequired")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("import.fieldRoleDescription")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">手机</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">联系电话</TableCell>
|
||||
<TableCell className="font-medium">{t("import.fieldPhone")}</TableCell>
|
||||
<TableCell>{t("import.fieldPhoneRequired")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("import.fieldPhoneDescription")}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">班级邀请码</TableCell>
|
||||
<TableCell>选填</TableCell>
|
||||
<TableCell className="text-muted-foreground">仅 student 角色有效,6 位邀请码</TableCell>
|
||||
<TableCell className="font-medium">{t("import.fieldInviteCode")}</TableCell>
|
||||
<TableCell>{t("import.fieldInviteCodeRequired")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t("import.fieldInviteCodeDescription")}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -7,9 +8,12 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||
import { getAdminUsers, getAdminUserRoles } from "@/modules/users/data-access"
|
||||
import { AdminUsersView } from "@/modules/users/components/admin-users-view"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "用户管理 - Next_Edu",
|
||||
description: "管理系统所有用户",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("users")
|
||||
return {
|
||||
title: `${t("title")} - Next_Edu`,
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
42
src/app/(dashboard)/management/grade/dashboard/loading.tsx
Normal file
42
src/app/(dashboard)/management/grade/dashboard/loading.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function ManagementGradeDashboardLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
|
||||
<Skeleton className="h-10 w-full max-w-2xl" />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
src/app/(dashboard)/management/grade/dashboard/page.tsx
Normal file
178
src/app/(dashboard)/management/grade/dashboard/page.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { getGradesForStaff } from "@/modules/school/data-access"
|
||||
import { getGradeDistributionByGradeId } from "@/modules/grades/data-access-analytics"
|
||||
import { getExamsByGradeId } from "@/modules/exams/data-access"
|
||||
import { getGradeCoursePlanProgress } from "@/modules/course-plans/data-access"
|
||||
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
|
||||
import { GradeDistributionPanel } from "@/modules/school/components/grade-dashboard/grade-distribution-panel"
|
||||
import { GradeHomeworkPanel } from "@/modules/school/components/grade-dashboard/grade-homework-panel"
|
||||
import { GradeExamsPanel } from "@/modules/school/components/grade-dashboard/grade-exams-panel"
|
||||
import { GradeProgressPanel } from "@/modules/school/components/grade-dashboard/grade-progress-panel"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const TAB_OPTIONS = [
|
||||
{ id: "distribution", name: "" },
|
||||
{ id: "homework", name: "" },
|
||||
{ id: "exams", name: "" },
|
||||
{ id: "progress", name: "" },
|
||||
] as const
|
||||
|
||||
type TabId = (typeof TAB_OPTIONS)[number]["id"]
|
||||
|
||||
const isTabId = (v: string): v is TabId =>
|
||||
v === "distribution" || v === "homework" || v === "exams" || v === "progress"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("school")
|
||||
return {
|
||||
title: `${t("grades.gradeDashboard.title")} - Next_Edu`,
|
||||
description: t("grades.gradeDashboard.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GradeDashboardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("school")
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
const tabRaw = getParam(params, "tab") || "distribution"
|
||||
const tab: TabId = isTabId(tabRaw) ? tabRaw : "distribution"
|
||||
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const grades = await getGradesForStaff(teacherId)
|
||||
const allowedIds = new Set(grades.map((g) => g.id))
|
||||
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
|
||||
|
||||
const buildHref = (gId: string): string => {
|
||||
const p = new URLSearchParams()
|
||||
if (gId && gId !== "all") p.set("gradeId", gId)
|
||||
if (tab !== "distribution") p.set("tab", tab)
|
||||
const qs = p.toString()
|
||||
return qs ? `/management/grade/dashboard?${qs}` : "/management/grade/dashboard"
|
||||
}
|
||||
|
||||
const buildTabHref = (tId: string): string => {
|
||||
const p = new URLSearchParams()
|
||||
if (selected) p.set("gradeId", selected)
|
||||
if (tId !== "distribution") p.set("tab", tId)
|
||||
const qs = p.toString()
|
||||
return qs ? `/management/grade/dashboard?${qs}` : "/management/grade/dashboard"
|
||||
}
|
||||
|
||||
const tabOptions = TAB_OPTIONS.map((o) => ({
|
||||
id: o.id,
|
||||
name: t(`grades.gradeDashboard.tabs.${o.id}` as const),
|
||||
}))
|
||||
|
||||
if (grades.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeDashboard.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grades.gradeDashboard.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("grades.gradeDashboard.selectToView")}
|
||||
description={t("grades.gradeDashboard.selectToViewDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch data for the active tab only
|
||||
let distributionData = null
|
||||
let homeworkData = null
|
||||
let examsData = null
|
||||
let progressData = null
|
||||
|
||||
if (selected) {
|
||||
if (tab === "distribution") {
|
||||
distributionData = await getGradeDistributionByGradeId({
|
||||
gradeId: selected,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
} else if (tab === "homework") {
|
||||
homeworkData = await getGradeHomeworkInsights({ gradeId: selected, limit: 50 })
|
||||
} else if (tab === "exams") {
|
||||
examsData = await getExamsByGradeId({ gradeId: selected, scope: ctx.dataScope })
|
||||
} else if (tab === "progress") {
|
||||
progressData = await getGradeCoursePlanProgress({ gradeId: selected })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeDashboard.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grades.gradeDashboard.description")}</p>
|
||||
</div>
|
||||
|
||||
<GradeInsightsFilters
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
|
||||
currentGradeId={selected || "all"}
|
||||
buildHref={buildHref}
|
||||
/>
|
||||
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("grades.gradeDashboard.selectToView")}
|
||||
description={t("grades.gradeDashboard.selectToViewDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<ChipNav
|
||||
options={tabOptions}
|
||||
currentId={tab}
|
||||
buildHref={buildTabHref}
|
||||
/>
|
||||
|
||||
{tab === "distribution" && distributionData && (
|
||||
<GradeDistributionPanel data={distributionData} />
|
||||
)}
|
||||
{tab === "homework" && homeworkData && (
|
||||
<GradeHomeworkPanel data={homeworkData} />
|
||||
)}
|
||||
{tab === "exams" && examsData && (
|
||||
<GradeExamsPanel data={examsData} />
|
||||
)}
|
||||
{tab === "progress" && progressData && (
|
||||
<GradeProgressPanel data={progressData} />
|
||||
)}
|
||||
|
||||
{/* Fallback: data was null (e.g. homework insights returned null) */}
|
||||
{((tab === "distribution" && !distributionData) ||
|
||||
(tab === "homework" && !homeworkData) ||
|
||||
(tab === "exams" && !examsData) ||
|
||||
(tab === "progress" && !progressData)) && (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("grades.gradeDashboard.noData")}
|
||||
description={t("grades.gradeDashboard.noDataDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,25 +7,23 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { getGradesForStaff } from "@/modules/school/data-access"
|
||||
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { formatDate, formatNumber } from "@/shared/lib/utils"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const formatScore = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("school")
|
||||
return {
|
||||
title: `${t("classManagement.grade.insights.title")} - Next_Edu`,
|
||||
description: t("classManagement.grade.insights.description"),
|
||||
title: `${t("grades.gradeInsights.title")} - Next_Edu`,
|
||||
description: t("grades.gradeInsights.description"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,17 +40,24 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
|
||||
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
|
||||
|
||||
const buildHref = (gId: string): string => {
|
||||
const p = new URLSearchParams()
|
||||
if (gId && gId !== "all") p.set("gradeId", gId)
|
||||
const qs = p.toString()
|
||||
return qs ? `/management/grade/insights?${qs}` : "/management/grade/insights"
|
||||
}
|
||||
|
||||
if (grades.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeInsights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grades.gradeInsights.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("classManagement.grade.insights.noGrades")}
|
||||
description={t("classManagement.grade.insights.noGradesDescription")}
|
||||
title={t("grades.gradeInsights.selectToView")}
|
||||
description={t("grades.gradeInsights.selectToViewDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -62,85 +67,62 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.insights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("classManagement.grade.insights.description")}</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grades.gradeInsights.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grades.gradeInsights.description")}</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.filters")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{grades.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form action="/management/grade/insights" method="get" className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<label htmlFor="gradeId" className="text-sm font-medium">{t("classManagement.grade.insights.grade")}</label>
|
||||
<select
|
||||
id="gradeId"
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
>
|
||||
<option value="all">{t("classManagement.grade.insights.selectGrade")}</option>
|
||||
{grades.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.school.name} / {g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" className="md:ml-2">
|
||||
{t("classManagement.grade.insights.apply")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 年级筛选:ChipNav 即时切换,无整页刷新 */}
|
||||
<GradeInsightsFilters
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
|
||||
currentGradeId={selected || "all"}
|
||||
buildHref={buildHref}
|
||||
/>
|
||||
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("classManagement.grade.insights.selectToView")}
|
||||
description={t("classManagement.grade.insights.selectToViewDescription")}
|
||||
title={t("grades.gradeInsights.selectToView")}
|
||||
description={t("grades.gradeInsights.selectToViewDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : !insights ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("classManagement.grade.insights.notFound")}
|
||||
description={t("classManagement.grade.insights.notFoundDescription")}
|
||||
title={t("grades.gradeInsights.notFound")}
|
||||
description={t("grades.gradeInsights.notFoundDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : insights.assignments.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("classManagement.grade.insights.noData")}
|
||||
description={t("classManagement.grade.insights.noDataDescription")}
|
||||
title={t("grades.gradeInsights.noData")}
|
||||
description={t("grades.gradeInsights.noDataDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title={t("classManagement.grade.insights.classes")}
|
||||
title={t("grades.gradeInsights.classes")}
|
||||
value={insights.classCount}
|
||||
description={`${insights.grade.school.name} / ${insights.grade.name}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("classManagement.grade.insights.students")}
|
||||
title={t("grades.gradeInsights.students")}
|
||||
value={insights.studentCounts.total}
|
||||
description={`${t("classManagement.grade.insights.active")} ${insights.studentCounts.active} • ${t("classManagement.grade.insights.inactive")} ${insights.studentCounts.inactive}`}
|
||||
description={`${t("grades.gradeInsights.active")} ${insights.studentCounts.active} • ${t("grades.gradeInsights.inactive")} ${insights.studentCounts.inactive}`}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("classManagement.grade.insights.overallAvg")}
|
||||
value={formatScore(insights.overallScores.avg)}
|
||||
title={t("grades.gradeInsights.overallAvg")}
|
||||
value={formatNumber(insights.overallScores.avg)}
|
||||
description="-"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("classManagement.grade.insights.latestAvg")}
|
||||
value={formatScore(insights.latest?.scoreStats.avg ?? null)}
|
||||
title={t("grades.gradeInsights.latestAvg")}
|
||||
value={formatNumber(insights.latest?.scoreStats.avg ?? null)}
|
||||
description={insights.latest ? insights.latest.title : "-"}
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
@@ -148,85 +130,91 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.homeworkTimeline")}</CardTitle>
|
||||
<CardTitle className="text-base">{t("grades.gradeInsights.homeworkTimeline")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.assignments.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("classManagement.grade.insights.assignment")}</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.status")}</TableHead>
|
||||
<TableHead>{t("classManagement.grade.insights.created")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.targeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.submitted")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.avg")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.median")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.assignments.map((a) => (
|
||||
<TableRow key={a.assignmentId}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(a.scoreStats.median)}</TableCell>
|
||||
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeInsights.assignment")}</TableHead>
|
||||
<TableHead>{t("grades.gradeInsights.status")}</TableHead>
|
||||
<TableHead>{t("grades.gradeInsights.created")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.targeted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.submitted")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.graded")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.avg")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.median")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.assignments.map((a) => (
|
||||
<TableRow key={a.assignmentId}>
|
||||
<TableCell className="font-medium">{a.title}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="capitalize">
|
||||
{a.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(a.createdAt)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.targetCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.submittedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{a.gradedCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.avg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(a.scoreStats.median)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="text-base">{t("classManagement.grade.insights.classRanking")}</CardTitle>
|
||||
<CardTitle className="text-base">{t("grades.gradeInsights.classRanking")}</CardTitle>
|
||||
<Badge variant="secondary" className="tabular-nums">
|
||||
{insights.classes.length}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("classManagement.grade.insights.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.students")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.latestAvgCol")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.prevAvg")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.delta")}</TableHead>
|
||||
<TableHead className="text-right">{t("classManagement.grade.insights.overallAvgCol")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.classes.map((c) => (
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? <span className="text-muted-foreground"> • {c.class.homeroom}</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatScore(c.overallScores.avg)}</TableCell>
|
||||
{/* v4-P1-11: 移动端表格水平滚动 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead>{t("grades.gradeInsights.class")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.students")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.latestAvgCol")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.prevAvg")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.delta")}</TableHead>
|
||||
<TableHead className="text-right">{t("grades.gradeInsights.overallAvgCol")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{insights.classes.map((c) => (
|
||||
<TableRow key={c.class.id}>
|
||||
<TableCell className="font-medium">
|
||||
{c.class.name}
|
||||
{c.class.homeroom ? <span className="text-muted-foreground"> • {c.class.homeroom}</span> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.studentCounts.total}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.latestAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.prevAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.deltaAvg)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatNumber(c.overallScores.avg)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
27
src/app/(dashboard)/management/grade/practice/error.tsx
Normal file
27
src/app/(dashboard)/management/grade/practice/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function Error() {
|
||||
useEffect(() => {
|
||||
console.error("Grade practice analytics page error")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">年级专项练习总览</h1>
|
||||
<p className="text-muted-foreground">加载年级练习数据时发生错误</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="加载失败"
|
||||
description="请刷新页面重试,或联系管理员检查数据访问权限。"
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/management/grade/practice/loading.tsx
Normal file
16
src/app/(dashboard)/management/grade/practice/loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px]" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
src/app/(dashboard)/management/grade/practice/page.tsx
Normal file
141
src/app/(dashboard)/management/grade/practice/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getGradesForStaff } from "@/modules/school/data-access"
|
||||
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
|
||||
|
||||
import {
|
||||
getGradePracticeOverview,
|
||||
getGradeClassPracticeComparison,
|
||||
getPracticeTypeBreakdown,
|
||||
} from "@/modules/adaptive-practice/data-access-analytics"
|
||||
import { getUserIdsByGradeId } from "@/modules/users/data-access"
|
||||
import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards"
|
||||
import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table"
|
||||
import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("practice")
|
||||
return {
|
||||
title: `${t("grade.title")} - Next_Edu`,
|
||||
description: t("grade.description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GradePracticePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
const t = await getTranslations("practice")
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const grades = await getGradesForStaff(teacherId)
|
||||
const allowedIds = new Set(grades.map((g) => g.id))
|
||||
const selected = gradeId && gradeId !== "all" && allowedIds.has(gradeId) ? gradeId : ""
|
||||
|
||||
const buildHref = (gId: string): string => {
|
||||
const p = new URLSearchParams()
|
||||
if (gId && gId !== "all") p.set("gradeId", gId)
|
||||
const qs = p.toString()
|
||||
return qs ? `/management/grade/practice?${qs}` : "/management/grade/practice"
|
||||
}
|
||||
|
||||
if (grades.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grade.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grade.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("grade.noGrade")}
|
||||
description={t("grade.noGradeDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 仅在选中年级时查询数据
|
||||
let overview: Awaited<ReturnType<typeof getGradePracticeOverview>> = null
|
||||
let classComparison: Awaited<ReturnType<typeof getGradeClassPracticeComparison>> = []
|
||||
let typeBreakdown: Awaited<ReturnType<typeof getPracticeTypeBreakdown>> = []
|
||||
|
||||
if (selected) {
|
||||
const studentIds = await getUserIdsByGradeId(selected)
|
||||
const [ov, cmp, breakdown] = await Promise.all([
|
||||
getGradePracticeOverview(selected),
|
||||
getGradeClassPracticeComparison(selected),
|
||||
getPracticeTypeBreakdown(studentIds),
|
||||
])
|
||||
overview = ov
|
||||
classComparison = cmp
|
||||
typeBreakdown = breakdown
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("grade.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("grade.description")}</p>
|
||||
</div>
|
||||
|
||||
<GradeInsightsFilters
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
|
||||
currentGradeId={selected || "all"}
|
||||
buildHref={buildHref}
|
||||
/>
|
||||
|
||||
{!selected ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("grade.noGrade")}
|
||||
description={t("grade.noGradeDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : !overview ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title={t("teacher.noData")}
|
||||
description={t("teacher.noDataDescription")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* 年级整体统计卡片 */}
|
||||
<PracticeOverviewStatsCards
|
||||
totalClasses={overview.totalClasses}
|
||||
totalSessions={overview.totalSessions}
|
||||
totalAnswered={overview.totalQuestionsAnswered}
|
||||
averageAccuracy={overview.averageAccuracy}
|
||||
participationRate={overview.participationRate}
|
||||
/>
|
||||
|
||||
{/* 各班级练习对比表 */}
|
||||
{classComparison.length > 0 ? (
|
||||
<ClassPracticeComparisonTable data={classComparison} />
|
||||
) : null}
|
||||
|
||||
{/* 练习类型分布图 */}
|
||||
{typeBreakdown.length > 0 ? (
|
||||
<PracticeTypeBreakdownChart data={typeBreakdown} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/modules/parent/components/child-detail-panel"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -27,6 +28,7 @@ export default async function ChildDetailPage({
|
||||
const { studentId } = await params
|
||||
const sp = await searchParams
|
||||
const ctx = await requireAuth()
|
||||
const t = await getTranslations("common")
|
||||
|
||||
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
|
||||
const relation = await verifyParentChildRelation(studentId, ctx.userId)
|
||||
@@ -41,8 +43,8 @@ export default async function ChildDetailPage({
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={ShieldAlert}
|
||||
title="Access denied"
|
||||
description="This student is not linked to your account. Please contact the school administrator if you believe this is an error."
|
||||
title={t("accessDenied")}
|
||||
description={t("accessDeniedDesc")}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -34,15 +35,16 @@ type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
|
||||
|
||||
export default async function ParentDiagnosticPage() {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("diagnostic")
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<ParentNoChildrenPage
|
||||
title="Children Diagnostic"
|
||||
description="View your children's knowledge point mastery and diagnostic reports."
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={Stethoscope}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
emptyTitle={t("parent.noChildren")}
|
||||
emptyDescription={t("parent.noChildrenDesc")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -68,7 +70,7 @@ export default async function ParentDiagnosticPage() {
|
||||
|
||||
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
|
||||
const studentId = childrenIds[idx]
|
||||
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
|
||||
const studentName = nameMap.get(studentId)?.name ?? t("parent.selectChild")
|
||||
if (r.status === "fulfilled") {
|
||||
return {
|
||||
studentId,
|
||||
@@ -88,11 +90,11 @@ export default async function ParentDiagnosticPage() {
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
title="Children Diagnostic"
|
||||
description="View knowledge point mastery and diagnostic reports for all your children."
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={Stethoscope}
|
||||
noRecordsTitle="No diagnostic data"
|
||||
noRecordsDescription="Your children don't have any diagnostic data yet."
|
||||
noRecordsTitle={t("parent.noReports")}
|
||||
noRecordsDescription={t("parent.noReports")}
|
||||
items={items}
|
||||
renderItem={(item) => (
|
||||
<>
|
||||
@@ -111,10 +113,10 @@ export default async function ParentDiagnosticPage() {
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
Failed to load diagnostic data for {item.studentName}.
|
||||
{t("error.loadFailed")} for {item.studentName}.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please refresh the page or contact the school administrator if the problem persists.
|
||||
{t("error.loadFailed")}. Please refresh the page or contact the school administrator if the problem persists.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -17,23 +17,25 @@ import {
|
||||
} from "@/modules/error-book/data-access"
|
||||
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const t = await getTranslations("errorBook")
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">子女错题本</h1>
|
||||
<p className="text-muted-foreground">查看子女的错题情况与学习进度。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("parent.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="暂无子女关联"
|
||||
description="您的账号尚未关联子女,请联系学校管理员进行关联。"
|
||||
title={t("parent.noChild")}
|
||||
description={t("parent.noChildDesc")}
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
@@ -56,8 +58,8 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">子女错题本</h1>
|
||||
<p className="text-muted-foreground">查看子女的错题情况与学习进度。</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("parent.description")}</p>
|
||||
</div>
|
||||
|
||||
{childrenIds.length === 1 ? (
|
||||
@@ -68,35 +70,35 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{childrenIds.map((childId, idx) => {
|
||||
const stats = childStatsList[idx]
|
||||
const name = nameMap.get(childId) ?? "未知"
|
||||
const name = nameMap.get(childId) ?? t("parent.unknown")
|
||||
return (
|
||||
<Card key={childId}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between text-base">
|
||||
<span>{name}</span>
|
||||
<Badge variant="outline">
|
||||
{formatNumber(stats.masteredRate * 100, 0)}% 掌握
|
||||
{t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">错题总数</div>
|
||||
<div className="text-muted-foreground">{t("parent.totalErrors")}</div>
|
||||
<div className="text-lg font-bold">{stats.totalCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">待复习</div>
|
||||
<div className="text-muted-foreground">{t("parent.dueReview")}</div>
|
||||
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">
|
||||
{stats.dueReviewCount}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">待学习</div>
|
||||
<div className="text-muted-foreground">{t("parent.newItems")}</div>
|
||||
<div className="font-medium text-blue-600 dark:text-blue-400">{stats.newCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">已掌握</div>
|
||||
<div className="text-muted-foreground">{t("parent.mastered")}</div>
|
||||
<div className="font-medium text-emerald-600 dark:text-emerald-400">{stats.masteredCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +114,7 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
{weakKps.length > 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">薄弱知识点</CardTitle>
|
||||
<CardTitle className="text-base">{t("parent.weakPoints")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
@@ -121,7 +123,7 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{kp.knowledgePointName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{kp.errorCount} 错 · {formatNumber(kp.masteryRate * 100, 0)}% 掌握
|
||||
{t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={kp.masteryRate * 100} className="h-1.5" />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export default function ParentError({
|
||||
error,
|
||||
@@ -10,13 +11,15 @@ export default function ParentError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("common")
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Something went wrong"
|
||||
title={t("somethingWentWrong")}
|
||||
description={error.message || "An unexpected error occurred. Please try again."}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
action={{ label: t("tryAgain"), onClick: reset }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
|
||||
import { GraduationCap } from "lucide-react"
|
||||
import type { ClassAverageTrendResult } from "@/modules/grades/types"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -22,15 +23,16 @@ interface ChildGradeItem {
|
||||
|
||||
export default async function ParentGradesPage() {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<ParentNoChildrenPage
|
||||
title="Children Grades"
|
||||
description="View your children's grade records."
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={GraduationCap}
|
||||
emptyTitle="No children linked"
|
||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
||||
emptyTitle={t("parent.noChildren")}
|
||||
emptyDescription={t("parent.noChildrenDesc")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -64,11 +66,11 @@ export default async function ParentGradesPage() {
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
title="Children Grades"
|
||||
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={GraduationCap}
|
||||
noRecordsTitle="No grade records"
|
||||
noRecordsDescription="Your children don't have any grade records yet."
|
||||
noRecordsTitle={t("parent.noGrades")}
|
||||
noRecordsDescription={t("parent.noGradesDesc")}
|
||||
items={validItems}
|
||||
renderItem={({ studentId, summary, classAverageTrend }) => (
|
||||
<>
|
||||
|
||||
@@ -4,23 +4,26 @@ import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentLeavePage() {
|
||||
const t = await getTranslations("leave")
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Leave Request</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Submit a leave request for your child.
|
||||
{t("description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
|
||||
<Link href="/parent/dashboard" aria-label={t("backToDashboard")}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Dashboard
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -28,33 +31,33 @@ export default async function ParentLeavePage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
Online Leave Request
|
||||
{t("onlineLeave")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="Coming soon"
|
||||
description="The online leave request feature is being developed and will be available soon. For now, please contact the homeroom teacher or school office directly."
|
||||
title={t("comingSoon")}
|
||||
description={t("comingSoonDesc")}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
|
||||
<div className="text-sm font-medium">Contact options</div>
|
||||
<div className="text-sm font-medium">{t("contactOptions")}</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4" aria-hidden />
|
||||
<span>Call the school office during working hours</span>
|
||||
<span>{t("callOffice")}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4" aria-hidden />
|
||||
<span>Send a message to the homeroom teacher via the Messages page</span>
|
||||
<span>{t("sendMessage")}</span>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/messages"
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
Go to Messages
|
||||
{t("goToMessages")}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentLessonPlanViewError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function ParentLessonPlanViewLoading() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentLessonPlanViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ planId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const plan = await getLessonPlanById(planId, ctx.userId)
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notFound")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (plan.status !== "published") {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notPublished")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanReadonlyView
|
||||
doc={plan.content}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/parent/lesson-plans/error.tsx
Normal file
20
src/app/(dashboard)/parent/lesson-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentLessonPlansError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/parent/lesson-plans/loading.tsx
Normal file
17
src/app/(dashboard)/parent/lesson-plans/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function ParentLessonPlansLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(dashboard)/parent/lesson-plans/page.tsx
Normal file
44
src/app/(dashboard)/parent/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentLessonPlansPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const [items, subjects] = await Promise.all([
|
||||
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("parent.description")}</p>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList
|
||||
initialItems={items}
|
||||
subjects={subjects}
|
||||
viewMode="parent"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function StudentAttendanceError({ reset }: { error: Error & { dig
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function StudentDiagnosticError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学情诊断页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
@@ -9,6 +11,7 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentDiagnosticPage() {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const [summary, reportsResult] = await Promise.all([
|
||||
getStudentMasterySummary(ctx.userId),
|
||||
@@ -25,10 +28,10 @@ export default async function StudentDiagnosticPage() {
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
My Diagnostic
|
||||
{t("diagnostic.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Your knowledge point mastery analysis and diagnostic reports.
|
||||
{t("diagnostic.description")}
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function StudentElectiveError({ reset }: { error: Error & { diges
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { BookX } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentErrorBookError() {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookX}
|
||||
title="加载错题本失败"
|
||||
description="发生了一些错误,请刷新页面重试。如果问题持续,请联系管理员。"
|
||||
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -92,6 +93,7 @@ export default async function StudentErrorBookPage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const t = await getTranslations("student")
|
||||
const stats = await getErrorBookStats(ctx.userId)
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
@@ -100,9 +102,9 @@ export default async function StudentErrorBookPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题本</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("errorBook.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
自动收录考试与作业中的错题,科学复习,攻克薄弱点。
|
||||
{t("errorBook.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentError({
|
||||
error,
|
||||
@@ -10,12 +12,13 @@ export default function StudentError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Something went wrong"
|
||||
description={error.message || "An unexpected error occurred. Please try again."}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
title={t("error.title")}
|
||||
description={error.message || t("error.description")}
|
||||
action={{ label: t("error.retry"), onClick: reset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function StudentGradesError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩查询页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function StudentGradesPage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([
|
||||
searchParams,
|
||||
getStudentGradeSummary(ctx.userId, ctx.dataScope),
|
||||
@@ -32,7 +33,6 @@ export default async function StudentGradesPage({
|
||||
])
|
||||
|
||||
if (!summary) {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -73,7 +73,7 @@ export default async function StudentGradesPage({
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
|
||||
<p className="text-muted-foreground">{summary.records.length} 条成绩记录</p>
|
||||
<p className="text-muted-foreground">{t("summary.recordCount", { count: summary.records.length })}</p>
|
||||
</div>
|
||||
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
|
||||
{filteredSummary.records.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -17,6 +18,8 @@ export default async function StudentAssignmentTakePage({
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
if (!data) return notFound()
|
||||
|
||||
@@ -28,7 +31,7 @@ export default async function StudentAssignmentTakePage({
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +45,9 @@ export default async function StudentAssignmentTakePage({
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
|
||||
<span className="mx-2" aria-hidden="true">•</span>
|
||||
<span>Max Attempts: {data.assignment.maxAttempts}</span>
|
||||
<span>{t("assignment.maxAttempts", { count: data.assignment.maxAttempts })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
School,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -20,14 +21,14 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const WEEKDAYS: Record<number, string> = {
|
||||
1: "Mon",
|
||||
2: "Tue",
|
||||
3: "Wed",
|
||||
4: "Thu",
|
||||
5: "Fri",
|
||||
6: "Sat",
|
||||
7: "Sun",
|
||||
const WEEKDAY_KEYS: Record<number, string> = {
|
||||
1: "mon",
|
||||
2: "tue",
|
||||
3: "wed",
|
||||
4: "thu",
|
||||
5: "fri",
|
||||
6: "sat",
|
||||
7: "sun",
|
||||
}
|
||||
|
||||
export default async function StudentClassDetailPage({
|
||||
@@ -39,6 +40,8 @@ export default async function StudentClassDetailPage({
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const [classInfo, schedule] = await Promise.all([
|
||||
getStudentClassById(student.id, classId),
|
||||
getStudentSchedule(student.id),
|
||||
@@ -58,14 +61,14 @@ export default async function StudentClassDetailPage({
|
||||
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-1">
|
||||
<Link href="/student/learning/courses">
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back to Courses
|
||||
{t("classDetail.backToCourses")}
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{classInfo.name}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Grade {classInfo.grade}
|
||||
{t("classDetail.grade", { grade: classInfo.grade })}
|
||||
</span>
|
||||
{classInfo.homeroom && (
|
||||
<>
|
||||
@@ -78,24 +81,24 @@ export default async function StudentClassDetailPage({
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Room {classInfo.room}
|
||||
{t("classDetail.room", { room: classInfo.room })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
<Badge variant="secondary">{t("classDetail.active")}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/student/schedule?classId=${encodeURIComponent(classInfo.id)}`}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
Full Schedule
|
||||
{t("classDetail.fullSchedule")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/student/learning/assignments">
|
||||
<PenTool className="mr-2 h-4 w-4" />
|
||||
Assignments
|
||||
{t("classDetail.assignments")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -107,7 +110,7 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<User className="h-4 w-4" />
|
||||
Teacher
|
||||
{t("classDetail.teacher")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
@@ -117,7 +120,7 @@ export default async function StudentClassDetailPage({
|
||||
<span className="font-medium">{classInfo.teacherName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No teacher assigned.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.noTeacher")}</p>
|
||||
)}
|
||||
{classInfo.teacherEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -138,7 +141,7 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<School className="h-4 w-4" />
|
||||
School
|
||||
{t("classDetail.school")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
@@ -148,12 +151,12 @@ export default async function StudentClassDetailPage({
|
||||
<span className="font-medium">{classInfo.schoolName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">School info not available.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.schoolNotAvailable")}</p>
|
||||
)}
|
||||
{classInfo.grade && (
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Grade {classInfo.grade}</span>
|
||||
<span>{t("classDetail.grade", { grade: classInfo.grade })}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -164,22 +167,22 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Classroom
|
||||
{t("classDetail.classroom")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{classInfo.room ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">Room {classInfo.room}</span>
|
||||
<span className="font-medium">{t("classDetail.room", { room: classInfo.room })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Room not assigned.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.roomNotAssigned")}</p>
|
||||
)}
|
||||
{classInfo.homeroom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Homeroom: {classInfo.homeroom}</span>
|
||||
<span>{t("classDetail.homeroom", { homeroom: classInfo.homeroom })}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -191,15 +194,15 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
Class Schedule
|
||||
{t("classDetail.classSchedule")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{classSchedule.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="No schedule"
|
||||
description="No timetable entries found for this class."
|
||||
title={t("classDetail.noSchedule")}
|
||||
description={t("classDetail.noScheduleDesc")}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
@@ -211,7 +214,7 @@ export default async function StudentClassDetailPage({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="w-12 justify-center">
|
||||
{WEEKDAYS[s.weekday]}
|
||||
{t(`weekdays.${WEEKDAY_KEYS[s.weekday]}`)}
|
||||
</Badge>
|
||||
<div>
|
||||
<p className="font-medium">{s.course}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -14,17 +15,18 @@ export default async function StudentCoursesPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("courses.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see courses."
|
||||
title={t("courses.noUser")}
|
||||
description={t("courses.noUserDesc")}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
@@ -51,8 +53,8 @@ export default async function StudentCoursesPage({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("courses.description")}</p>
|
||||
</div>
|
||||
{classes.length > 0 && <CourseFilters />}
|
||||
<StudentCoursesView classes={filteredClasses} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react"
|
||||
import { BookOpen, PenTool, Library, ArrowRight, UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
@@ -7,16 +8,16 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLearningPage() {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<EmptyState title="No user found" description="Create a student user to see learning." icon={UserX} />
|
||||
<EmptyState title={t("learning.noUser")} description={t("learning.noUserDesc")} icon={UserX} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,33 +41,33 @@ export default async function StudentLearningPage() {
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Courses",
|
||||
description: "Your enrolled classes.",
|
||||
title: t("learning.courses"),
|
||||
description: t("learning.coursesDesc"),
|
||||
icon: BookOpen,
|
||||
href: "/student/learning/courses",
|
||||
stat: `${classes.length} enrolled`,
|
||||
stat: t("learning.enrolled", { count: classes.length }),
|
||||
},
|
||||
{
|
||||
title: "Assignments",
|
||||
description: "Homework and practice.",
|
||||
title: t("learning.assignments"),
|
||||
description: t("learning.assignmentsDesc"),
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`,
|
||||
stat: t("learning.pending", { count: pendingCount }) + (dueSoonCount > 0 ? t("learning.dueSoon", { count: dueSoonCount }) : ""),
|
||||
},
|
||||
{
|
||||
title: "Textbooks",
|
||||
description: "Browse course materials.",
|
||||
title: t("learning.textbooks"),
|
||||
description: t("learning.textbooksDesc"),
|
||||
icon: Library,
|
||||
href: "/student/learning/textbooks",
|
||||
stat: `${textbooks.length} available`,
|
||||
stat: t("learning.available", { count: textbooks.length }),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Learning</h2>
|
||||
<p className="text-muted-foreground">Your learning hub: courses, assignments, and textbooks.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("learning.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("learning.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentLessonPlanViewError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentLessonPlanViewLoading() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLessonPlanViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ planId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const plan = await getLessonPlanById(planId, ctx.userId)
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notFound")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 学生只能查看已发布的课案
|
||||
if (plan.status !== "published") {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notPublished")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 拉取教材/章节标题
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanReadonlyView
|
||||
doc={plan.content}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/student/lesson-plans/error.tsx
Normal file
20
src/app/(dashboard)/student/lesson-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentLessonPlansError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/student/lesson-plans/loading.tsx
Normal file
17
src/app/(dashboard)/student/lesson-plans/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentLessonPlansLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(dashboard)/student/lesson-plans/page.tsx
Normal file
44
src/app/(dashboard)/student/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLessonPlansPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const [items, subjects] = await Promise.all([
|
||||
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("student.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("student.description")}</p>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList
|
||||
initialItems={items}
|
||||
subjects={subjects}
|
||||
viewMode="student"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/student/practice/[sessionId]/error.tsx
Normal file
32
src/app/(dashboard)/student/practice/[sessionId]/error.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export default function PracticeSessionError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): React.ReactNode {
|
||||
const t = useTranslations("student")
|
||||
useEffect(() => {
|
||||
console.error("Practice session error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<h2 className="text-xl font-semibold">{t("error.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset}>{t("error.retry")}</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/student/practice">{t("classDetail.backToCourses")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/app/(dashboard)/student/practice/[sessionId]/loading.tsx
Normal file
14
src/app/(dashboard)/student/practice/[sessionId]/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function PracticeSessionLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<Skeleton className="h-96 w-full rounded-md" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-10 w-24 rounded-md" />
|
||||
<Skeleton className="h-10 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/student/practice/[sessionId]/page.tsx
Normal file
31
src/app/(dashboard)/student/practice/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getPracticeSessionById } from "@/modules/adaptive-practice/data-access"
|
||||
import { PracticeSessionView } from "@/modules/adaptive-practice/components/practice-session-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function PracticeSessionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
const { sessionId } = await params
|
||||
|
||||
const session = await getPracticeSessionById(sessionId, ctx.userId)
|
||||
|
||||
if (!session) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<PracticeSessionView session={session} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/app/(dashboard)/student/practice/error.tsx
Normal file
26
src/app/(dashboard)/student/practice/error.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export default function PracticeError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): React.ReactNode {
|
||||
const t = useTranslations("student")
|
||||
useEffect(() => {
|
||||
console.error("Practice page error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<h2 className="text-xl font-semibold">{t("error.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<Button onClick={reset}>{t("error.retry")}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/app/(dashboard)/student/practice/loading.tsx
Normal file
25
src/app/(dashboard)/student/practice/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function PracticeLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Skeleton className="h-96 w-full rounded-md" />
|
||||
<div className="space-y-3 lg:col-span-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/student/practice/page.tsx
Normal file
45
src/app/(dashboard)/student/practice/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access"
|
||||
import { PracticeStarter } from "@/modules/adaptive-practice/components/practice-starter"
|
||||
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
|
||||
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
|
||||
import { getKnowledgePointOptions } from "@/modules/questions/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentPracticePage(): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
const t = await getTranslations("practice")
|
||||
|
||||
const [stats, sessionsResult, knowledgePoints] = await Promise.all([
|
||||
getPracticeStats(ctx.userId),
|
||||
getPracticeSessions(ctx.userId, { pageSize: 20 }),
|
||||
getKnowledgePointOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("page.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.description")}</p>
|
||||
</div>
|
||||
|
||||
<PracticeStatsCards stats={stats} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<PracticeStarter knowledgePoints={knowledgePoints} />
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("history.title")}</h2>
|
||||
<PracticeHistory sessions={sessionsResult.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -14,15 +15,20 @@ export default async function StudentSchedulePage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("schedule.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("schedule.description")}</p>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see schedule." icon={UserX} />
|
||||
<EmptyState
|
||||
title={t("schedule.noUser")}
|
||||
description={t("schedule.noUserDesc")}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,8 +47,8 @@ export default async function StudentSchedulePage({
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("schedule.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("schedule.description")}</p>
|
||||
</div>
|
||||
<StudentScheduleFilters classes={classes} />
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
getStudentErrorBookSummaries,
|
||||
getTopWrongQuestionsByStudentIds,
|
||||
getKnowledgePointWeakness,
|
||||
getSubjectErrorDistribution,
|
||||
getStudentNameMap,
|
||||
getSubjectErrorOverviews,
|
||||
getClassErrorOverviews,
|
||||
getChapterWeakness,
|
||||
} from "@/modules/error-book/data-access"
|
||||
import { ClassErrorBookOverview, StudentErrorTable } from "@/modules/error-book/components/class-error-overview"
|
||||
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
|
||||
import { ClassFilter } from "@/modules/error-book/components/class-filter"
|
||||
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
|
||||
import { ClassErrorBarChart } from "@/modules/error-book/components/class-error-bar-chart"
|
||||
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
|
||||
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
|
||||
import { GroupedStudentErrorTable } from "@/modules/error-book/components/grouped-student-error-table"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherErrorBookPage(): Promise<JSX.Element> {
|
||||
async function TeacherErrorBookContent({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_ANALYTICS_READ)
|
||||
|
||||
// 教师的 dataScope 为 class_taught,年级主任/教研组长为 grade_managed,管理员为 all
|
||||
const params = await searchParams
|
||||
const classIds = ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
|
||||
const gradeIds = ctx.dataScope.type === "grade_managed" ? ctx.dataScope.gradeIds : []
|
||||
const teacherSubjectIds = ctx.dataScope.type === "class_taught" ? (ctx.dataScope.subjectIds ?? []) : []
|
||||
|
||||
if (classIds.length === 0 && gradeIds.length === 0 && ctx.dataScope.type !== "all") {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<p className="text-muted-foreground">查看班级学生的错题统计与薄弱知识点。</p>
|
||||
<p className="text-muted-foreground">按学科、班级查看学生的错题统计与薄弱知识点。</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
@@ -42,21 +58,22 @@ export default async function TeacherErrorBookPage(): Promise<JSX.Element> {
|
||||
)
|
||||
}
|
||||
|
||||
// 年级主任/教研组长:先根据 gradeIds 查询班级,再查询学生
|
||||
// 年级主任/教研组长:展开年级为班级
|
||||
let targetClassIds = classIds
|
||||
if (gradeIds.length > 0) {
|
||||
const gradeClassIds = await getClassIdsByGradeIds(gradeIds)
|
||||
targetClassIds = [...new Set([...classIds, ...gradeClassIds])]
|
||||
}
|
||||
|
||||
const studentIds = await getStudentIdsByClassIds(targetClassIds)
|
||||
// 获取所有学生 ID(用于学科概览查询)
|
||||
const allStudentIds = await getStudentIdsByClassIds(targetClassIds)
|
||||
|
||||
if (studentIds.length === 0) {
|
||||
if (allStudentIds.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<p className="text-muted-foreground">查看班级学生的错题统计与薄弱知识点。</p>
|
||||
<p className="text-muted-foreground">按学科、班级查看学生的错题统计与薄弱知识点。</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
@@ -68,52 +85,168 @@ export default async function TeacherErrorBookPage(): Promise<JSX.Element> {
|
||||
)
|
||||
}
|
||||
|
||||
// 并行查询所有统计数据
|
||||
const [summaries, topWrongQuestions, weakKps, subjectDist, nameMap] = await Promise.all([
|
||||
getStudentErrorBookSummaries(studentIds),
|
||||
getTopWrongQuestionsByStudentIds(studentIds, 10),
|
||||
getKnowledgePointWeakness(studentIds, 10),
|
||||
getSubjectErrorDistribution(studentIds),
|
||||
getStudentNameMap(studentIds),
|
||||
// 解析 URL 参数:学科筛选 + 班级筛选
|
||||
const subjectParam = getParam(params, "subject")
|
||||
const classParam = getParam(params, "classId")
|
||||
|
||||
// 学科概览(用于 Tab 显示,不受学科筛选影响)
|
||||
const subjectOverviews = await getSubjectErrorOverviews(allStudentIds)
|
||||
|
||||
// 班级概览(用于班级筛选器显示,受学科筛选影响)
|
||||
const classOverviews = await getClassErrorOverviews(targetClassIds, subjectParam)
|
||||
|
||||
// 确定实际查询的学科和班级
|
||||
// 如果教师有所教学科,默认只显示所教学科;否则显示全部
|
||||
const effectiveSubjectId =
|
||||
subjectParam ?? (teacherSubjectIds.length === 1 ? teacherSubjectIds[0] : null)
|
||||
const effectiveClassId = classParam ?? "all"
|
||||
|
||||
// 确定查询的学生范围(按班级筛选)
|
||||
const queryClassIds = effectiveClassId === "all" ? targetClassIds : [effectiveClassId]
|
||||
const queryStudentIds = await getStudentIdsByClassIds(queryClassIds)
|
||||
|
||||
// 并行查询所有统计数据(按学科+班级过滤)
|
||||
const [summaries, topWrongQuestions, weakKps, chapterWeakness, nameMap] = await Promise.all([
|
||||
getStudentErrorBookSummaries(queryStudentIds, effectiveSubjectId),
|
||||
getTopWrongQuestionsByStudentIds(queryStudentIds, 10, effectiveSubjectId),
|
||||
getKnowledgePointWeakness(queryStudentIds, 10, effectiveSubjectId),
|
||||
getChapterWeakness(queryStudentIds, 10, effectiveSubjectId),
|
||||
getStudentNameMap(queryStudentIds),
|
||||
])
|
||||
|
||||
const studentsWithErrorBook = summaries.filter((s) => s.totalCount > 0)
|
||||
const totalErrorItems = summaries.reduce((sum, s) => sum + s.totalCount, 0)
|
||||
const totalDueReview = summaries.reduce((sum, s) => sum + s.dueReviewCount, 0)
|
||||
const averageMasteryRate = studentsWithErrorBook.length > 0
|
||||
? studentsWithErrorBook.reduce((sum, s) => sum + s.masteredRate, 0) / studentsWithErrorBook.length
|
||||
: 0
|
||||
const knowledgePointCount = weakKps.length
|
||||
|
||||
// 按错题数降序排列
|
||||
const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题分析</h1>
|
||||
<p className="text-muted-foreground">
|
||||
查看班级学生的错题统计与薄弱知识点,辅助精准教学。
|
||||
按学科、班级查看学生的错题统计与薄弱知识点,辅助精准教学。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ClassErrorBookOverview
|
||||
totalStudents={studentIds.length}
|
||||
{/* 学科 Tab */}
|
||||
{subjectOverviews.length > 0 ? (
|
||||
<Suspense fallback={<Skeleton className="h-10 w-full" />}>
|
||||
<SubjectTabs
|
||||
subjects={subjectOverviews}
|
||||
currentSubjectId={effectiveSubjectId}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{/* 班级筛选器 */}
|
||||
{classOverviews.length > 0 ? (
|
||||
<Suspense fallback={<Skeleton className="h-10 w-full" />}>
|
||||
<ClassFilter
|
||||
classes={classOverviews}
|
||||
currentClassId={effectiveClassId}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<AnalyticsStatsCards
|
||||
totalStudents={queryStudentIds.length}
|
||||
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||
totalErrorItems={totalErrorItems}
|
||||
averageMasteryRate={averageMasteryRate}
|
||||
topWeakKnowledgePoints={weakKps}
|
||||
subjectDistribution={subjectDist}
|
||||
dueReviewCount={totalDueReview}
|
||||
knowledgePointCount={knowledgePointCount}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">学生错题详情</h2>
|
||||
<StudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/teacher/error-book"
|
||||
/>
|
||||
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}
|
||||
{effectiveClassId === "all" && classOverviews.length > 1 ? (
|
||||
<ClassErrorBarChart data={classOverviews} />
|
||||
) : null}
|
||||
|
||||
{/* 章节错题分布 + 知识点薄弱度(并排) */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{chapterWeakness.length > 0 ? (
|
||||
<ChapterWeaknessChart data={chapterWeakness} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无章节错题数据"
|
||||
description="尚未关联知识点到章节,无法显示章节维度统计。"
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
{weakKps.length > 0 ? (
|
||||
<KnowledgePointWeaknessChart data={weakKps} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无知识点数据"
|
||||
description="错题尚未关联知识点,无法显示薄弱知识点统计。"
|
||||
className="h-[300px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
{/* 学生错题详情(按班级分组) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">学生错题详情</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
共 {queryStudentIds.length} 名学生,{studentsWithErrorBook.length} 名有错题
|
||||
</span>
|
||||
</div>
|
||||
{sortedSummaries.length > 0 ? (
|
||||
<GroupedStudentErrorTable
|
||||
students={sortedSummaries}
|
||||
studentNames={nameMap}
|
||||
basePath="/teacher/error-book"
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="暂无学生错题"
|
||||
description="所选范围内没有学生错题数据。"
|
||||
className="h-[200px] bg-card"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高频错题 Top 10 */}
|
||||
{topWrongQuestions.length > 0 ? (
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function TeacherErrorBookPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<Skeleton className="h-10 w-48" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px]" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TeacherErrorBookContent searchParams={searchParams} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getTeacherClasses, getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
||||
import { getExamsForGradeEntry, getExamForGradeEntry } from "@/modules/exams/data-access"
|
||||
import { BatchGradeEntryByExam } from "@/modules/grades/components/batch-grade-entry"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
@@ -19,63 +19,73 @@ export default async function BatchEntryPage({
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const sp = await searchParams
|
||||
|
||||
const defaultClassId = getParam(sp, "classId")
|
||||
const defaultSubjectId = getParam(sp, "subjectId")
|
||||
const examId = getParam(sp, "examId")
|
||||
const classId = getParam(sp, "classId")
|
||||
|
||||
// P3 修复:添加 scope 校验,对 class_taught scope 限制可录入的班级
|
||||
const [classes, allSubjects, students] = await Promise.all([
|
||||
// 获取试卷列表 + 班级列表
|
||||
const [exams, teacherClasses] = await Promise.all([
|
||||
getExamsForGradeEntry(ctx.dataScope),
|
||||
getTeacherClasses(),
|
||||
getSubjectOptions(),
|
||||
defaultClassId
|
||||
? getClassStudentsForEntry(defaultClassId, ctx.dataScope)
|
||||
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForEntry>>),
|
||||
])
|
||||
|
||||
// 对 class_taught scope,过滤掉不在 scope 中的班级
|
||||
// scope 过滤班级
|
||||
const allowedClassIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null
|
||||
const scopedClasses = allowedClassIds
|
||||
? classes.filter((c) => allowedClassIds.includes(c.id))
|
||||
: classes
|
||||
? teacherClasses.filter((c) => allowedClassIds.includes(c.id))
|
||||
: teacherClasses
|
||||
|
||||
const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
// 如果指定了 classId 但 scope 不允许,显示提示
|
||||
if (defaultClassId && students.length === 0 && scopedClasses.length > 0) {
|
||||
const classExists = scopedClasses.some((c) => c.id === defaultClassId)
|
||||
if (!classExists) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h1>
|
||||
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="无权访问该班级"
|
||||
description="您没有权限为该班级录入成绩。"
|
||||
icon={ClipboardList}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// 获取 classId → gradeId 映射(用于客户端按试卷年级过滤班级)
|
||||
const classGradeMap: Record<string, string> = {}
|
||||
if (scopedClasses.length > 0) {
|
||||
const gradeMap = await getClassGradeIdsByClassIds(
|
||||
scopedClasses.map((c) => c.id)
|
||||
)
|
||||
for (const [cid, gid] of gradeMap.entries()) {
|
||||
classGradeMap[cid] = gid
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 examId,获取试卷详情(含题目列表)
|
||||
const exam = examId
|
||||
? await getExamForGradeEntry(examId, ctx.dataScope)
|
||||
: null
|
||||
|
||||
// 如果有 examId + classId,获取学生列表
|
||||
const students =
|
||||
examId && classId
|
||||
? await getClassStudentsForEntry(classId, ctx.dataScope)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h1>
|
||||
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">批量录入成绩</h1>
|
||||
<p className="text-muted-foreground">
|
||||
从试卷库选择试卷,按每题得分录入,像填 Excel 表格一样。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BatchGradeEntry
|
||||
classes={classOptions}
|
||||
subjects={subjectOptions}
|
||||
students={students}
|
||||
defaultClassId={defaultClassId}
|
||||
defaultSubjectId={defaultSubjectId}
|
||||
/>
|
||||
{exams.length === 0 ? (
|
||||
<EmptyState
|
||||
title="没有可用的试卷"
|
||||
description="请先在试卷管理中创建试卷并添加题目,才能录入成绩。"
|
||||
icon={ClipboardList}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<BatchGradeEntryByExam
|
||||
exams={exams}
|
||||
classes={classOptions}
|
||||
classGradeMap={classGradeMap}
|
||||
exam={exam}
|
||||
students={students}
|
||||
defaultExamId={examId}
|
||||
defaultClassId={classId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function EditLessonPlanError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherEditLessonPlanLoading() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Suspense } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"
|
||||
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
@@ -82,26 +83,29 @@ export default async function EditLessonPlanPage({
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
classes={classes}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<LessonPlanProviderSetup>
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanEditor
|
||||
planId={plan.id}
|
||||
initialTitle={plan.title}
|
||||
initialDoc={plan.content}
|
||||
initialStatus={plan.status}
|
||||
textbookId={plan.textbookId ?? undefined}
|
||||
chapterId={plan.chapterId ?? undefined}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
classes={classes}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</LessonPlanProviderSetup>
|
||||
</AiClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
20
src/app/(dashboard)/teacher/lesson-plans/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function LessonPlansError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/app/(dashboard)/teacher/lesson-plans/loading.tsx
Normal file
25
src/app/(dashboard)/teacher/lesson-plans/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherLessonPlansLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Skeleton className="h-9 w-[240px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/teacher/lesson-plans/new/error.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/new/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function NewLessonPlanError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx
Normal file
20
src/app/(dashboard)/teacher/lesson-plans/new/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function TeacherNewLessonPlanLoading() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Skeleton className="h-9 w-[120px]" />
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
||||
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -22,20 +23,22 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
<LessonPlanProviderSetup>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplatePicker />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<TemplatePicker />
|
||||
</Suspense>
|
||||
</LessonPlanProviderSetup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -37,24 +38,26 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Skeleton className="h-9 w-[240px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
<LessonPlanProviderSetup>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Skeleton className="h-9 w-[240px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} />
|
||||
</Suspense>
|
||||
</LessonPlanProviderSetup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
27
src/app/(dashboard)/teacher/practice/error.tsx
Normal file
27
src/app/(dashboard)/teacher/practice/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function Error() {
|
||||
useEffect(() => {
|
||||
console.error("Practice analytics page error")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">专项练习分析</h1>
|
||||
<p className="text-muted-foreground">加载练习分析数据时发生错误</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="加载失败"
|
||||
description="请刷新页面重试,或联系管理员检查数据访问权限。"
|
||||
className="h-[360px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user