Compare commits

...

17 Commits

Author SHA1 Message Date
SpecialX
0c64219cb8 docs: add exam rich editor and photo grading design plan
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 1s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
- Add design plan for exam rich text editor and photo-based grading feature
2026-06-24 12:04:26 +08:00
SpecialX
1f833097e2 feat(shared): add errors lib, question-content, and update permissions and UI
- Add errors lib for standardized error handling

- Add question-content lib for question content processing

- Update action-utils, ai/provider-config, auth-guard, permissions, types/permissions

- Update UI sheet component

- Update proxy middleware
2026-06-24 12:04:09 +08:00
SpecialX
e3b8455b31 feat(i18n): add new i18n message files and update request config
- Add new i18n message files: audit, course-plans, files, leave, nav, practice, scheduling, student, users

- Update existing i18n messages: ai, attendance, common, diagnostic, elective, error-book, exam-homework, grades, lesson-preparation, school, settings

- Update i18n request config for new locale handling
2026-06-24 12:04:01 +08:00
SpecialX
37d2688a28 feat(app): add lesson-plans, practice, and grade dashboard routes
- Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes

- Add student/practice and teacher/practice routes for adaptive practice

- Add management/grade/dashboard and management/grade/practice routes

- Add teacher/lesson-plans error and loading boundaries

- Update existing admin, parent, student, teacher pages with new features

- Update globals.css and proxy middleware
2026-06-24 12:03:47 +08:00
SpecialX
8c2fe14c20 refactor(modules): update classes, course-plans, diagnostic, questions, settings, student, layout
- Update classes data-access (invitations, main) for invitation management

- Update course-plans actions, data-access, and types

- Update diagnostic data-access for report queries

- Update questions data-access for question bank queries

- Update settings actions, ai-provider-settings-card, data-access, and types

- Update student course-filters, student-courses-view, student-schedule-filters, student-schedule-view

- Update layout app-sidebar, site-header, and navigation config
2026-06-24 12:03:35 +08:00
SpecialX
c9e46f9f80 feat(school): add grade dashboard and insights filters
- Add grade-dashboard components directory for school-wide grade analytics

- Add grade-insights-filters component for filtering grade insights

- Update grades-view and data-access
2026-06-24 12:03:22 +08:00
SpecialX
f0f713ff33 feat(exams,homework): add error collection data-access for error book integration
- Add data-access-error-collection in exams module for collecting wrong exam answers

- Add data-access-error-collection in homework module for collecting wrong homework answers

- Update exams actions, exam-ai-generator, data-access, and types

- Update homework actions and data-access-write
2026-06-24 12:03:03 +08:00
SpecialX
0cee93676b feat(grades): add scope-check and update analytics
- Add scope-check lib for grade data access scope validation

- Update actions, actions-analytics, data-access, data-access-analytics

- Update batch-grade-entry, schema, and types
2026-06-24 12:02:50 +08:00
SpecialX
6bc113eaff feat(lesson-preparation): add readonly view, anchor node selector, and type guards
- Add lesson-plan-readonly-view for viewing published plans

- Add anchor-node-selector and textbook-segments for canvas anchor positioning

- Add i18n-errors and type-guards lib utilities

- Add lesson-plan-provider-setup for provider initialization

- Update actions, data-access (knowledge, versions, main), publish-service

- Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection)

- Update editor, node-editor, node-edit-panel, pickers, and providers
2026-06-24 12:02:42 +08:00
SpecialX
a48e7d0e27 feat(ai): add chart renderer, floating ball hook, and provider updates
- Add ai-chart-renderer for rendering charts in AI responses

- Add use-floating-ball hook for draggable AI assistant widget

- Update ai-assistant-widget, ai-chat-panel, ai-markdown-renderer, ai-provider-selector

- Update use-ai-chat-stream hook and prompt-templates service
2026-06-24 12:02:29 +08:00
SpecialX
61e76f0d67 feat(error-book): add analytics stats, charts, and error collection
- Add analytics-stats-cards, chapter-weakness-chart, class-error-bar-chart

