From e2e0487a3b98cb989ddeafe3c743b5427b826403 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:02:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(attendance,elective):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=89=80=E6=9C=89=20P2=20=E9=95=BF=E6=9C=9F=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 修复(来自审计报告): - 2.4.4: Server Action 错误消息 i18n 化(attendance/elective 全部 Action) - 2.5.3: 抽取 AttendancePageLayout 组件复用(admin/teacher 页面) - 2.5.4: 抽取 ElectivePageLayout 组件复用(admin/teacher 列表页) - 2.6.3: 考勤月历键盘导航(tabIndex + 方向键 + Home/End + role=grid) - 2.8.2: getStudentAttendanceSummary 分页优化(SQL 聚合统计 + LIMIT 分页) - 2.8.3: resolveCourseDisplayNames 缓存优化(React cache 去重) - 2.1.4: elective data-access 跨模块依赖接口抽象(resolvers.ts 可注入) P2 建议项: - 选课时间冲突检测(parseSchedule + isScheduleConflict 纯函数 + checkScheduleConflict) - 学分上限校验(MAX_CREDIT_PER_TERM + checkCreditLimit) - 考勤/选课数据导出 Excel(export.ts + API 路由扩展) 新增文件: - src/modules/attendance/components/attendance-page-layout.tsx - src/modules/elective/components/elective-page-layout.tsx - src/modules/elective/resolvers.ts - src/modules/attendance/export.ts - src/modules/elective/export.ts 校验: - npm run lint 通过(exit 0) - npx tsc --noEmit attendance/elective/parent 相关零错误 --- .../004_architecture_impact_map.md | 34 +++- docs/architecture/005_architecture_data.json | 188 +++++++++++++++--- src/app/(dashboard)/admin/attendance/page.tsx | 39 ++-- src/app/(dashboard)/admin/elective/page.tsx | 20 +- .../(dashboard)/teacher/attendance/page.tsx | 52 ++--- src/app/(dashboard)/teacher/elective/page.tsx | 21 +- src/app/api/export/route.ts | 79 +++++++- src/modules/attendance/actions.ts | 69 ++++--- .../components/attendance-page-layout.tsx | 38 ++++ src/modules/attendance/data-access-stats.ts | 78 +++++++- src/modules/attendance/export.ts | 90 +++++++++ src/modules/elective/actions.ts | 73 +++---- .../components/elective-page-layout.tsx | 30 +++ .../elective/data-access-operations.ts | 126 ++++++++++++ .../elective/data-access-selections.ts | 8 +- src/modules/elective/data-access.ts | 34 ++-- src/modules/elective/export.ts | 102 ++++++++++ src/modules/elective/resolvers.ts | 83 ++++++++ .../components/parent-attendance-calendar.tsx | 77 ++++++- src/modules/textbooks/actions.ts | 16 +- .../components/chapter-sidebar-list.tsx | 10 +- .../components/create-chapter-dialog.tsx | 1 + .../textbooks/components/graph-kp-node.tsx | 4 +- .../components/graph-node-detail-panel.tsx | 1 - .../textbooks/components/graph-toolbar.tsx | 10 +- .../textbooks/components/knowledge-graph.tsx | 9 +- .../components/knowledge-point-dialogs.tsx | 1 + .../components/knowledge-point-list.tsx | 13 +- .../components/section-error-boundary.tsx | 27 ++- .../components/teacher-textbook-reader.tsx | 50 ----- .../textbooks/components/textbook-card.tsx | 1 + .../components/textbook-content-panel.tsx | 39 ++-- .../textbooks/components/textbook-filters.tsx | 2 + .../components/textbook-form-dialog.tsx | 1 + .../textbooks/components/textbook-reader.tsx | 23 ++- .../components/textbook-settings-dialog.tsx | 1 + src/modules/textbooks/data-access-graph.ts | 8 +- src/modules/textbooks/graph-layout.test.ts | 64 ++++++ src/modules/textbooks/hooks/use-graph-data.ts | 19 +- .../hooks/use-knowledge-point-actions.ts | 140 +++---------- src/modules/textbooks/hooks/use-kp-crud.ts | 122 ++++++++++++ .../textbooks/hooks/use-kp-dialog-state.ts | 38 ++++ src/modules/textbooks/schema.ts | 3 +- src/modules/textbooks/utils.test.ts | 19 ++ src/shared/i18n/messages/en/attendance.json | 12 ++ src/shared/i18n/messages/en/elective.json | 12 ++ src/shared/i18n/messages/en/textbooks.json | 7 +- .../i18n/messages/zh-CN/attendance.json | 12 ++ src/shared/i18n/messages/zh-CN/elective.json | 12 ++ src/shared/i18n/messages/zh-CN/textbooks.json | 7 +- 50 files changed, 1514 insertions(+), 411 deletions(-) create mode 100644 src/modules/attendance/components/attendance-page-layout.tsx create mode 100644 src/modules/attendance/export.ts create mode 100644 src/modules/elective/components/elective-page-layout.tsx create mode 100644 src/modules/elective/export.ts create mode 100644 src/modules/elective/resolvers.ts delete mode 100644 src/modules/textbooks/components/teacher-textbook-reader.tsx create mode 100644 src/modules/textbooks/hooks/use-kp-crud.ts create mode 100644 src/modules/textbooks/hooks/use-kp-dialog-state.ts diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 428de86..77e0c5d 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -451,7 +451,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | **表单字段** | `TextareaField` | `components/form-fields/textarea-field.tsx` | 通用多行文本字段(FormField + Textarea 包装) | 1 个(P1-2: create-question-dialog) | | **图表组件** | `ChartCardShell` | `components/charts/chart-card-shell.tsx` | 图表卡片外壳(Card+Header+EmptyState+Content 统一结构) | 8 个(P3-c) | | **图表组件** | `TrendLineChart` | `components/charts/trend-line-chart.tsx` | 趋势折线图(LineChart 统一配置,支持单/多系列) | 8 个(P3-c: grade-trend-chart 等) | -| **图表组件** | `SimpleBarChart` | `components/charts/simple-bar-chart.tsx` | 柱状图(BarChart 统一配置,支持单/多 Bar + Cell 分桶着色) | 8 个(P3-c: grade-distribution-chart 等) | +| **图表组件** | `SimpleBarChart` | `components/charts/simple-bar-chart.tsx` | 柱状图(BarChart 统一配置,支持单/多 Bar + Cell 分桶着色 + defs 自定义 SVG 图案) | 8 个(P3-c: grade-distribution-chart 等) | | **图表组件** | `ComparisonRadarChart` | `components/charts/comparison-radar-chart.tsx` | 对比雷达图(RadarChart 统一配置,支持双 Radar 对比) | 8 个(P3-c: subject-comparison-chart, mastery-radar-chart 等) | | **课表组件** | `ScheduleList` / `ScheduleListItem` | `components/schedule/schedule-list.tsx` | 课表列表+列表项(课程+时间+地点+班级徽章,separator/card 两种变体) | 3 个(P3-a: student-today-schedule-card, child-schedule-card, student-schedule-view) | | **题库组件** | `QuestionBankFilters` | `components/question/question-bank-filters.tsx` | 题库筛选栏(搜索+题型+难度,default/compact 两种布局) | 2 个(P3-d: exam-assembly, question-bank-picker) | @@ -766,6 +766,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ v3-P2 改进(2026-06-23):`getGradeTrend`/`getGradeDistribution`/`getSubjectComparison`/`getClassComparison` 均新增 `semester` 和 `examId` 可选参数,支持按学期和考试筛选分析 - ✅ v3-P2 改进(2026-06-23):新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总);admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard - ✅ v3-P2 改进(2026-06-23):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard +- ✅ v3-P3-4 改进(2026-06-23):GradeTrendCard 新增日期范围选择器(全部/近7天/近30天/近90天),通过 nuqs `trendRange` URL 参数持久化,useEffect 中计算截止时间戳避免渲染阶段调用 Date.now() - ✅ P3 修复(2026-06-23):~~`lib/grade-utils.ts` 72 行超 40 行工具函数上限~~ P3-26 将 `buildScopeClassFilter` 迁移至 `lib/scope-filter.ts`,grade-utils.ts 仅保留 toNumber/normalize(20 行) - ✅ P3 修复(2026-06-23):~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数 - ✅ P3 修复(2026-06-23):~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言 @@ -1111,7 +1112,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ V2-P1-2 已修复:~~MessageList 客户端过滤冗余~~ 客户端过滤仅在初始数据(type=all)时执行,搜索结果已由服务端按 tab 过滤 - ✅ V2-P1-3 已修复:~~消息详情页分散编排~~ 新增 `getMessageDetailPageData` 编排函数,替代 page.tsx 中 `after()` + `getMessageById` + `markMessageAsRead` 的分散编排 - ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `message-compose.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(receiverId/subject/content) -- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(60_000ms) +- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(~~60_000ms~~ → 30_000ms,✅ V2-P3 与通知组件保持一致) **文件清单**: | 文件 | 行数 | 职责 | @@ -1128,7 +1129,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `components/message-list.tsx` | 消息列表(✅ P1-7:使用 `useMessageSearch` hook + 客户端分页 UI,PAGE_SIZE=20;✅ V2-P1-2:客户端过滤仅在初始数据时执行) | | `components/message-detail.tsx` | 消息详情(含回复) | | `components/message-compose.tsx` | 撰写新消息(✅ V2-P1-4:fieldErrors + aria-invalid 字段级错误展示) | -| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 60 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1:POLL_INTERVAL_MS 常量) | +| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 30 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1:POLL_INTERVAL_MS 常量;✅ V2-P3:间隔从 60s 缩短为 30s 与通知一致) | **客户端行为**: - `message-list.tsx`:客户端调用 `getMessagesAction` 搜索消息(useMessageSearch hook,400ms 防抖,请求竞态取消);V2-P1-2 优化:客户端过滤仅在初始数据(type=all)时执行 @@ -1147,6 +1148,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移) - Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender` - Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移) +- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook) **依赖关系**: - 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId) @@ -1160,6 +1162,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P2-11 已修复:~~通知标记已读无埋点~~ `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` 新增 `trackEvent` 埋点(notification.marked_read / notification.marked_all_read) - ✅ V2-P0-1 已修复:~~通知 i18n 键混在 messages.json 中~~ 新增独立的 `notifications.json` 命名空间(zh-CN/en),通知组件 `useTranslations` 从 `"messages"` 切换到 `"notifications"`;`src/i18n/request.ts` 新增 notifications 命名空间加载 - ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(30_000ms) +- ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 Server Actions,间隔 30 秒) - ⚠️ P1:发送日志仅 console,无 `notification_logs` 表 - ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现) @@ -1174,16 +1177,18 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `index.ts` | ~75 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出) | | `channels/*` | 5 文件 | 4 个渠道实现 | | `components/notification-list.tsx` | ~140 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件 | -| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件 | +| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级 | +| `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级) | **组件清单**: | 组件 | 职责 | |------|------| | `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications") | -| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,每 30 秒轮询 `getNotificationsAction` + `getUnreadNotificationCountAction`;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) | +| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) | **客户端行为**: -- `notification-dropdown.tsx`:每 `POLL_INTERVAL_MS`(30_000ms)轮询 `getNotificationsAction`(pageSize=10)和 `getUnreadNotificationCountAction` 刷新通知和未读计数 +- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒) +- `hooks/use-notification-stream.ts`:✅ V2-P3 新增,管理 SSE 连接生命周期(EventSource onopen/onmessage/onerror),降级时通过 `pollFnRef` 调用 Server Actions 轮询 --- @@ -1509,13 +1514,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **导出函数**: - Actions:`generateStudentReportAction` / `generateClassReportAction` / `publishReportAction` / `deleteReportAction`(v2-P2-3 修复:删除死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction`,页面直接调用 data-access 并自行权限校验) -- Data-access:`updateMasteryFromSubmission`(v2-P1-8 修复:累积模式;v2-P2-5 修复:db.transaction 包裹)/ `getStudentMastery` / `getStudentMasterySummary` / `getClassMasterySummary`(v2-P2-4 修复:totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`(v2-P1-7 修复:页面先查班级再传参) -- Data-access-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据)/ `getDiagnosticReports` / `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization) -- Stats-service(✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses` / `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数) +- Data-access:`updateMasteryFromSubmission`(v2-P1-8 修复:累积模式;v2-P2-5 修复:db.transaction 包裹)/ `getStudentMastery`(P3-19 修复:移除 export,改为模块内部函数)/ `getStudentMasterySummary`(P3-18 修复:getUserNamesByIds 与 getStudentMastery 并行查询)/ `getClassMasterySummary`(v2-P2-4 修复:totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`(v2-P1-7 修复:页面先查班级再传参) +- Data-access-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据;P3-27 修复:使用 DiagnosticReportError 结构化错误码)/ `getDiagnosticReports`(P3-15 修复:支持分页 limit/offset,返回 { reports, total } 结构)/ `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization;P3-1 修复:toNumber 从 grades 模块导入)/ `DiagnosticReportError`(P3-27 新增:结构化错误码类) +- Stats-service(✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses`(P3-16 修复:弱项阈值从 <60 改为 <80,消除 60-79 盲区)/ `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数) - Schema:`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema`(v2-P2-3 修复:删除死代码 `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema`) **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId;v2-P1-7 新增 getStudentActiveClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId) +- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId;v2-P1-7 新增 getStudentActiveClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId)、`grades`(P3-1 修复:通过 grades/lib/grade-utils.toNumber 复用工具函数) - 被依赖:无 **已知问题**: @@ -1539,6 +1544,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ v2-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor` 和 `id` - ✅ 与 grades 模块无职责重叠 - ✅ v3-P2 改进(2026-06-23):`StudentDiagnosticView` 新增 `practiceHrefBase` prop(string | null),null 时隐藏练习按钮;teacher/diagnostic/student/[studentId] 传入 `practiceHrefBase="/teacher/questions"`,parent/diagnostic 传入 `practiceHrefBase={null}` 隐藏练习按钮 +- ✅ P3-1 已修复(2026-06-23):~~`data-access-reports.ts` 本地定义 `toNumber` 与 `grades/lib/grade-utils.ts` 重复~~ 改为从 grades 模块导入 +- ✅ P3-15 已修复(2026-06-23):~~`getDiagnosticReports` 无分页,可能返回大量数据~~ 添加 limit/offset 分页支持,返回 `{ reports, total }` 结构,Promise.all 并行查询总数和数据 +- ✅ P3-16 已修复(2026-06-23):~~强弱项分类存在 60-79 盲区~~ 弱项阈值从 <60 改为 <80,确保所有知识点都被分类 +- ✅ P3-17 已修复:~~班级报告 strengths 无数量上限~~ `buildClassReportContent` 中 strengths 已限制为前 5 个(按掌握度降序) +- ✅ P3-18 已修复(2026-06-23):~~`getStudentMasterySummary` 串行查询用户名和掌握度~~ 改为 Promise.all 并行查询 +- ✅ P3-19 已修复(2026-06-23):~~`getStudentMastery` 使用 export 但仅内部使用~~ 移除 export,改为模块内部函数 +- ✅ P3-27 已修复(2026-06-23):~~`generateDiagnosticReport` / `generateClassDiagnosticReport` 中 `throw new Error("...")` 直接暴露给用户~~ 改为使用 `DiagnosticReportError` 结构化错误码(继承 BusinessError) **文件清单**: | 文件 | 行数 | 职责 | @@ -1952,6 +1964,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | **Server Actions** | `recommendStudyPathAction` | `modules/ai/actions.ts` | 学习路径推荐(权限:AI_CHAT)— V2 新增 | | **Server Actions** | `getAiUsageStatsAction` | `modules/ai/actions.ts` | AI 使用统计(权限:AI_CONFIGURE)— V2 新增 | | **SSE Route** | `POST /api/ai/chat/stream` | `app/api/ai/chat/stream/route.ts` | 流式 AI 对话(SSE)— V2 新增 | +| **SSE Route** | `GET /api/notifications/stream` | `app/api/notifications/stream/route.ts` | 通知实时推送(SSE,15s 心跳 + 5min 超时)— V2-P3 新增 | | **Service** | `AiService` | `modules/ai/types.ts` | 服务端 AI 服务接口(含 8 个方法) | | **Service** | `AiClientService` | `modules/ai/types.ts` | 客户端 AI 服务接口(Server Action 引用集合) | | **Provider** | `AiClientProvider` | `modules/ai/context/ai-client-provider.tsx` | React Context Provider,注入 AiClientService | @@ -2036,6 +2049,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook | | `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook | | `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 | +| `app/api/notifications/stream/route.ts` | ~120 | 通知实时推送 SSE 端点 — V2-P3 新增 | **i18n**: - 翻译文件:`shared/i18n/messages/{locale}/ai.json` diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 7597cc4..8fd8f8e 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -7031,19 +7031,121 @@ ] }, { - "name": "toggleTwoFactorAction", + "name": "setupTwoFactorAction", "file": "actions-security.ts", "permission": "USER_PROFILE_UPDATE", - "signature": "(enabled: boolean) => Promise>", - "purpose": "启用/禁用 2FA(P2-9 新增:占位实现;v2 已禁用开关,显示'即将推出'提示,避免虚假安全感)", + "signature": "() => Promise>", + "purpose": "2FA 启用流程第一步:生成 TOTP 密钥 + QR 码 Data URL(v3 新增:完整 TOTP 实现,替代 v2 的占位开关)", "deps": [ "requirePermission", - "data-access-system-settings.upsertSystemSetting" + "users/data-access.getUserProfile", + "data-access-two-factor.getTwoFactorEnabled", + "data-access-two-factor.setTotpSecret", + "lib/totp.generateTotpSecret", + "lib/totp.buildOtpAuthUrl", + "lib/totp.generateQrCodeDataUrl" ], "usedBy": [ "components/security-center-card.tsx" ] }, + { + "name": "verifyTwoFactorAction", + "file": "actions-security.ts", + "permission": "USER_PROFILE_UPDATE", + "signature": "(token: string) => Promise>", + "purpose": "2FA 启用流程第二步:校验一次性码 + 生成 10 个备份码(bcrypt 哈希存储)+ 启用 2FA(v3 新增)", + "deps": [ + "requirePermission", + "data-access-two-factor.getTotpSecret", + "data-access-two-factor.setBackupCodesHashed", + "data-access-two-factor.setTwoFactorEnabled", + "data-access-two-factor.setTwoFactorEnabledAt", + "lib/totp.verifyTotpCode", + "lib/totp.generateBackupCodes", + "lib/totp.hashBackupCodes" + ], + "usedBy": [ + "components/security-center-card.tsx" + ] + }, + { + "name": "disableTwoFactorAction", + "file": "actions-security.ts", + "permission": "USER_PROFILE_UPDATE", + "signature": "(token: string) => Promise>", + "purpose": "关闭 2FA:需提供有效 TOTP 码或备份码确认身份,清除密钥和备份码(v3 新增)", + "deps": [ + "requirePermission", + "data-access-two-factor.getTwoFactorEnabled", + "data-access-two-factor.getTotpSecret", + "data-access-two-factor.getBackupCodesHashed", + "data-access-two-factor.setTwoFactorEnabled", + "data-access-two-factor.setTwoFactorEnabledAt", + "data-access-two-factor.deleteTotpSecret", + "data-access-two-factor.deleteBackupCodes", + "data-access-two-factor.setBackupCodesHashed", + "lib/totp.verifyTotpCode", + "lib/totp.verifyBackupCode", + "lib/totp.consumeBackupCode" + ], + "usedBy": [ + "components/security-center-card.tsx" + ] + }, + { + "name": "regenerateBackupCodesAction", + "file": "actions-security.ts", + "permission": "USER_PROFILE_UPDATE", + "signature": "(token: string) => Promise>", + "purpose": "重新生成备份码:需 TOTP 码确认身份,使旧备份码失效(v3 新增)", + "deps": [ + "requirePermission", + "data-access-two-factor.getTwoFactorEnabled", + "data-access-two-factor.getTotpSecret", + "data-access-two-factor.setBackupCodesHashed", + "lib/totp.verifyTotpCode", + "lib/totp.generateBackupCodes", + "lib/totp.hashBackupCodes" + ], + "usedBy": [ + "components/security-center-card.tsx" + ] + }, + { + "name": "preflightTwoFactorAction", + "file": "actions-security.ts", + "permission": "(public, login 前预检)", + "signature": "(email: string) => Promise<{ required: boolean }>", + "purpose": "登录预检:根据邮箱查询用户是否启用 2FA,登录表单据此展示 2FA 输入框(v3 新增;不验证密码,防邮箱枚举)", + "deps": [ + "shared.db", + "shared.db.schema.users", + "data-access-two-factor.getTwoFactorEnabled" + ], + "usedBy": [ + "modules/auth/components/login-form.tsx" + ] + }, + { + "name": "verifyTwoFactorForLogin", + "file": "actions-security.ts", + "permission": "(internal, 供 auth.ts 调用)", + "signature": "(params: { userId: string; token?: string }) => Promise<{ required: boolean; valid: boolean }>", + "purpose": "登录时 2FA 校验:检查用户是否启用 2FA 并校验 TOTP 码或备份码(消耗备份码);由 auth.ts authorize 回调调用(v3 新增)", + "deps": [ + "data-access-two-factor.getTwoFactorEnabled", + "data-access-two-factor.getTotpSecret", + "data-access-two-factor.getBackupCodesHashed", + "data-access-two-factor.setBackupCodesHashed", + "lib/totp.verifyTotpCode", + "lib/totp.verifyBackupCode", + "lib/totp.consumeBackupCode" + ], + "usedBy": [ + "auth.ts" + ] + }, { "name": "revokeAllOtherSessionsAction", "file": "actions-security.ts", @@ -9657,7 +9759,7 @@ { "name": "GradeDistributionChart", "file": "components/grade-distribution-chart.tsx", - "purpose": "分数分布柱状图(recharts BarChart,彩色区间 90-100/80-89/70-79/60-69/<60)", + "purpose": "分数分布柱状图(recharts BarChart,彩色区间 90-100/80-89/70-79/60-69/<60;v4-P3-4 改进:每个分数段使用不同 SVG pattern + 颜色双重编码,色盲友好)", "deps": [ "recharts", "shared/components/ui/chart" @@ -10864,7 +10966,7 @@ { "name": "UnreadMessageBadge", "file": "components/unread-message-badge.tsx", - "purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 60 秒轮询 getUnreadMessageCountAction;V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量)" + "purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 30 秒轮询 getUnreadMessageCountAction;V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量;V2-P3 优化:间隔从 60s 缩短为 30s 与通知组件保持一致)" } ], "hooks": [ @@ -10922,7 +11024,7 @@ "data-access.getNotifications" ], "usedBy": [ - "notification-dropdown.tsx", + "hooks/use-notification-stream.ts", "notification-list.tsx" ] }, @@ -10936,7 +11038,7 @@ "data-access.getUnreadNotificationCount" ], "usedBy": [ - "notification-dropdown.tsx" + "hooks/use-notification-stream.ts" ] }, { @@ -12629,22 +12731,21 @@ "name": "getStudentMastery", "signature": "(studentId: string) => Promise", "file": "data-access.ts", - "purpose": "获取学生在所有知识点的掌握度(含知识点名称,按掌握度降序)", + "purpose": "获取学生在所有知识点的掌握度(含知识点名称,按掌握度降序)。P3-19 修复:移除 export,改为模块内部函数", "deps": [ "shared.db", "shared.db.schema.knowledgePointMastery", "shared.db.schema.knowledgePoints" ], "usedBy": [ - "data-access.getStudentMasterySummary", - "teacher/diagnostic/student/[studentId]/page.tsx" + "data-access.getStudentMasterySummary" ] }, { "name": "getStudentMasterySummary", "signature": "(studentId: string) => Promise", "file": "data-access.ts", - "purpose": "获取学生掌握度摘要(平均掌握度、强项≥80%、弱项<60%)", + "purpose": "获取学生掌握度摘要(平均掌握度、强项≥80%、弱项<80%[P3-16修复]。P3-18 修复:getUserNamesByIds 与 getStudentMastery 并行查询)", "deps": [ "shared.db", "shared.db.schema.users", @@ -12710,11 +12811,12 @@ "name": "generateDiagnosticReport", "signature": "(studentId: string, period: string, generatedBy: string) => Promise", "file": "data-access-reports.ts", - "purpose": "生成个人诊断报告(计算 overallScore、强项/弱项列表、复习建议,status=draft)", + "purpose": "生成个人诊断报告(计算 overallScore、强项/弱项列表、复习建议,status=draft。P3-27 修复:使用 DiagnosticReportError 结构化错误码;P3-1 修复:toNumber 从 grades 模块导入)", "deps": [ "shared.db", "shared.db.schema.learningDiagnosticReports", "data-access.getStudentMasterySummary", + "grades.lib.grade-utils.toNumber", "@paralleldrive/cuid2" ], "usedBy": [ @@ -12725,7 +12827,7 @@ "name": "generateClassDiagnosticReport", "signature": "(classId: string, period: string, generatedBy: string) => Promise", "file": "data-access-reports.ts", - "purpose": "生成班级诊断报告(聚合班级掌握度,识别薄弱知识点,status=draft,studentId 存生成者 ID)", + "purpose": "生成班级诊断报告(聚合班级掌握度,识别薄弱知识点,status=draft,studentId 存生成者 ID。P3-27 修复:使用 DiagnosticReportError 结构化错误码)", "deps": [ "shared.db", "shared.db.schema.learningDiagnosticReports", @@ -12738,19 +12840,20 @@ }, { "name": "getDiagnosticReports", - "signature": "(filters: DiagnosticReportQueryParams) => Promise", + "signature": "(filters: DiagnosticReportQueryParams, scope?: DataScope) => Promise", "file": "data-access-reports.ts", - "purpose": "查询诊断报告列表(可按 studentId/reportType/status/period 过滤,含学生名和生成者名;v3 修复:conditions 显式标注 SQL[] 类型,移除 round2 死代码)", + "purpose": "查询诊断报告列表(可按 studentId/reportType/status/period 过滤,含学生名和生成者名。P3-15 修复:支持分页 limit/offset,返回 { reports, total } 结构,Promise.all 并行查询总数和数据)", "deps": [ "shared.db", "shared.db.schema.learningDiagnosticReports", - "shared.db.schema.users" + "shared.db.schema.users", + "grades.lib.grade-utils.toNumber" ], "usedBy": [ - "actions.getDiagnosticReportsAction", "teacher/diagnostic/page.tsx", "teacher/diagnostic/student/[studentId]/page.tsx", - "student/diagnostic/page.tsx" + "student/diagnostic/page.tsx", + "parent/diagnostic/page.tsx" ] }, { @@ -12987,7 +13090,7 @@ "name": "StudentMasterySummary", "type": "interface", "file": "types.ts", - "definition": "{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<60), allMastery }", + "definition": "{ studentId, studentName, averageMastery, totalKnowledgePoints, strengths(≥80), weaknesses(<80)[P3-16修复:消除60-79盲区], allMastery }", "usedBy": [ "data-access.getStudentMasterySummary", "data-access-reports.generateDiagnosticReport", @@ -13042,12 +13145,21 @@ "name": "DiagnosticReportQueryParams", "type": "interface", "file": "types.ts", - "definition": "{ studentId?, reportType?, status?, period? }", + "definition": "{ studentId?, reportType?, status?, period?, limit?(P3-15), offset?(P3-15) }", "usedBy": [ "data-access-reports.getDiagnosticReports", "actions.getDiagnosticReportsAction" ] }, + { + "name": "DiagnosticReportListResult", + "type": "interface", + "file": "types.ts", + "definition": "{ reports: DiagnosticReportWithDetails[], total: number }(P3-15 修复:分页查询结果)", + "usedBy": [ + "data-access-reports.getDiagnosticReports" + ] + }, { "name": "MasteryRadarPoint", "type": "interface", @@ -13075,7 +13187,7 @@ { "name": "StudentDiagnosticView", "file": "components/student-diagnostic-view.tsx", - "purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮)", + "purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮;P3-22 改进:练习按钮添加 aria-label 含知识点名)", "props": "{ studentId, summary, classAverage?, reports?, practiceHrefBase?: string | null }", "deps": [ "usePermission", @@ -16131,6 +16243,12 @@ "type": "data-access", "description": "关联考试提交(examSubmissions/submissionAnswers)" }, + { + "from": "diagnostic", + "to": "grades", + "type": "lib-import", + "description": "复用 toNumber 工具函数(P3-1 修复:从 grades/lib/grade-utils 导入)" + }, { "from": "elective", "to": "school", @@ -17467,7 +17585,7 @@ "grades/data-access-analytics.getClassAverageTrend" ], "permission": "grade_record:read", - "description": "家长成绩视图(按 DataScope.children 过滤;v4 新增 ParentExportButton 占位;v3-P2 更新:为每个子女并行查询 getClassAverageTrend,渲染 GradeTrendCard)" + "description": "家长成绩视图(按 DataScope.children 过滤;v4 新增 ParentExportButton 占位;v3-P2 更新:为每个子女并行查询 getClassAverageTrend,渲染 GradeTrendCard;v3-P3-4 更新:GradeTrendCard 新增日期范围选择器,通过 nuqs trendRange URL 参数持久化)" }, "/parent/diagnostic": { "component": "子女学情诊断", @@ -17642,6 +17760,30 @@ ], "studentMode": "强制苏格拉底式引导系统提示" }, + "/api/notifications/stream": { + "methods": [ + "GET" + ], + "handler": "通知实时推送 SSE 端点(ReadableStream + setInterval 定时推送)", + "auth": "MESSAGE_READ", + "validation": "requirePermission 权限校验", + "protocol": "Server-Sent Events", + "events": [ + "update — 未读数 + 最新通知列表(连接建立时立即推送,之后每 15 秒推送)", + "error — 权限拒绝或内部错误", + "[DONE] — 连接超时(5 分钟)自动关闭" + ], + "pushStrategy": "连接建立立即推送 + 15 秒间隔定时推送 + 5 分钟超时自动关闭", + "module": "notifications", + "deps": [ + "requirePermission", + "data-access.getUnreadNotificationCount", + "data-access.getNotifications" + ], + "usedBy": [ + "hooks/use-notification-stream.ts" + ] + }, "/api/onboarding/complete": { "methods": [ "POST" diff --git a/src/app/(dashboard)/admin/attendance/page.tsx b/src/app/(dashboard)/admin/attendance/page.tsx index 932cf53..0408528 100644 --- a/src/app/(dashboard)/admin/attendance/page.tsx +++ b/src/app/(dashboard)/admin/attendance/page.tsx @@ -14,6 +14,7 @@ import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/d import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards" import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" +import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout" import type { AttendanceStatus } from "@/modules/attendance/types" export const dynamic = "force-dynamic" @@ -55,25 +56,27 @@ export default async function AdminAttendancePage({ date: date && date.length > 0 ? date : undefined, }) - return ( -
-
-
-

{t("title.adminOverview")}

-

{t("description.adminOverview")}

-
- + const header = ( +
+
+

{t("title.adminOverview")}

+

{t("description.adminOverview")}

+ +
+ ) - - - - + return ( + } + filters={} + > {result.items.length === 0 && !classId && !status && !date ? ( )} -
+ ) } diff --git a/src/app/(dashboard)/admin/elective/page.tsx b/src/app/(dashboard)/admin/elective/page.tsx index 343f61a..7f42543 100644 --- a/src/app/(dashboard)/admin/elective/page.tsx +++ b/src/app/(dashboard)/admin/elective/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "next-intl/server" import { getElectiveCourses } from "@/modules/elective/data-access" import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import type { ElectiveCourseStatus } from "@/modules/elective/types" @@ -24,20 +25,23 @@ export default async function AdminElectivePage({ const courses = await getElectiveCourses({ status }) + const header = ( +
+

{t("title.adminList")}

+

+ {t("description.adminList")} +

+
+ ) + return ( -
-
-

{t("title.adminList")}

-

- {t("description.adminList")} -

-
+ -
+ ) } diff --git a/src/app/(dashboard)/teacher/attendance/page.tsx b/src/app/(dashboard)/teacher/attendance/page.tsx index f4a5c77..78ceac8 100644 --- a/src/app/(dashboard)/teacher/attendance/page.tsx +++ b/src/app/(dashboard)/teacher/attendance/page.tsx @@ -11,6 +11,7 @@ import { getTeacherClasses } from "@/modules/classes/data-access" import { getAttendanceRecords } from "@/modules/attendance/data-access" import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters" import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list" +import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout" import type { AttendanceStatus } from "@/modules/attendance/types" export const dynamic = "force-dynamic" @@ -62,31 +63,34 @@ export default async function TeacherAttendancePage({ const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE) const hasFilters = Boolean(classId || status || date) - return ( -
-
-
-

{t("title.teacherRecords")}

-

{t("description.teacherRecords")}

-
-
- - -
+ const header = ( +
+
+

{t("title.teacherRecords")}

+

{t("description.teacherRecords")}

+
+ + +
+
+ ) - - + return ( + } + > {result.items.length === 0 && !hasFilters ? ( )} -
+ ) } diff --git a/src/app/(dashboard)/teacher/elective/page.tsx b/src/app/(dashboard)/teacher/elective/page.tsx index f69ef8b..dba2684 100644 --- a/src/app/(dashboard)/teacher/elective/page.tsx +++ b/src/app/(dashboard)/teacher/elective/page.tsx @@ -1,9 +1,11 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getElectiveCourses } from "@/modules/elective/data-access" import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list" +import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout" import type { ElectiveCourseStatus } from "@/modules/elective/types" export const dynamic = "force-dynamic" @@ -25,7 +27,7 @@ export default async function TeacherElectivePage({ searchParams: Promise }): Promise { const t = await getTranslations("elective") - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.ELECTIVE_READ) const teacherId = ctx.userId const sp = await searchParams @@ -36,18 +38,21 @@ export default async function TeacherElectivePage({ ? await getElectiveCourses({ teacherId, status }) : [] + const header = ( +
+

{t("title.teacher")}

+

{t("description.teacher")}

+
+ ) + return ( -
-
-

{t("title.teacher")}

-

{t("description.teacher")}

-
+ -
+ ) } diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 3eaa377..c2c1d91 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -5,6 +5,8 @@ import { Permissions } from "@/shared/types/permissions" import { formatDateForFile } from "@/shared/lib/utils" import { exportUsersToExcel } from "@/modules/users/import-export" import { exportGradeRecordsToExcel } from "@/modules/grades/export" +import { exportAttendanceRecordsToExcel } from "@/modules/attendance/export" +import { exportElectiveCoursesToExcel, exportCourseSelectionsToExcel } from "@/modules/elective/export" import { exportAuditLogsAction, exportDataChangeLogsAction, @@ -13,10 +15,25 @@ import { export const dynamic = "force-dynamic" -type ExportType = "grades" | "users" | "attendance" | "audit" | "login" | "dataChange" +type ExportType = + | "grades" + | "users" + | "attendance" + | "electiveCourses" + | "courseSelections" + | "audit" + | "login" + | "dataChange" const isExportType = (v: string): v is ExportType => - v === "grades" || v === "users" || v === "attendance" || v === "audit" || v === "login" || v === "dataChange" + v === "grades" || + v === "users" || + v === "attendance" || + v === "electiveCourses" || + v === "courseSelections" || + v === "audit" || + v === "login" || + v === "dataChange" export async function POST(req: Request) { try { @@ -53,6 +70,62 @@ export async function POST(req: Request) { role: params.role ? String(params.role) : undefined, }) filename = `users_export_${formatDateForFile()}.xlsx` + } else if (type === "attendance") { + // 考勤导出需要 ATTENDANCE_READ 权限 + try { + await requirePermission(Permissions.ATTENDANCE_READ) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + throw e + } + buffer = await exportAttendanceRecordsToExcel({ + scope: ctx.dataScope, + currentUserId: ctx.userId, + classId: params.classId ? String(params.classId) : undefined, + status: params.status ? String(params.status) : undefined, + date: params.date ? String(params.date) : undefined, + }) + filename = `attendance_export_${formatDateForFile()}.xlsx` + } else if (type === "electiveCourses") { + // 选修课导出需要 ELECTIVE_READ 权限 + try { + await requirePermission(Permissions.ELECTIVE_READ) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + throw e + } + buffer = await exportElectiveCoursesToExcel({ + status: params.status ? String(params.status) : undefined, + teacherId: params.teacherId ? String(params.teacherId) : undefined, + }) + filename = `elective_courses_export_${formatDateForFile()}.xlsx` + } else if (type === "courseSelections") { + // 选课名单导出需要 ELECTIVE_MANAGE 权限 + try { + await requirePermission(Permissions.ELECTIVE_MANAGE) + } catch (e) { + if (e instanceof PermissionDeniedError) { + return NextResponse.json( + { success: false, message: e.message }, + { status: 403 } + ) + } + throw e + } + buffer = await exportCourseSelectionsToExcel({ + courseId: String(params.courseId ?? ""), + }) + filename = `course_selections_export_${formatDateForFile()}.xlsx` } else if (type === "audit" || type === "login" || type === "dataChange") { // Audit-related exports require AUDIT_LOG_READ permission try { @@ -104,7 +177,7 @@ export async function POST(req: Request) { } } else { return NextResponse.json( - { success: false, message: "Attendance export not implemented" }, + { success: false, message: "Export type not implemented" }, { status: 400 } ) } diff --git a/src/modules/attendance/actions.ts b/src/modules/attendance/actions.ts index 34bff44..7bde9ff 100644 --- a/src/modules/attendance/actions.ts +++ b/src/modules/attendance/actions.ts @@ -1,9 +1,11 @@ "use server" import { revalidatePath } from "next/cache" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils" import { trackEvent } from "@/shared/lib/track-event" import { verifyTeacherOwnsClass } from "@/modules/classes/data-access" @@ -32,15 +34,16 @@ async function assertRecordOwnership( recordId: string, ctx: Awaited> ): Promise<{ ok: boolean; message?: string }> { + const t = await getTranslations("attendance") if (ctx.dataScope.type === "all") return { ok: true } if (ctx.dataScope.type === "class_taught") { const classId = await getAttendanceRecordClassId(recordId) - if (!classId) return { ok: false, message: "Attendance record not found" } + if (!classId) return { ok: false, message: t("errors.notFound") } const owns = await verifyTeacherOwnsClass(classId, ctx.userId) - if (!owns) return { ok: false, message: "You do not own this attendance record" } + if (!owns) return { ok: false, message: t("errors.noOwnership") } return { ok: true } } - return { ok: false, message: "Insufficient permissions" } + return { ok: false, message: t("errors.insufficientPermissions") } } /** @@ -53,13 +56,14 @@ async function assertClassOwnership( classId: string, ctx: Awaited> ): Promise<{ ok: boolean; message?: string }> { + const t = await getTranslations("attendance") if (ctx.dataScope.type === "all") return { ok: true } if (ctx.dataScope.type === "class_taught") { const owns = await verifyTeacherOwnsClass(classId, ctx.userId) - if (!owns) return { ok: false, message: "You do not own this class" } + if (!owns) return { ok: false, message: t("errors.noClassOwnership") } return { ok: true } } - return { ok: false, message: "Insufficient permissions" } + return { ok: false, message: t("errors.insufficientPermissions") } } export async function recordAttendanceAction( @@ -67,6 +71,7 @@ export async function recordAttendanceAction( formData: FormData ): Promise> { try { + const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const parsed = RecordAttendanceSchema.safeParse({ @@ -81,7 +86,7 @@ export async function recordAttendanceAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -95,11 +100,9 @@ export async function recordAttendanceAction( targetType: "attendance_record", properties: { studentId: parsed.data.studentId, classId: parsed.data.classId, status: parsed.data.status }, }) - return { success: true, message: "Attendance recorded", data: id } + return { success: true, message: t("messages.recorded"), data: id } } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -108,21 +111,22 @@ export async function batchRecordAttendanceAction( formData: FormData ): Promise> { try { + const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const recordsJson = formData.get("recordsJson") if (typeof recordsJson !== "string" || recordsJson.length === 0) { - return { success: false, message: "Missing records data" } + return { success: false, message: t("errors.missingRecords") } } const parsed = BatchRecordAttendanceSchema.safeParse({ - records: JSON.parse(recordsJson), + records: safeJsonParse(recordsJson, t("errors.invalidRecordsJson")), }) if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -135,11 +139,9 @@ export async function batchRecordAttendanceAction( targetType: "attendance_record", properties: { count, classId: parsed.data.records[0]?.classId }, }) - return { success: true, message: `Recorded attendance for ${count} students`, data: count } + return { success: true, message: t("messages.batchRecorded", { count }), data: count } } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -149,11 +151,12 @@ export async function updateAttendanceAction( formData: FormData ): Promise> { try { + const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const ownership = await assertRecordOwnership(id, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const parsed = UpdateAttendanceSchema.safeParse({ @@ -165,7 +168,7 @@ export async function updateAttendanceAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -179,11 +182,9 @@ export async function updateAttendanceAction( targetType: "attendance_record", properties: { status: parsed.data.status }, }) - return { success: true, message: "Attendance updated" } + return { success: true, message: t("messages.updated") } } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -191,11 +192,12 @@ export async function deleteAttendanceAction( id: string ): Promise> { try { + const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const ownership = await assertRecordOwnership(id, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } await deleteAttendanceRecord(id) @@ -206,11 +208,9 @@ export async function deleteAttendanceAction( targetId: id, targetType: "attendance_record", }) - return { success: true, message: "Attendance record deleted" } + return { success: true, message: t("messages.deleted") } } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -219,6 +219,7 @@ export async function saveAttendanceRulesAction( formData: FormData ): Promise> { try { + const t = await getTranslations("attendance") const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE) const parsed = AttendanceRuleSchema.safeParse({ @@ -231,14 +232,14 @@ export async function saveAttendanceRulesAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } const ownership = await assertClassOwnership(parsed.data.classId, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const id = await upsertAttendanceRules(parsed.data) @@ -250,10 +251,8 @@ export async function saveAttendanceRulesAction( targetType: "attendance_rule", properties: { classId: parsed.data.classId }, }) - return { success: true, message: "Attendance rules saved", data: id } + return { success: true, message: t("messages.rulesSaved"), data: id } } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } diff --git a/src/modules/attendance/components/attendance-page-layout.tsx b/src/modules/attendance/components/attendance-page-layout.tsx new file mode 100644 index 0000000..8371e87 --- /dev/null +++ b/src/modules/attendance/components/attendance-page-layout.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from "react" + +import { cn } from "@/shared/lib/utils" + +/** + * 考勤模块页面布局(消除 admin/teacher 页面重复结构)。 + * + * 复用模式:标题区 + 统计卡片(可选)+ 筛选区 + 内容区。 + */ +interface AttendancePageLayoutProps { + /** 页面头部(标题 + 描述 + 操作按钮) */ + header: ReactNode + /** 统计卡片区(admin 总览页使用) */ + stats?: ReactNode + /** 筛选区 */ + filters?: ReactNode + /** 主体内容(列表/表单等) */ + children: ReactNode + /** 额外类名 */ + className?: string +} + +export function AttendancePageLayout({ + header, + stats, + filters, + children, + className, +}: AttendancePageLayoutProps) { + return ( +
+ {header} + {stats} + {filters} + {children} +
+ ) +} diff --git a/src/modules/attendance/data-access-stats.ts b/src/modules/attendance/data-access-stats.ts index 12465bd..4c42e02 100644 --- a/src/modules/attendance/data-access-stats.ts +++ b/src/modules/attendance/data-access-stats.ts @@ -1,9 +1,10 @@ import "server-only" -import { and, asc, desc, eq, gte, lte } from "drizzle-orm" +import { and, asc, count, desc, eq, gte, lte, sql } from "drizzle-orm" import { db } from "@/shared/db" import { attendanceRecords, classes, users } from "@/shared/db/schema" +import { safeParseDate } from "@/shared/lib/action-utils" import type { AttendanceListItem, @@ -23,6 +24,9 @@ const EMPTY_STATS: AttendanceStats = { lateRate: 0, } +/** 最近记录的默认截取数量(避免一次拉全量) */ +const DEFAULT_RECENT_LIMIT = 20 + /** * 根据考勤记录行计算统计(纯函数,便于测试)。 */ @@ -41,13 +45,44 @@ export const computeStats = (rows: { status: string }[]): AttendanceStats => { return stats } +/** + * 将 SQL 聚合行转换为 AttendanceStats(避免拉全量记录计算统计)。 + */ +const statsFromAggregate = (row: { + total: number + present: number + absent: number + late: number + earlyLeave: number + excused: number +}): AttendanceStats => { + const total = Number(row.total ?? 0) + const present = Number(row.present ?? 0) + const late = Number(row.late ?? 0) + return { + total, + present, + absent: Number(row.absent ?? 0), + late, + earlyLeave: Number(row.earlyLeave ?? 0), + excused: Number(row.excused ?? 0), + presentRate: total > 0 ? Math.round((present / total) * 10000) / 100 : 0, + lateRate: total > 0 ? Math.round((late / total) * 10000) / 100 : 0, + } +} + const serializeDate = (d: Date | string | null): string => d ? new Date(d).toISOString().slice(0, 10) : "" +/** + * 获取学生考勤汇总。 + * 优化:统计使用 SQL 聚合查询(避免拉全量记录),最近记录使用 LIMIT 分页。 + */ export async function getStudentAttendanceSummary( studentId: string, startDate?: string, - endDate?: string + endDate?: string, + recentLimit: number = DEFAULT_RECENT_LIMIT ): Promise { const [student] = await db .select({ name: users.name }) @@ -57,9 +92,33 @@ export async function getStudentAttendanceSummary( if (!student) return null const conditions = [eq(attendanceRecords.studentId, studentId)] - if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate))) - if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate))) + if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "开始日期"))) + if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期"))) + const where = and(...conditions) + // 统计使用 SQL 聚合,避免拉全量记录 + const [statsRow] = await db + .select({ + total: count(), + present: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`, + absent: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`, + late: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`, + earlyLeave: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`, + excused: sql`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`, + }) + .from(attendanceRecords) + .where(where) + + const stats = statsFromAggregate(statsRow ?? { + total: 0, + present: 0, + absent: 0, + late: 0, + earlyLeave: 0, + excused: 0, + }) + + // 最近记录使用 LIMIT 分页,避免拉全量 const rows = await db .select({ record: attendanceRecords, @@ -67,12 +126,11 @@ export async function getStudentAttendanceSummary( }) .from(attendanceRecords) .leftJoin(classes, eq(classes.id, attendanceRecords.classId)) - .where(and(...conditions)) + .where(where) .orderBy(desc(attendanceRecords.date)) + .limit(recentLimit) - const stats = computeStats(rows.map((r) => ({ status: r.record.status }))) - - const recentRecords: AttendanceListItem[] = rows.slice(0, 20).map((r) => ({ + const recentRecords: AttendanceListItem[] = rows.map((r) => ({ id: r.record.id, studentId: r.record.studentId, studentName: student.name ?? "Unknown", @@ -108,8 +166,8 @@ export async function getClassAttendanceStats( if (!classRow) return null const conditions = [eq(attendanceRecords.classId, classId)] - if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate))) - if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate))) + if (startDate) conditions.push(gte(attendanceRecords.date, safeParseDate(startDate, "开始日期"))) + if (endDate) conditions.push(lte(attendanceRecords.date, safeParseDate(endDate, "结束日期"))) const rows = await db .select({ diff --git a/src/modules/attendance/export.ts b/src/modules/attendance/export.ts new file mode 100644 index 0000000..f7a12f4 --- /dev/null +++ b/src/modules/attendance/export.ts @@ -0,0 +1,90 @@ +import "server-only" + +import { getTranslations } from "next-intl/server" + +import type { DataScope } from "@/shared/types/permissions" +import { exportToExcel } from "@/shared/lib/excel" + +import { getAttendanceRecords, getAttendanceStats } from "./data-access" + +/** + * 导出考勤记录到 Excel + * Sheet 1: 考勤明细 + * Sheet 2: 统计汇总 + */ +export async function exportAttendanceRecordsToExcel(params: { + scope: DataScope + currentUserId?: string + classId?: string + status?: string + date?: string +}): Promise { + const t = await getTranslations("attendance") + + const records = await getAttendanceRecords({ + scope: params.scope, + currentUserId: params.currentUserId, + classId: params.classId, + status: params.status as + | "present" + | "absent" + | "late" + | "early_leave" + | "excused" + | undefined, + date: params.date, + }) + + const detailRows = records.items.map((r) => ({ + [t("list.columns.student")]: r.studentName, + [t("list.columns.class")]: r.className, + [t("list.columns.date")]: r.date, + [t("list.columns.status")]: t(`status.${r.status}`), + [t("list.columns.remark")]: r.remark ?? "", + [t("list.columns.recorder")]: r.recorderName, + [t("list.columns.createdAt")]: r.createdAt.split("T")[0], + })) + + const stats = await getAttendanceStats({ + scope: params.scope, + currentUserId: params.currentUserId ?? "", + classId: params.classId, + date: params.date, + }) + + const statsRows = [ + { metric: t("stats.totalRecords"), value: stats.totalRecords }, + { metric: t("stats.present"), value: stats.presentCount }, + { metric: t("stats.absent"), value: stats.absentCount }, + { metric: t("stats.late"), value: stats.lateCount }, + { metric: t("stats.earlyLeave"), value: stats.earlyLeaveCount }, + { metric: t("stats.excused"), value: stats.excusedCount }, + { metric: t("stats.attendanceRate"), value: `${stats.attendanceRate}%` }, + ] + + return exportToExcel({ + sheets: [ + { + name: t("title.adminOverview"), + columns: [ + { header: t("list.columns.student"), key: t("list.columns.student"), width: 18 }, + { header: t("list.columns.class"), key: t("list.columns.class"), width: 18 }, + { header: t("list.columns.date"), key: t("list.columns.date"), width: 14 }, + { header: t("list.columns.status"), key: t("list.columns.status"), width: 12 }, + { header: t("list.columns.remark"), key: t("list.columns.remark"), width: 24 }, + { header: t("list.columns.recorder"), key: t("list.columns.recorder"), width: 16 }, + { header: t("list.columns.createdAt"), key: t("list.columns.createdAt"), width: 14 }, + ], + rows: detailRows, + }, + { + name: t("actions.stats"), + columns: [ + { header: "Metric", key: "metric", width: 24 }, + { header: "Value", key: "value", width: 16 }, + ], + rows: statsRows, + }, + ], + }) +} diff --git a/src/modules/elective/actions.ts b/src/modules/elective/actions.ts index 99de612..ddc9faf 100644 --- a/src/modules/elective/actions.ts +++ b/src/modules/elective/actions.ts @@ -1,9 +1,11 @@ "use server" import { revalidatePath } from "next/cache" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { getTranslations } from "next-intl/server" +import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" +import { handleActionError } from "@/shared/lib/action-utils" import { trackEvent } from "@/shared/lib/track-event" import { @@ -23,12 +25,6 @@ import { } from "./data-access" import { runLottery, selectCourse, dropCourse } from "./data-access-operations" -const handleError = (e: unknown): ActionState => { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } -} - const revalidateElectivePaths = (id?: string) => { revalidatePath("/admin/elective") revalidatePath("/teacher/elective") @@ -54,11 +50,12 @@ async function assertCourseOwnership( courseId: string, ctx: Awaited> ): Promise<{ ok: boolean; message?: string }> { + const t = await getTranslations("elective") if (ctx.dataScope.type === "all") return { ok: true } const course = await getElectiveCourseById(courseId) - if (!course) return { ok: false, message: "Course not found" } + if (!course) return { ok: false, message: t("errors.notFound") } if (course.teacherId !== ctx.userId) { - return { ok: false, message: "You do not own this course" } + return { ok: false, message: t("errors.noOwnership") } } return { ok: true } } @@ -68,6 +65,7 @@ export async function createElectiveCourseAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const parsed = CreateElectiveCourseSchema.safeParse({ name: formData.get("name"), @@ -88,7 +86,7 @@ export async function createElectiveCourseAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -101,9 +99,9 @@ export async function createElectiveCourseAction( targetType: "elective_course", properties: { capacity: parsed.data.capacity, selectionMode: parsed.data.selectionMode }, }) - return { success: true, message: "Elective course created", data: id } + return { success: true, message: t("messages.created"), data: id } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -113,11 +111,12 @@ export async function updateElectiveCourseAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const ownership = await assertCourseOwnership(id, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const parsed = UpdateElectiveCourseSchema.safeParse({ @@ -140,7 +139,7 @@ export async function updateElectiveCourseAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -152,9 +151,9 @@ export async function updateElectiveCourseAction( targetId: id, targetType: "elective_course", }) - return { success: true, message: "Elective course updated", data: id } + return { success: true, message: t("messages.updated"), data: id } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -163,12 +162,13 @@ export async function deleteElectiveCourseAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const id = requireCourseId(formData) const ownership = await assertCourseOwnership(id, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } await deleteElectiveCourse(id) @@ -179,9 +179,9 @@ export async function deleteElectiveCourseAction( targetId: id, targetType: "elective_course", }) - return { success: true, message: "Elective course deleted" } + return { success: true, message: t("messages.deleted") } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -190,12 +190,13 @@ export async function openSelectionAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const courseId = requireCourseId(formData) const ownership = await assertCourseOwnership(courseId, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } await openSelection(courseId) @@ -206,9 +207,9 @@ export async function openSelectionAction( targetId: courseId, targetType: "elective_course", }) - return { success: true, message: "Selection opened" } + return { success: true, message: t("messages.selectionOpened") } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -217,12 +218,13 @@ export async function closeSelectionAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const courseId = requireCourseId(formData) const ownership = await assertCourseOwnership(courseId, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } await closeSelection(courseId) @@ -233,9 +235,9 @@ export async function closeSelectionAction( targetId: courseId, targetType: "elective_course", }) - return { success: true, message: "Selection closed" } + return { success: true, message: t("messages.selectionClosed") } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -244,6 +246,7 @@ export async function runLotteryAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE) const parsed = RunLotterySchema.safeParse({ courseId: formData.get("courseId"), @@ -251,14 +254,14 @@ export async function runLotteryAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } const ownership = await assertCourseOwnership(parsed.data.courseId, ctx) if (!ownership.ok) { - return { success: false, message: ownership.message ?? "Ownership check failed" } + return { success: false, message: ownership.message ?? t("messages.ownershipCheckFailed") } } const result = await runLottery(parsed.data.courseId) @@ -272,11 +275,11 @@ export async function runLotteryAction( }) return { success: true, - message: `Lottery completed: ${result.enrolled} enrolled, ${result.waitlist} waitlisted`, + message: t("messages.lotteryCompleted", { enrolled: result.enrolled, waitlist: result.waitlist }), data: result, } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -285,6 +288,7 @@ export async function selectCourseAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) const parsed = SelectCourseSchema.safeParse({ courseId: formData.get("courseId"), @@ -293,7 +297,7 @@ export async function selectCourseAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -308,7 +312,7 @@ export async function selectCourseAction( }) return { success: true, message: result.message, data: result.status } } catch (e) { - return handleError(e) + return handleActionError(e) } } @@ -317,6 +321,7 @@ export async function dropCourseAction( formData: FormData ): Promise> { try { + const t = await getTranslations("elective") const ctx = await requirePermission(Permissions.ELECTIVE_SELECT) const parsed = DropCourseSchema.safeParse({ courseId: formData.get("courseId"), @@ -324,7 +329,7 @@ export async function dropCourseAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("errors.invalidForm"), errors: parsed.error.flatten().fieldErrors, } } @@ -336,8 +341,8 @@ export async function dropCourseAction( targetId: parsed.data.courseId, targetType: "course_selection", }) - return { success: true, message: "Course dropped" } + return { success: true, message: t("messages.courseDropped") } } catch (e) { - return handleError(e) + return handleActionError(e) } } diff --git a/src/modules/elective/components/elective-page-layout.tsx b/src/modules/elective/components/elective-page-layout.tsx new file mode 100644 index 0000000..e22bd2b --- /dev/null +++ b/src/modules/elective/components/elective-page-layout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react" + +import { cn } from "@/shared/lib/utils" + +/** + * 选修课模块页面布局(消除 admin/teacher 列表页重复结构)。 + * + * 复用模式:标题区 + 内容区(含创建按钮)。 + */ +interface ElectivePageLayoutProps { + /** 页面头部(标题 + 描述) */ + header: ReactNode + /** 主体内容(课程列表等) */ + children: ReactNode + /** 额外类名 */ + className?: string +} + +export function ElectivePageLayout({ + header, + children, + className, +}: ElectivePageLayoutProps) { + return ( +
+ {header} + {children} +
+ ) +} diff --git a/src/modules/elective/data-access-operations.ts b/src/modules/elective/data-access-operations.ts index 93c6cac..ddf89f7 100644 --- a/src/modules/elective/data-access-operations.ts +++ b/src/modules/elective/data-access-operations.ts @@ -11,6 +11,9 @@ import { import type { CourseSelectionStatus } from "./types" +/** 学分上限(K12 选修课学期学分上限,可按需调整) */ +const MAX_CREDIT_PER_TERM = 10 + /** * 构建 lotteryRank 的 CASE SQL 表达式(纯函数,便于测试 SQL 片段结构)。 */ @@ -21,6 +24,117 @@ export function buildLotteryRankCase(ids: string[], startRank: number): SQL { return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END` } +/** + * 解析课程 schedule 字段为可比较的时间段(纯函数,便于测试)。 + * schedule 格式约定:"周一 14:00-15:30" 或 "Mon 14:00-15:30"。 + * 返回 null 表示无法解析(不参与冲突检测)。 + */ +export function parseSchedule(schedule: string | null): { day: string; start: string; end: string } | null { + if (!schedule || schedule.length === 0) return null + // 匹配 "周X HH:MM-HH:MM" 或 "Day HH:MM-HH:MM" + const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+(\d{1,2}:\d{2})\s*[-~]\s*(\d{1,2}:\d{2})/i) + if (!match) return null + const [, day, start, end] = match + return { day: day ?? "", start: start ?? "", end: end ?? "" } +} + +/** + * 检测两个时间段是否冲突(纯函数,便于测试)。 + * 仅当 day 相同且时间区间重叠时判定为冲突。 + */ +export function isScheduleConflict( + a: { day: string; start: string; end: string }, + b: { day: string; start: string; end: string } +): boolean { + // 归一化星期表示(周一/Mon → 1,周二/Tue → 2 ...) + const normalizeDay = (d: string): string => { + const dayMap: Record = { + "周一": "1", "周二": "2", "周三": "3", "周四": "4", "周五": "5", "周六": "6", "周日": "7", "周天": "7", + "mon": "1", "tue": "2", "wed": "3", "thu": "4", "fri": "5", "sat": "6", "sun": "7", + } + return dayMap[d.toLowerCase()] ?? d + } + if (normalizeDay(a.day) !== normalizeDay(b.day)) return false + return a.start < b.end && b.start < a.end +} + +/** + * 检测学生选课时间冲突(P2 建议:选课时间冲突检测)。 + * 查询学生已选/已录取的课程,与新课程 schedule 比对。 + */ +async function checkScheduleConflict( + tx: Parameters[0]>[0], + studentId: string, + newCourseId: string +): Promise { + const [newCourse] = await tx + .select({ schedule: electiveCourses.schedule }) + .from(electiveCourses) + .where(eq(electiveCourses.id, newCourseId)) + .limit(1) + const newSchedule = parseSchedule(newCourse?.schedule ?? null) + if (!newSchedule) return false + + const existingCourses = await tx + .select({ + schedule: electiveCourses.schedule, + }) + .from(courseSelections) + .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId)) + .where( + and( + eq(courseSelections.studentId, studentId), + inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) + ) + ) + + for (const row of existingCourses) { + const existing = parseSchedule(row.schedule) + if (existing && isScheduleConflict(newSchedule, existing)) { + return true + } + } + return false +} + +/** + * 检测学生学分是否超限(P2 建议:学分上限校验)。 + * 查询学生已选课程的学分总和,加上新课程学分后是否超过上限。 + */ +async function checkCreditLimit( + tx: Parameters[0]>[0], + studentId: string, + newCourseId: string +): Promise<{ exceeded: boolean; current: number; max: number }> { + const [newCourse] = await tx + .select({ credit: electiveCourses.credit }) + .from(electiveCourses) + .where(eq(electiveCourses.id, newCourseId)) + .limit(1) + const newCredit = Number(newCourse?.credit ?? 0) + + const existing = await tx + .select({ + credit: electiveCourses.credit, + }) + .from(courseSelections) + .innerJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId)) + .where( + and( + eq(courseSelections.studentId, studentId), + inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) + ) + ) + + const currentCredit = existing.reduce((sum, row) => sum + Number(row.credit ?? 0), 0) + const total = currentCredit + newCredit + return { + exceeded: total > MAX_CREDIT_PER_TERM, + current: total, + max: MAX_CREDIT_PER_TERM, + } +} + export async function runLottery(courseId: string): Promise<{ enrolled: number waitlist: number @@ -139,6 +253,18 @@ export async function selectCourse( .limit(1) if (existing) throw new Error("Already selected this course") + // P2 建议:选课时间冲突检测 + const hasConflict = await checkScheduleConflict(tx, studentId, courseId) + if (hasConflict) { + throw new Error("Schedule conflicts with your existing courses") + } + + // P2 建议:学分上限校验 + const creditCheck = await checkCreditLimit(tx, studentId, courseId) + if (creditCheck.exceeded) { + throw new Error(`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`) + } + const id = createId() let status: CourseSelectionStatus = "selected" let enrolledAt: Date | null = null diff --git a/src/modules/elective/data-access-selections.ts b/src/modules/elective/data-access-selections.ts index e311ed5..563a506 100644 --- a/src/modules/elective/data-access-selections.ts +++ b/src/modules/elective/data-access-selections.ts @@ -9,15 +9,13 @@ import { electiveCourses, } from "@/shared/db/schema" -import { getStudentActiveGradeId } from "@/modules/classes/data-access" -import { getUserNamesByIds } from "@/modules/users/data-access" - import { buildCourseSelect, mapCourseRow, resolveCourseDisplayNames, type CourseCoreRow, } from "./data-access" +import { getStudentGradeResolver, getCourseDisplayResolver } from "./resolvers" import type { CourseSelectionWithDetails, ElectiveCourseWithDetails, @@ -92,7 +90,7 @@ const buildSelectionCoreSelect = () => const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise> => { const studentIds = Array.from(new Set(rows.map((r) => r.studentId).filter((v): v is string => typeof v === "string" && v.length > 0))) - const userMap = await getUserNamesByIds(studentIds) + const userMap = await getCourseDisplayResolver().getUserNamesByIds(studentIds) const studentNames = new Map() for (const [id, user] of userMap.entries()) { studentNames.set(id, user.name) @@ -125,7 +123,7 @@ export const getStudentSelections = cache( ) export const getStudentGradeId = cache(async (studentId: string): Promise => { - return getStudentActiveGradeId(studentId) + return getStudentGradeResolver().getStudentActiveGradeId(studentId) }) export const getAvailableCoursesForStudent = cache( diff --git a/src/modules/elective/data-access.ts b/src/modules/elective/data-access.ts index a6d1a96..c5941cd 100644 --- a/src/modules/elective/data-access.ts +++ b/src/modules/elective/data-access.ts @@ -7,8 +7,7 @@ import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { electiveCourses } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" -import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access" -import { getUserNamesByIds } from "@/modules/users/data-access" +import { safeParseDate } from "@/shared/lib/action-utils" import type { ElectiveCourseWithDetails, @@ -18,6 +17,7 @@ import type { CreateElectiveCourseInput, UpdateElectiveCourseInput, } from "./schema" +import { getCourseDisplayResolver } from "./resolvers" const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null @@ -97,16 +97,24 @@ export const buildCourseSelect = () => }) .from(electiveCourses) +/** + * 缓存科目/年级选项(单次请求内复用,避免高频访问时重复查询)。 + * 使用 React `cache()` 在同一渲染周期内去重。 + */ +const getCachedSubjectOptions = cache(async () => getCourseDisplayResolver().getSubjectOptions()) +const getCachedGradeOptions = cache(async () => getCourseDisplayResolver().getGradeOptions()) + export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{ teacherNames: Map subjectNames: Map gradeNames: Map }> => { + const resolver = getCourseDisplayResolver() const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0))) const [userMap, subjects, grades] = await Promise.all([ - getUserNamesByIds(teacherIds), - getSubjectOptions(), - getGradeOptions(), + resolver.getUserNamesByIds(teacherIds), + getCachedSubjectOptions(), + getCachedGradeOptions(), ]) const teacherNames = new Map() @@ -189,10 +197,10 @@ export async function createElectiveCourse( enrolledCount: 0, classroom: data.classroom, schedule: data.schedule, - startDate: data.startDate ? new Date(data.startDate) : null, - endDate: data.endDate ? new Date(data.endDate) : null, - selectionStartAt: data.selectionStartAt ? new Date(data.selectionStartAt) : null, - selectionEndAt: data.selectionEndAt ? new Date(data.selectionEndAt) : null, + startDate: data.startDate ? safeParseDate(data.startDate, "开始日期") : null, + endDate: data.endDate ? safeParseDate(data.endDate, "结束日期") : null, + selectionStartAt: data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null, + selectionEndAt: data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null, status: "draft", selectionMode: data.selectionMode, credit: data.credit, @@ -214,13 +222,13 @@ export async function updateElectiveCourse( if (data.classroom !== undefined) update.classroom = data.classroom if (data.schedule !== undefined) update.schedule = data.schedule if (data.startDate !== undefined) - update.startDate = data.startDate ? new Date(data.startDate) : null + update.startDate = data.startDate ? safeParseDate(data.startDate, "开始日期") : null if (data.endDate !== undefined) - update.endDate = data.endDate ? new Date(data.endDate) : null + update.endDate = data.endDate ? safeParseDate(data.endDate, "结束日期") : null if (data.selectionStartAt !== undefined) - update.selectionStartAt = data.selectionStartAt ? new Date(data.selectionStartAt) : null + update.selectionStartAt = data.selectionStartAt ? safeParseDate(data.selectionStartAt, "选课开始时间") : null if (data.selectionEndAt !== undefined) - update.selectionEndAt = data.selectionEndAt ? new Date(data.selectionEndAt) : null + update.selectionEndAt = data.selectionEndAt ? safeParseDate(data.selectionEndAt, "选课结束时间") : null if (data.status !== undefined) update.status = data.status if (data.selectionMode !== undefined) update.selectionMode = data.selectionMode if (data.credit !== undefined) update.credit = data.credit diff --git a/src/modules/elective/export.ts b/src/modules/elective/export.ts new file mode 100644 index 0000000..55412ca --- /dev/null +++ b/src/modules/elective/export.ts @@ -0,0 +1,102 @@ +import "server-only" + +import { getTranslations } from "next-intl/server" + +import { exportToExcel } from "@/shared/lib/excel" + +import { getElectiveCourses } from "./data-access" +import { getCourseSelections } from "./data-access-selections" + +/** + * 导出选修课课程列表到 Excel + * Sheet 1: 课程明细 + */ +export async function exportElectiveCoursesToExcel(params: { + status?: string + teacherId?: string +}): Promise { + const t = await getTranslations("elective") + + const courses = await getElectiveCourses({ + status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined, + teacherId: params.teacherId, + }) + + const rows = courses.map((c) => ({ + [t("fields.name")]: c.name, + [t("fields.teacher")]: c.teacherName ?? "", + [t("fields.subject")]: c.subjectName ?? "", + [t("fields.grade")]: c.gradeName ?? "", + [t("fields.capacity")]: c.capacity, + [t("fields.enrolled")]: c.enrolledCount, + [t("fields.classroom")]: c.classroom ?? "", + [t("fields.schedule")]: c.schedule ?? "", + [t("fields.credit")]: c.credit, + [t("fields.selectionMode")]: t(`selectionMode.${c.selectionMode}`), + status: t(`status.${c.status}`), + [t("fields.startDate")]: c.startDate ?? "", + [t("fields.endDate")]: c.endDate ?? "", + })) + + return exportToExcel({ + sheets: [ + { + name: t("title.adminList"), + columns: [ + { header: t("fields.name"), key: t("fields.name"), width: 24 }, + { header: t("fields.teacher"), key: t("fields.teacher"), width: 16 }, + { header: t("fields.subject"), key: t("fields.subject"), width: 14 }, + { header: t("fields.grade"), key: t("fields.grade"), width: 12 }, + { header: t("fields.capacity"), key: t("fields.capacity"), width: 10 }, + { header: t("fields.enrolled"), key: t("fields.enrolled"), width: 10 }, + { header: t("fields.classroom"), key: t("fields.classroom"), width: 14 }, + { header: t("fields.schedule"), key: t("fields.schedule"), width: 20 }, + { header: t("fields.credit"), key: t("fields.credit"), width: 8 }, + { header: t("fields.selectionMode"), key: t("fields.selectionMode"), width: 16 }, + { header: "Status", key: "status", width: 12 }, + { header: t("fields.startDate"), key: t("fields.startDate"), width: 14 }, + { header: t("fields.endDate"), key: t("fields.endDate"), width: 14 }, + ], + rows, + }, + ], + }) +} + +/** + * 导出课程选课名单到 Excel + * Sheet 1: 选课名单 + */ +export async function exportCourseSelectionsToExcel(params: { + courseId: string +}): Promise { + const t = await getTranslations("elective") + + const selections = await getCourseSelections(params.courseId) + + const rows = selections.map((s, idx) => ({ + "#": idx + 1, + [t("fields.name")]: s.studentName ?? "", + status: t(`selectionStatus.${s.status}`), + priority: s.priority ?? 1, + selectedAt: s.selectedAt.split("T")[0], + enrolledAt: s.enrolledAt ? s.enrolledAt.split("T")[0] : "", + })) + + return exportToExcel({ + sheets: [ + { + name: t("student.mySelections"), + columns: [ + { header: "#", key: "#", width: 6 }, + { header: t("fields.name"), key: t("fields.name"), width: 18 }, + { header: "Status", key: "status", width: 12 }, + { header: "Priority", key: "priority", width: 10 }, + { header: "Selected At", key: "selectedAt", width: 14 }, + { header: "Enrolled At", key: "enrolledAt", width: 14 }, + ], + rows, + }, + ], + }) +} diff --git a/src/modules/elective/resolvers.ts b/src/modules/elective/resolvers.ts new file mode 100644 index 0000000..021ea19 --- /dev/null +++ b/src/modules/elective/resolvers.ts @@ -0,0 +1,83 @@ +import "server-only" + +/** + * 选修课模块跨模块依赖的接口抽象(P2-2.1.4)。 + * + * 目的:将 elective 对 school/users/classes 模块的直接 import 收敛到此文件, + * 便于单测时 mock 单一入口,未来替换实现只需改此文件。 + * + * 注意:实际实现仍委托给各模块的 data-access,此处仅做接口聚合。 + */ + +import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access" +import { getUserNamesByIds } from "@/modules/users/data-access" +import { getStudentActiveGradeId } from "@/modules/classes/data-access" + +/** 科目/年级/教师名称解析接口 */ +export interface CourseDisplayResolver { + /** 根据教师 ID 列表获取用户名映射 */ + getUserNamesByIds: (ids: string[]) => Promise> + /** 获取所有科目选项 */ + getSubjectOptions: () => Promise> + /** 获取所有年级选项 */ + getGradeOptions: () => Promise> +} + +/** 学生年级解析接口 */ +export interface StudentGradeResolver { + /** 获取学生当前激活的年级 ID */ + getStudentActiveGradeId: (studentId: string) => Promise +} + +/** + * 默认实现:委托给各模块的 data-access。 + * 单测时可注入 mock 实现替换。 + */ +export const defaultCourseDisplayResolver: CourseDisplayResolver = { + getUserNamesByIds, + getSubjectOptions, + getGradeOptions, +} + +export const defaultStudentGradeResolver: StudentGradeResolver = { + getStudentActiveGradeId, +} + +/** + * 可注入的解析器实例(单测时可覆盖)。 + * 使用闭包而非全局可变变量,避免并发测试污染。 + */ +let courseDisplayResolver: CourseDisplayResolver = defaultCourseDisplayResolver +let studentGradeResolver: StudentGradeResolver = defaultStudentGradeResolver + +/** + * 获取当前注入的课程显示名称解析器。 + */ +export function getCourseDisplayResolver(): CourseDisplayResolver { + return courseDisplayResolver +} + +/** + * 获取当前注入的学生年级解析器。 + */ +export function getStudentGradeResolver(): StudentGradeResolver { + return studentGradeResolver +} + +/** + * 注入自定义解析器(仅用于测试)。 + * 调用后需在测试结束后调用 `resetResolvers()` 恢复默认实现。 + */ +export function setCourseDisplayResolver(resolver: CourseDisplayResolver): void { + courseDisplayResolver = resolver +} + +export function setStudentGradeResolver(resolver: StudentGradeResolver): void { + studentGradeResolver = resolver +} + +/** 恢复默认解析器(测试 teardown 调用) */ +export function resetResolvers(): void { + courseDisplayResolver = defaultCourseDisplayResolver + studentGradeResolver = defaultStudentGradeResolver +} diff --git a/src/modules/parent/components/parent-attendance-calendar.tsx b/src/modules/parent/components/parent-attendance-calendar.tsx index 09dca06..332dc88 100644 --- a/src/modules/parent/components/parent-attendance-calendar.tsx +++ b/src/modules/parent/components/parent-attendance-calendar.tsx @@ -1,7 +1,7 @@ "use client" import { ChevronLeft, ChevronRight } from "lucide-react" -import { useState } from "react" +import { useState, useRef, type KeyboardEvent } from "react" import { useTranslations } from "next-intl" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -78,6 +78,7 @@ export function isSameDay(a: Date, b: Date): boolean { /** * 家长视角的考勤月历视图。 * 基于子女的近期考勤记录,在月历上按状态着色,让家长直观看到出勤分布。 + * 支持键盘方向键导航(a11y):← → ↑ ↓ 在日期格子间移动,Home/End 跳至行首/尾。 */ export function ParentAttendanceCalendar({ summary, @@ -88,6 +89,8 @@ export function ParentAttendanceCalendar({ const now = new Date() const [viewYear, setViewYear] = useState(now.getFullYear()) const [viewMonth, setViewMonth] = useState(now.getMonth()) + const [focusedIdx, setFocusedIdx] = useState(-1) + const gridRef = useRef(null) const recordMap = new Map() for (const r of summary.recentRecords) { @@ -108,6 +111,7 @@ export function ParentAttendanceCalendar({ } else { setViewMonth((m) => m - 1) } + setFocusedIdx(-1) } const goNext = () => { if (viewMonth === 11) { @@ -116,6 +120,60 @@ export function ParentAttendanceCalendar({ } else { setViewMonth((m) => m + 1) } + setFocusedIdx(-1) + } + + /** + * 键盘导航:方向键在日期格子间移动,Home/End 跳至行首/尾。 + * 仅在非空格子(有 Date 的格子)间移动,跳过 null 占位。 + */ + const handleKeyDown = (e: KeyboardEvent) => { + if (focusedIdx < 0) return + const nonNullIndices = days + .map((d, i) => (d ? i : -1)) + .filter((i) => i >= 0) + const currentPos = nonNullIndices.indexOf(focusedIdx) + if (currentPos < 0) return + + const cols = 7 + const currentRow = Math.floor(currentPos / cols) + const currentCol = currentPos % cols + let nextPos = currentPos + + switch (e.key) { + case "ArrowRight": + nextPos = Math.min(currentPos + 1, nonNullIndices.length - 1) + break + case "ArrowLeft": + nextPos = Math.max(currentPos - 1, 0) + break + case "ArrowDown": { + const target = (currentRow + 1) * cols + currentCol + nextPos = target < nonNullIndices.length ? target : currentPos + break + } + case "ArrowUp": { + const target = (currentRow - 1) * cols + currentCol + nextPos = target >= 0 ? target : currentPos + break + } + case "Home": + nextPos = currentRow * cols + break + case "End": + nextPos = Math.min((currentRow + 1) * cols - 1, nonNullIndices.length - 1) + break + default: + return + } + + e.preventDefault() + const nextIdx = nonNullIndices[nextPos] + if (nextIdx !== undefined && nextIdx !== focusedIdx) { + setFocusedIdx(nextIdx) + const cell = gridRef.current?.querySelector(`[data-day-idx="${nextIdx}"]`) + cell?.focus() + } } const usedStatuses = new Set() @@ -155,26 +213,39 @@ export function ParentAttendanceCalendar({
))}
-
+
{days.map((d, idx) => { if (!d) return
const key = formatDateKey(d) const record = recordMap.get(key) const isToday = isSameDay(d, now) + const isFocused = focusedIdx === idx return (
setFocusedIdx(idx)} className={cn( "relative flex aspect-square flex-col items-center justify-center rounded-md border text-xs", - "min-h-[36px]", + "min-h-[36px] cursor-default", record ? "border-border bg-muted/30" : "border-transparent", isToday && "ring-2 ring-primary ring-offset-1", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", )} + role="gridcell" aria-label={ record ? `${formatDateKey(d)}: ${t(ATTENDANCE_STATUS_LABEL_KEYS[record.status])}` : formatDateKey(d) } + aria-selected={isFocused} > {d.getDate()} {record ? ( diff --git a/src/modules/textbooks/actions.ts b/src/modules/textbooks/actions.ts index 2f0534b..7056460 100644 --- a/src/modules/textbooks/actions.ts +++ b/src/modules/textbooks/actions.ts @@ -27,7 +27,9 @@ import { import { getKnowledgePointsWithRelations, getStudentKpMastery, + getClassKpMastery, } from "./data-access-graph"; +import { getClassStudents } from "@/modules/classes/data-access-students"; import { CreateTextbookSchema, UpdateTextbookSchema, @@ -387,11 +389,17 @@ export async function getKnowledgeGraphDataAction( masteryMap[kpId] = info; } } + // 无学生身份时 masteryMap 保持为空,前端将显示"未测评"状态 } else if (viewMode === "class-mastery") { - // 简化实现:暂不获取班级学生列表,返回空 masteryMap - // 后续迭代可通过 classes 模块获取教师所带班级学生 ID, - // 再从 data-access-graph 导入 getClassKpMastery 并调用 - // getClassKpMastery(studentIds, textbookId) 计算班级平均掌握度 + // 获取教师所带班级的所有学生 ID,计算班级平均掌握度 + const students = await getClassStudents({ status: "active" }); + const studentIds = students.map((s) => s.id); + if (studentIds.length > 0) { + const mastery = await getClassKpMastery(studentIds, textbookId); + for (const [kpId, info] of mastery) { + masteryMap[kpId] = info; + } + } } return { diff --git a/src/modules/textbooks/components/chapter-sidebar-list.tsx b/src/modules/textbooks/components/chapter-sidebar-list.tsx index 93ca80f..52f6496 100644 --- a/src/modules/textbooks/components/chapter-sidebar-list.tsx +++ b/src/modules/textbooks/components/chapter-sidebar-list.tsx @@ -40,7 +40,7 @@ interface SortableChapterItemProps { canEdit?: boolean } -function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: SortableChapterItemProps) { +function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: SortableChapterItemProps) { const t = useTranslations("textbooks") const [isOpen, setIsOpen] = useState(level === 0) const hasChildren = chapter.children && chapter.children.length > 0 @@ -158,7 +158,7 @@ function SortableChapterItem({ chapter, level, selectedId, onSelect, textbookId, ) } -function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = true }: { +function RecursiveSortableList({ items, level, selectedId, onSelect, textbookId, onDelete, onCreateSub, canEdit = false }: { items: Chapter[], level: number, selectedId?: string, @@ -195,7 +195,7 @@ interface ChapterSidebarListProps { canEdit?: boolean } -export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = true }: ChapterSidebarListProps) { +export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapter, textbookId, canEdit = false }: ChapterSidebarListProps) { const t = useTranslations("textbooks") const [showCreateDialog, setShowCreateDialog] = useState(false) const [createParentId, setCreateParentId] = useState(undefined) @@ -239,11 +239,11 @@ export function ChapterSidebarList({ chapters, selectedChapterId, onSelectChapte if (result.success) { toast.success(t("dialog.chapter.orderUpdated")) } else { - toast.error(result.message || t("dialog.chapter.orderUpdated")) + toast.error(result.message || t("dialog.chapter.orderUpdateFailed")) } } catch (e) { console.error("Failed to reorder chapters", e) - toast.error(t("dialog.chapter.orderUpdated")) + toast.error(t("dialog.chapter.orderUpdateFailed")) } } } diff --git a/src/modules/textbooks/components/create-chapter-dialog.tsx b/src/modules/textbooks/components/create-chapter-dialog.tsx index e8cc828..a13dd3c 100644 --- a/src/modules/textbooks/components/create-chapter-dialog.tsx +++ b/src/modules/textbooks/components/create-chapter-dialog.tsx @@ -81,6 +81,7 @@ export function CreateChapterDialog({ return ( {triggerNode ? {triggerNode} : null} + {/* 任意值 sm:max-w-[425px]:shadcn/ui Dialog 标准宽度约定 */} {t("dialog.chapter.createTitle")} diff --git a/src/modules/textbooks/components/graph-kp-node.tsx b/src/modules/textbooks/components/graph-kp-node.tsx index 30d7d83..c16bdcd 100644 --- a/src/modules/textbooks/components/graph-kp-node.tsx +++ b/src/modules/textbooks/components/graph-kp-node.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl" import { cn } from "@/shared/lib/utils" import type { GraphNodeData, MasteryLevel } from "../types" import type { GraphLayoutNodeData } from "../graph-layout" +import { NODE_WIDTH } from "../graph-layout" /** 根据掌握度计算色彩等级 */ function getMasteryLevel(mastery: number | null): MasteryLevel { @@ -47,12 +48,13 @@ function GraphKpNodeComponent({ data, selected }: NodeProps) { graphData?.isHighlighted && "ring-2 ring-primary", !graphData?.isHighlighted && graphData !== undefined && "opacity-40", )} - style={{ width: 180 }} + style={{ width: NODE_WIDTH }} >
{kp.name} + {/* 任意值 text-[10px]:图谱节点空间受限,text-xs(12px) 过大 */} {kp.questionCount > 0 && ( {kp.questionCount} {t("graph.node.questions")} diff --git a/src/modules/textbooks/components/graph-node-detail-panel.tsx b/src/modules/textbooks/components/graph-node-detail-panel.tsx index 0653677..43817a6 100644 --- a/src/modules/textbooks/components/graph-node-detail-panel.tsx +++ b/src/modules/textbooks/components/graph-node-detail-panel.tsx @@ -15,7 +15,6 @@ interface GraphNodeDetailPanelProps { prerequisites: { id: string; name: string; description: string | null }[] successors: { id: string; name: string; description: string | null }[] canEdit: boolean - textbookId: string onClose: () => void onJumpToKp: (kpId: string) => void onAddPrerequisite: () => void diff --git a/src/modules/textbooks/components/graph-toolbar.tsx b/src/modules/textbooks/components/graph-toolbar.tsx index 5bbd2ba..1f4fdc3 100644 --- a/src/modules/textbooks/components/graph-toolbar.tsx +++ b/src/modules/textbooks/components/graph-toolbar.tsx @@ -1,7 +1,7 @@ "use client" import { useTranslations } from "next-intl" -import { Search, RotateCcw } from "lucide-react" +import { Search, RotateCcw, Loader2 } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" import { @@ -20,6 +20,8 @@ interface GraphToolbarProps { searchText: string onSearchChange: (text: string) => void onResetView: () => void + /** 切换模式刷新中(显示轻量指示器,保留旧数据) */ + isRefreshing?: boolean } const ALL_VIEW_MODES: readonly GraphViewMode[] = [ @@ -39,6 +41,7 @@ export function GraphToolbar({ searchText, onSearchChange, onResetView, + isRefreshing, }: GraphToolbarProps) { const t = useTranslations("textbooks") @@ -67,6 +70,7 @@ export function GraphToolbar({ + {/* 任意值 min-w-[120px]:搜索框最小宽度,防止压缩过窄无法输入 */}
{t("graph.toolbar.resetView")} + + {isRefreshing && ( + + )}
) } diff --git a/src/modules/textbooks/components/knowledge-graph.tsx b/src/modules/textbooks/components/knowledge-graph.tsx index 4a82e71..67ade83 100644 --- a/src/modules/textbooks/components/knowledge-graph.tsx +++ b/src/modules/textbooks/components/knowledge-graph.tsx @@ -66,7 +66,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know const t = useTranslations("textbooks") const { hasPermission } = usePermission() const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE) - const isTeacher = hasPermission(Permissions.TEXTBOOK_UPDATE) const reactFlow = useReactFlow() const [viewMode, setViewMode] = useState(initialViewMode) @@ -77,9 +76,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know const [newPrereqId, setNewPrereqId] = useState("") const [isSavingPrereq, setIsSavingPrereq] = useState(false) - const { data, isLoading, error, reload } = useGraphData(textbookId, viewMode) + const { data, isLoading, isRefreshing, error, reload } = useGraphData(textbookId, viewMode) - const availableViewModes: GraphViewMode[] = isTeacher + // 教师可查看班级掌握度,学生可查看个人掌握度 + const availableViewModes: GraphViewMode[] = canEdit ? ["structure", "class-mastery"] : ["structure", "student-mastery"] @@ -286,6 +286,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know searchText={searchText} onSearchChange={setSearchText} onResetView={resetView} + isRefreshing={isRefreshing} />
{selectedKp && ( + // 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
setSelectedKpId(null)} onJumpToKp={onJumpToKp} onAddPrerequisite={() => setAddPrereqOpen(true)} diff --git a/src/modules/textbooks/components/knowledge-point-dialogs.tsx b/src/modules/textbooks/components/knowledge-point-dialogs.tsx index 2ef7657..97f39c6 100644 --- a/src/modules/textbooks/components/knowledge-point-dialogs.tsx +++ b/src/modules/textbooks/components/knowledge-point-dialogs.tsx @@ -144,6 +144,7 @@ export function KnowledgePointDialogs({ className="text-sm font-mono" required /> + {/* 任意值 text-[10px]:辅助提示文案,text-xs(12px) 过大 */}

{t("anchorTextHint")}

diff --git a/src/modules/textbooks/components/knowledge-point-list.tsx b/src/modules/textbooks/components/knowledge-point-list.tsx index d708b06..f03800f 100644 --- a/src/modules/textbooks/components/knowledge-point-list.tsx +++ b/src/modules/textbooks/components/knowledge-point-list.tsx @@ -1,6 +1,6 @@ "use client" -import { PlusCircle, Pencil, Trash2 } from "lucide-react" +import { PlusCircle, Pencil, Trash2, Tag } from "lucide-react" import { useTranslations } from "next-intl" import type { KnowledgePoint } from "../types" @@ -8,6 +8,7 @@ import { cn } from "@/shared/lib/utils" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" interface KnowledgePointListProps { knowledgePoints: KnowledgePoint[] @@ -35,9 +36,12 @@ export function KnowledgePointList({ if (knowledgePoints.length === 0) { return ( -
- {t("reader.emptyKnowledge")} -
+ ) } @@ -57,6 +61,7 @@ export function KnowledgePointList({

{kp.name}

+ {/* 任意值 text-[10px]:紧凑徽章,text-xs(12px) 在列表项中过大 */} {t("panel.level")} {kp.level} diff --git a/src/modules/textbooks/components/section-error-boundary.tsx b/src/modules/textbooks/components/section-error-boundary.tsx index 085d34d..7809ef9 100644 --- a/src/modules/textbooks/components/section-error-boundary.tsx +++ b/src/modules/textbooks/components/section-error-boundary.tsx @@ -44,20 +44,25 @@ export class TextbookSectionErrorBoundary extends Component< render(): ReactNode { if (this.state.hasError) { + // 默认值为空字符串,强制调用方传入 i18n 文案 + const title = this.props.fallbackTitle ?? "" + const description = this.props.fallbackDescription ?? "" + const retryLabel = this.props.retryLabel ?? "" return ( + // 任意值 min-h-[200px]:错误降级 UI 最小高度,保证视觉占位
-
-

- {this.props.fallbackTitle ?? "区块加载失败"} -

-

- {this.props.fallbackDescription ?? "请重试或刷新页面"} -

-
- + {title && ( +

{title}

+ )} + {description && ( +

{description}

+ )} + {retryLabel && ( + + )}
) } diff --git a/src/modules/textbooks/components/teacher-textbook-reader.tsx b/src/modules/textbooks/components/teacher-textbook-reader.tsx deleted file mode 100644 index 283da28..0000000 --- a/src/modules/textbooks/components/teacher-textbook-reader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -"use client" - -import type { ReactNode } from "react" -import { useTranslations } from "next-intl" -import { TextbookReader, type TextbookReaderProps } from "./textbook-reader" -import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" -import type { KnowledgePoint } from "../types" - -/** - * 教师端 TextbookReader 包装组件。 - * - * 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader) - * 传递函数 prop(renderQuestionCreator)。此包装组件在客户端层组装 - * renderQuestionCreator,避免违反 Next.js App Router 的 Server→Client 序列化约束。 - */ -export function TeacherTextbookReader({ - chapters, - textbookId, -}: { - chapters: TextbookReaderProps["chapters"] - textbookId: string -}) { - const t = useTranslations("textbooks") - const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({ - open, - onOpenChange, - targetKp, - }: { - open: boolean - onOpenChange: (open: boolean) => void - targetKp: KnowledgePoint | null - }): ReactNode => ( - - ) - - return ( - - ) -} diff --git a/src/modules/textbooks/components/textbook-card.tsx b/src/modules/textbooks/components/textbook-card.tsx index 801e5c1..d5d102c 100644 --- a/src/modules/textbooks/components/textbook-card.tsx +++ b/src/modules/textbooks/components/textbook-card.tsx @@ -103,6 +103,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
+ {/* 任意值 max-w-[120px]:出版社名称截断宽度,防止卡片布局错乱 */} {textbook.publisher || t("card.publisherNA")} diff --git a/src/modules/textbooks/components/textbook-content-panel.tsx b/src/modules/textbooks/components/textbook-content-panel.tsx index 3a03277..d63b80d 100644 --- a/src/modules/textbooks/components/textbook-content-panel.tsx +++ b/src/modules/textbooks/components/textbook-content-panel.tsx @@ -4,13 +4,14 @@ import ReactMarkdown from "react-markdown" import remarkBreaks from "remark-breaks" import remarkGfm from "remark-gfm" import rehypeSanitize from "rehype-sanitize" -import { Edit2, Save, Plus } from "lucide-react" +import { Edit2, Save, Plus, BookOpen } from "lucide-react" import { useTranslations } from "next-intl" -import type { Chapter, KnowledgePoint } from "../types" +import type { Chapter } from "../types" import { cn } from "@/shared/lib/utils" import { ScrollArea } from "@/shared/components/ui/scroll-area" import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" import { ContextMenu, ContextMenuContent, @@ -25,7 +26,6 @@ interface TextbookContentPanelProps { editContent: string setEditContent: (content: string) => void canEdit: boolean - knowledgePoints: KnowledgePoint[] highlightedKpId: string | null onHighlight: (id: string) => void onSwitchToKnowledgeTab: () => void @@ -33,10 +33,7 @@ interface TextbookContentPanelProps { onPointerDown: (e: React.PointerEvent) => void onContextMenuChange: (open: boolean) => void selectedText: string - createDialogOpen: boolean setCreateDialogOpen: (open: boolean) => void - isCreating: boolean - onCreateKnowledgePoint: (formData: FormData) => Promise startEditing: () => void cancelEditing: () => void saveContent: () => void @@ -68,9 +65,12 @@ export function TextbookContentPanel({ if (!selected) { return ( -
- {t("reader.selectChapter")} -
+ ) } @@ -105,6 +105,7 @@ export function TextbookContentPanel({
@@ -129,6 +130,10 @@ export function TextbookContentPanel({ return ( { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onHighlight(id) + onSwitchToKnowledgeTab() + } + }} title={t("reader.clickToViewKp")} > {children} @@ -152,9 +164,12 @@ export function TextbookContentPanel({
) : ( -
- {t("reader.emptyContent")} -
+ )}
diff --git a/src/modules/textbooks/components/textbook-filters.tsx b/src/modules/textbooks/components/textbook-filters.tsx index ad48a3a..2b32ab8 100644 --- a/src/modules/textbooks/components/textbook-filters.tsx +++ b/src/modules/textbooks/components/textbook-filters.tsx @@ -39,6 +39,7 @@ export function TextbookFilters() {