- Add knowledge-point-weakness-chart, subject-distribution-chart, subject-tabs

- Add class-filter and grouped-student-error-table components

- Add data-access-collection for error aggregation from multiple sources

- Update error-book-detail-dialog, data-access, and types
2026-06-24 12:02:16 +08:00
SpecialX
d7876c5854 feat(adaptive-practice): add new adaptive practice module
- Add adaptive practice module with data-access, schema, types, and components

- Provides personalized practice based on student performance and error patterns
2026-06-24 12:02:04 +08:00
SpecialX
9783be58c0 feat(scripts): add diagnostic, seed, and test scripts
- Add add-ai-provider-visibility and add-missing-columns migration scripts

- Add clear-error-book, seed-error-book, diagnose-error-book scripts

- Add diagnose-tables and create-missing-tables scripts

- Add test-failing-modules and test-teacher-pages test scripts
2026-06-24 12:01:54 +08:00
SpecialX
e4254f0f8e docs: update architecture map and add lesson-preparation usage fixes design
- Update architecture impact map (004) and data (005) with new modules

- Add lesson-preparation usage fixes design spec

- Add teacher web test post-audit report
2026-06-24 12:01:35 +08:00
SpecialX
9d87388524 feat(db): add grade_record_answers migration and update schema
- Add migration 0010 for grade_record_answers table

- Update shared DB schema with new table definitions
2026-06-24 12:01:26 +08:00
SpecialX
eb28a523cb chore(config): update ESLint config and dependencies
- Update eslint.config.mjs rules

- Update package.json and package-lock.json dependencies
2026-06-24 12:01:09 +08:00
SpecialX
7e320d78c1 feat(ai): 统一 AI 配置入口到 /admin/ai-settings
## 新增
- 创建 /admin/ai-settings 统一配置页(AiProviderSettingsCard + AiUsageDashboard)
- admin 侧边栏新增"AI 配置"菜单项(权限 AI_CONFIGURE,图标 Sparkles)
- 新增 deleteAiProvider 数据访问层(事务删除 + 自动转移默认)
- 新增 deleteAiProviderAction Server Action(Zod 校验 + 权限校验)
- AiProviderSettingsCard 新增删除按钮(AlertDialog 确认 + destructive 变体)
- 新增 i18n 翻译键(delete/deleteConfirm/deleteSuccess 等,zh-CN + en)

## 移除
- 从 /settings 移除 AI 标签页(原 VALID_TABS 含 "ai",现仅 4 标签页)
- 从考试页面移除 AI 配置弹窗(Dialog + AiProviderSettingsCard 内嵌)
- 从 ai-provider-selector.tsx 移除配置弹窗(managePanel/manageOpen props)
- 移除 settings-view.tsx 中 canConfigureAi 逻辑和未使用 import

## 变更
- 考试页面"管理"按钮改为 Link 跳转到 /admin/ai-settings
- ai-provider-selector.tsx"管理"按钮改为 Link 跳转到 /admin/ai-settings
- exam-form.tsx 移除 providerDialogOpen/providerDialogKey 状态
- 修正架构文档 004 中 Action 命名(getAiProvidersAction → getAiProviderSummaries 等)

## 架构文档同步
- 004 更新 settings 模块章节(V3 标记/修正 Action 名称/新增 deleteAiProvider)
- 005 新增 deleteAiProviderAction 节点 + /admin/ai-settings 路由
2026-06-23 19:33:28 +08:00
272 changed files with 24484 additions and 3489 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -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 新增年级仪表盘维度3exams 表有直接 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-serviceV3-8 新增):`getExamAnalytics`cache 包装,聚合考试所有作业的已批改提交,计算平均分/及格率/分数段分布/逐题错误率与难度等级,对标智学网考试分析)+ `ExamAnalyticsSummary` 类型
- Types✅ 2026-06-24 新增成绩录入相关类型):`ExamQuestionItem`(试卷中单个题目的精简结构 { id, order, score, type }/ `ExamForGradeEntry`(成绩录入用的试卷详情,含题目列表)/ `ExamOptionForEntry`(成绩录入页试卷选择器选项 { id, title, subjectName, gradeName, questionCount, totalScore }
- ComponentsV3-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-accessP0-4 已修复)、`proctoring`(✅ P1-1 已修复:通过 exams data-access`diagnostic`(✅ P1-1 已修复:通过 exams data-access
- 被依赖:`homework`(通过 sourceExamId 外键,合理)、`dashboard`(通过 data-accessP0-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 ActionP1-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 新增 getExamsByGradeId2026-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 新增:成绩录入草稿 CRUDupsert + 24 小时过期)/ `getExamOptionsForGrades` / `getSchoolWideGradeSummary`(✅ v3-P2 新增:考试选项查询 + 全校各年级成绩汇总,管理员视图按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总)
- Types✅ v3-P2 新增`SchoolWideGradeSummaryItem`全校汇总按年级聚合项gradeId/gradeName/schoolName/classCount/studentCount/averageScore/passRate/excellentRate/recordCount/ `SchoolWideGradeSummary`全校汇总grades 数组 + totals 汇总对象)/ `GradeDraftData`(草稿数据接口:{ scores: Record<string, string>, timestamp: number },位于 data-access.ts
- 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 新增:成绩录入草稿 CRUDupsert + 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/deleteGradeDraftActionv4-P1-6createGradeRecordAction/batchCreateGradeRecordsAction 新增通知v4-P1-12exportGradesAction 新增 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/deleteGradeDraftActionv4-P1-6createGradeRecordAction/batchCreateGradeRecordsAction 新增通知v4-P1-12exportGradesAction 新增 studentId 参数v3-P3-2 新增bulkDeleteGradeRecordsAction 批量删除v4-P2-6assertClassInScope 迁移至 lib/scope-check.ts2026-06-24 新增batchCreateGradeRecordsByExamAction 按试卷录入每题得分 |
| `actions-analytics.ts` | 170 | 5 个分析 Action含 Zod 校验P3 修复handleActionError + assertClassInScope 校验v4-P2-6assertClassInScope 改从 ./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 应用 buildScopeClassFilterv3-P2 新增getExamOptionsForGrades/getSchoolWideGradeSummarygetGradeTrend/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-10createDefaultBuckets 改为内部函数P3-24buildGradeTrendPoints 使用 isGradeTrendType 类型守卫替代 as 断言) |
| `export.ts` | 290+ | Excel 导出v2-P1-5 修复:传递 currentUserId 到 data-accessP3 修复:适配 PaginatedGradeRecords 结构 + 传递 scopeP3-6复用 stats-service.computeAverageScore 替代局部 avgP3-7硬编码中文改用 next-intl getTranslationsv4-P1-12 新增exportStudentGradeRecordsToExcel 家长视角单学生导出) |
| `schema.ts` | 113+ | Zod 校验(含 12 个查询 schemaP3 修复score .max(1000) + records .max(500) + 补全查询字段v3-P2 新增grade_drafts 表定义第 1444-1469 行) |
| `schema.ts` | 130+ | Zod 校验(含 12 个查询 schemaP3 修复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/normalizeP3-26buildScopeClassFilter 迁移至 scope-filter.ts |
| `lib/scope-filter.ts` | 56 | DB 行级权限过滤buildScopeClassFilterP3-26 从 grade-utils.ts 迁移v2-P2-2 修复:改用 classes data-access 子查询P3 修复:新增 currentUserId 参数) |
| `types.ts` | 168+ | 类型定义v3-P2 新增:SchoolWideGradeSummaryItem/SchoolWideGradeSummary |
| `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/SchoolWideGradeSummary2026-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-4i18nv3-P3-5 新增:显著性分析区域,基于极差和样本量的经验规则判断班级间差异,含可折叠详细分析) |
| `components/grade-trend-chart.tsx` | 59 | 趋势图v2-P1-4i18n |
| `components/grade-record-form.tsx` | 177 | 录入表单v2-P2-7 修复Label htmlForv2-P1-4i18nP3 修复safeActionCall 包装提交) |
| `components/batch-grade-entry.tsx` | 500+ | 批量录入(v2-P2-7 修复:Label htmlForv2-P1-4i18nP3 修复:safeActionCall + localStorage 安全检查 + 区分未录入与录入 0v3-P2 新增:接入服务端草稿 saveGradeDraftAction/getGradeDraftAction/deleteGradeDraftActionv3-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 htmlForv2-P1-4 i18nP3 safeActionCall + localStorage 安全检查v3-P2 服务端草稿v3-P3-1 下载 CSV 模板v4-P3-2 新手引导均已替换 |
| `components/grade-filters.tsx` | 76 | 过滤器v2-P1-4i18n |
| `components/student-grade-summary.tsx` | 107 | 学生成绩摘要v2-P1-4i18n |
| `components/export-button.tsx` | 79 | 导出按钮v2-P1-4i18nP3 修复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 BoundaryP1-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 | 学校数据管理 hookP2-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 新增:管理员系统设置 CRUD4 分类 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 选择器 + 可见性/归属 BadgeisAdmin 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 节点V3ctx 新增 `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-23V3 多角色课案查看):
> - **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 FlownodesDraggable=false, nodesConnectable=false供学生/家长/管理员/教研组长查看已发布课案
> - **V3-3 多角色视图**`LessonPlanCard` 新增 viewMode propteacher/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)` 预解析 gradeIdsparent 通过孩子的 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-plansstudent 导航新增「我的课案」(/student/lesson-plansparent 导航新增「孩子课案」(/parent/lesson-plans
> 架构变更2026-06-24V3 深度审计修复):
> - **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-24V3 深度审计修复续):
> - **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→v2migrateV1ToV2/ v2→v3migrateV2ToV3/ 规范化normalizeDocument兼容 v1/v2/v3/ 初始内容buildInitialContent/ 默认骨架buildDefaultSkeleton10 节点 + 正文节点)/ 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 注册表 + BlockRendererswitch 渲染 11 种定制节点 + textbook_content |
| `providers/lesson-plan-provider.tsx` | **Provider + ContextP1-5/P1-7/P2-4/V2-6**LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置TEACHER/ADMIN/STUDENT/PARENT、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTrackerhooksuseLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafeV2-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-3V2-1抛出 `LessonPlanDataError` 错误码V2-3mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate 显式映射 + isLessonPlanStatus/isTemplateType/isTemplateScope 类型守卫) |
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理V2-3mapRowToVersion 显式映射 |
| `data-access-templates.ts` | 个人模板 CRUDV2-3mapRowToTemplate 显式映射 + 类型守卫 |
| `data-access-knowledge.ts` | 按知识点/题目反查课案V2-3显式字段映射替代 `as unknown as` |
| `actions.ts` | 课案 CRUD/版本/模板 Server ActionsV2-1getTranslations i18n + 错误码捕获V2-2createLessonPlanAction 传入 translateTitle 翻译 SYSTEM_TEMPLATES |
| `actions-publish.ts` | 发布作业 Server ActionV2-1getTranslations i18n + PUBLISH_ERROR_KEY_MAP 错误码映射 |
| `actions-ai.ts` | AI 知识点建议 Server ActionV2-1i18n + 错误码) |
| `actions-kp.ts` | 知识点选项 Server ActionV2-1i18n + 错误码) |
| `publish-service.ts` | 发布作业服务(编排 homework/exams/classes通过对方 data-access 调用无直查跨模块表V2-1抛出 `PublishServiceError` 错误码V2-3显式字段映射替代 `as unknown as` |
| `lib/rf-mappers.ts` | **纯函数**toRfNodesV3移除冗余 `as` 断言TypeScript 判别联合自动收窄)/ toRfEdges / fromRfEdges |
| `config/block-registry.tsx` | **配置驱动**BLOCK_REGISTRY 注册表 + BlockRendererV3使用 `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 接口 + noopTrackerhooksuseLessonPlanContextSafe/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker/useLessonPlanTrackerSafe |
| `providers/lesson-plan-provider-setup.tsx` | **页面层 Provider 设置包装组件V3 续新增)**LessonPlanProviderSetup 自动注入默认数据服务createDefaultDataService和角色配置TEACHER_ROLE_CONFIG3 个 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 ActionsV3所有 action 使用 `translateFieldErrors` 翻译 Zod 错误;`duplicateLessonPlanAction` 传入 i18n 翻译的副本后缀;`getLessonPlanByIdAction` 返回类型改为 `ActionState<{ plan: LessonPlan }>` |
| `actions-publish.ts` | 发布作业 Server ActionV3actions 层注入 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-6duplicate/archive 调用 tracker.track |
| `components/lesson-plan-filters.tsx` | 课案筛选器i18n 已接入V2-53 个表单元素 label htmlFor 关联) |
| `components/lesson-plan-editor.tsx` | 课案编辑器(编排 NodeEditor + NodeEditPaneli18n 已接入V2-6handleManualSave 调用 tracker.trackV3顶部工具栏显示教材/章节标题指示器 |
| `components/node-editor.tsx` | **节点图画布**React Flow使用 lib/rf-mappers + lib/node-summary 纯函数i18n 已接入V2-4MiniMap 用 getNodeColorV2-5role=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/getNodeColori18n 已接入 |
| `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` | 课案编辑器 HookV3`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` | **只读画布组件**V3MiniMap 使`getNodeColor` 替代硬编码颜色 |
| `components/node-editor.tsx` | **节点图画布**V3MiniMap `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-6create 调用 tracker.track |
| `components/version-history-drawer.tsx` | 版本历史抽屉(i18n 已接入V2-6revert 调用 tracker.track |
| `components/knowledge-point-picker.tsx` | 知识点选择器(i18n 已接入 |
| `components/question-bank-picker.tsx` | 题库选择器(i18n 已接入 |
| `components/inline-question-editor.tsx` | 内联题目编辑器i18n 已接入V2-5type/difficulty select label htmlFor 关联) |
| `components/publish-homework-dialog.tsx` | 发布作业对话框(i18n 已接入V2-6publish 调用 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.reloadi18n 已接入V2-5purpose 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` | 教学目标 BlockV3 续select onChange 使用 `isObjectiveDimension` 类型守卫替代 `as` 断言 |
| `components/blocks/key-point-block.tsx` | 重难点 BlockV3 续select onChange 使用 `isKeyPointType` 类型守卫替代 `as` 断言 |
| `components/blocks/import-block.tsx` | 导入 BlockV3 续select onChange 使用 `isImportMethod` 类型守卫替代 `as` 断言) |
| `components/blocks/new-teaching-block.tsx` | 新授 Block |
| `components/blocks/summary-block.tsx` | 总结 Block |
| `components/blocks/homework-block.tsx` | 作业 BlockV3 续select onChange 使用 `isHomeworkType` 类型守卫替代 `as` 断言) |
| `components/blocks/blackboard-block.tsx` | 板书 BlockV3 续select onChange 使用 `isBlackboardLayout` 类型守卫替代 `as` 断言) |
| `components/blocks/exercise-block.tsx` | 练习 BlockV3 续select onChange 使用 `isExercisePurpose` 类型守卫替代 `as` 断言) |
| `components/blocks/reflection-block.tsx` | 反思 BlockV3 续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 | 班级错题数对比柱状图rechartstooltip 显示学生数/人均/掌握率) |
| `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

View File

@@ -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 课案
- 教研组长可查看本年级教师备课
- 锚点交互完整可用(选中文本 → 选择节点 → 创建锚点)
- 锚点边颜色与关联节点一致

View 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`);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View 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);
});

View 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);
});

View 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)
})

View 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);
});

View 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)
})

View 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
View 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)
})

View 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()

View 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()

View 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>
)
}

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}

View File

@@ -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
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
{!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>
{/* 错题最多的学生 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>
)
}

View File

@@ -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"

View File

@@ -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}</>
}

View 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 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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,24 +132,26 @@ 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">
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<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>
<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>
</TableHeader>
<TableBody>
@@ -188,27 +174,30 @@ export default async function AdminGradeInsightsPage({
</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">
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<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>
<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>
</TableHeader>
<TableBody>
@@ -230,6 +219,7 @@ export default async function AdminGradeInsightsPage({
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -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>
)

View File

@@ -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"

View File

@@ -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>

View File

@@ -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"

View 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>
)
}

View 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>
)
}

View File

@@ -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,24 +130,26 @@ 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">
{/* v4-P1-11: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<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>
<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>
</TableHeader>
<TableBody>
@@ -181,34 +165,37 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
<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>
<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">
{/* v4-P1-11: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<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>
<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>
</TableHeader>
<TableBody>
@@ -219,15 +206,16 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
{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>
<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>
</div>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>
)

View File

@@ -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 }) => (
<>

View File

@@ -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>

View 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 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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} />

View File

@@ -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"

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 }}
/>
)
}

View File

@@ -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"

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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">

View 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 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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">
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}
{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>
{/* 学生错题详情(按班级分组) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<StudentErrorTable
<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>
)
}

View File

@@ -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) {
// 获取 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>
{exams.length === 0 ? (
<EmptyState
title="无权访问该班级"
description="您没有权限为该班级录入成绩。"
title="没有可用的试卷"
description="请先在试卷管理中创建试卷并添加题目,才能录入成绩。"
icon={ClipboardList}
className="border-none shadow-none"
/>
</div>
)
}
}
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>
<BatchGradeEntry
) : (
<BatchGradeEntryByExam
exams={exams}
classes={classOptions}
subjects={subjectOptions}
classGradeMap={classGradeMap}
exam={exam}
students={students}
defaultClassId={defaultClassId}
defaultSubjectId={defaultSubjectId}
defaultExamId={examId}
defaultClassId={classId}
/>
)}
</div>
)
}

View 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 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,6 +83,7 @@ export default async function EditLessonPlanPage({
return (
<AiClientProvider service={aiClientService}>
<LessonPlanProviderSetup>
<div className="h-[calc(100vh-4rem)]">
<Suspense
fallback={
@@ -94,6 +96,7 @@ export default async function EditLessonPlanPage({
planId={plan.id}
initialTitle={plan.title}
initialDoc={plan.content}
initialStatus={plan.status}
textbookId={plan.textbookId ?? undefined}
chapterId={plan.chapterId ?? undefined}
textbookTitle={textbookTitle}
@@ -102,6 +105,7 @@ export default async function EditLessonPlanPage({
/>
</Suspense>
</div>
</LessonPlanProviderSetup>
</AiClientProvider>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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,6 +23,7 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
</Button>
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
</div>
<LessonPlanProviderSetup>
<Suspense
fallback={
<div className="max-w-3xl mx-auto p-6 space-y-6">
@@ -36,6 +38,7 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
>
<TemplatePicker />
</Suspense>
</LessonPlanProviderSetup>
</div>
)
}

View File

@@ -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,6 +38,7 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
</Link>
</Button>
</div>
<LessonPlanProviderSetup>
<Suspense
fallback={
<div className="space-y-4">
@@ -55,6 +57,7 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
>
<LessonPlanList initialItems={items} subjects={subjects} />
</Suspense>
</LessonPlanProviderSetup>
</div>
)
}

View 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