feat(attendance,elective): 实现所有 P2 长期改进项

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 相关零错误
This commit is contained in:
SpecialX
2026-06-23 09:02:41 +08:00
parent c766951374
commit e2e0487a3b
50 changed files with 1514 additions and 411 deletions

View File

@@ -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-23parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard
- ✅ v3-P3-4 改进2026-06-23GradeTrendCard 新增日期范围选择器(全部/近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/normalize20 行)
- ✅ P3 修复2026-06-23~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数
- ✅ P3 修复2026-06-23~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言
@@ -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 + 客户端分页 UIPAGE_SIZE=20✅ V2-P1-2客户端过滤仅在初始数据时执行 |
| `components/message-detail.tsx` | 消息详情(含回复) |
| `components/message-compose.tsx` | 撰写新消息(✅ V2-P1-4fieldErrors + aria-invalid 字段级错误展示) |
| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 60 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1POLL_INTERVAL_MS 常量) |
| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 30 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1POLL_INTERVAL_MS 常量;✅ V2-P3间隔从 60s 缩短为 30s 与通知一致 |
**客户端行为**
- `message-list.tsx`:客户端调用 `getMessagesAction` 搜索消息useMessageSearch hook400ms 防抖请求竞态取消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 实时推送 HookEventSource + 轮询降级) |
**组件清单**
| 组件 | 职责 |
|------|------|
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1useTranslations 命名空间从 "messages" 切换到 "notifications" |
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,每 30 秒轮询 `getNotificationsAction` + `getUnreadNotificationCountAction`;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_INTERVAL_MS 常量) |
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1useTranslations 命名空间切换;✅ V2-P2-1POLL_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()` 包装实现请求级 memoizationP3-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/getActiveStudentIdsByClassIdv2-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/getActiveStudentIdsByClassIdv2-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` propstring | nullnull 时隐藏练习按钮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` | 通知实时推送SSE15s 心跳 + 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`

View File

@@ -7031,19 +7031,121 @@
]
},
{
"name": "toggleTwoFactorAction",
"name": "setupTwoFactorAction",
"file": "actions-security.ts",
"permission": "USER_PROFILE_UPDATE",
"signature": "(enabled: boolean) => Promise<ActionState<TwoFactorStatus>>",
"purpose": "启用/禁用 2FAP2-9 新增占位实现v2 已禁用开关,显示'即将推出'提示,避免虚假安全感",
"signature": "() => Promise<ActionState<TwoFactorSetupData>>",
"purpose": "2FA 启用流程第一步:生成 TOTP 密钥 + QR 码 Data URLv3 新增:完整 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<ActionState<{ backupCodes: string[]; status: TwoFactorStatus }>>",
"purpose": "2FA 启用流程第二步:校验一次性码 + 生成 10 个备份码bcrypt 哈希存储)+ 启用 2FAv3 新增)",
"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<ActionState<TwoFactorStatus>>",
"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<ActionState<{ backupCodes: string[]; status: TwoFactorStatus }>>",
"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/<60v4-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 秒轮询 getUnreadMessageCountActionV2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量)"
"purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 30 秒轮询 getUnreadMessageCountActionV2-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<MasteryWithKnowledgePoint[]>",
"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<StudentMasterySummary | null>",
"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<string>",
"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<string>",
"file": "data-access-reports.ts",
"purpose": "生成班级诊断报告聚合班级掌握度识别薄弱知识点status=draftstudentId 存生成者 ID",
"purpose": "生成班级诊断报告聚合班级掌握度识别薄弱知识点status=draftstudentId 存生成者 ID。P3-27 修复:使用 DiagnosticReportError 结构化错误码",
"deps": [
"shared.db",
"shared.db.schema.learningDiagnosticReports",
@@ -12738,19 +12840,20 @@
},
{
"name": "getDiagnosticReports",
"signature": "(filters: DiagnosticReportQueryParams) => Promise<DiagnosticReportWithDetails[]>",
"signature": "(filters: DiagnosticReportQueryParams, scope?: DataScope) => Promise<DiagnosticReportListResult>",
"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 propnull 时隐藏练习按钮)",
"purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示v3-P2 新增practiceHrefBase propnull 时隐藏练习按钮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渲染 GradeTrendCardv3-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"

View File

@@ -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,8 +56,7 @@ export default async function AdminAttendancePage({
date: date && date.length > 0 ? date : undefined,
})
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
const header = (
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminOverview")}</h2>
@@ -69,11 +69,14 @@ export default async function AdminAttendancePage({
</Link>
</Button>
</div>
)
<AttendanceStatsCards stats={stats} />
<AttendanceFilters classes={classOptions} />
return (
<AttendancePageLayout
header={header}
stats={<AttendanceStatsCards stats={stats} />}
filters={<AttendanceFilters classes={classOptions} />}
>
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title={t("list.empty")}
@@ -83,6 +86,6 @@ export default async function AdminAttendancePage({
) : (
<AttendanceRecordList records={result.items} />
)}
</div>
</AttendancePageLayout>
)
}

View File

@@ -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 })
return (
<div className="flex h-full flex-col space-y-8 p-8">
const header = (
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">{t("title.adminList")}</h2>
<p className="text-muted-foreground">
{t("description.adminList")}
</p>
</div>
)
return (
<ElectivePageLayout header={header}>
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editBaseHref="/admin/elective"
/>
</div>
</ElectivePageLayout>
)
}

View File

@@ -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,8 +63,7 @@ export default async function TeacherAttendancePage({
const pagedRecords = paginate(result.items, currentPage, PAGE_SIZE)
const hasFilters = Boolean(classId || status || date)
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
const header = (
<div className="flex items-center justify-between space-y-2">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacherRecords")}</h1>
@@ -84,9 +84,13 @@ export default async function TeacherAttendancePage({
</Button>
</div>
</div>
)
<AttendanceFilters classes={classOptions} />
return (
<AttendancePageLayout
header={header}
filters={<AttendanceFilters classes={classOptions} />}
>
{result.items.length === 0 && !hasFilters ? (
<EmptyState
title={t("list.empty")}
@@ -113,6 +117,6 @@ export default async function TeacherAttendancePage({
) : null}
</div>
)}
</div>
</AttendancePageLayout>
)
}

View File

@@ -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<SearchParams>
}): Promise<JSX.Element> {
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 })
: []
return (
<div className="flex h-full flex-col space-y-8 p-8">
const header = (
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.teacher")}</h1>
<p className="text-muted-foreground">{t("description.teacher")}</p>
</div>
)
return (
<ElectivePageLayout header={header}>
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editBaseHref="/admin/elective"
/>
</div>
</ElectivePageLayout>
)
}

View File

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

View File

@@ -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<ReturnType<typeof requirePermission>>
): 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<ReturnType<typeof requirePermission>>
): 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<ActionState<string>> {
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<ActionState<number>> {
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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<string>> {
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)
}
}

View File

@@ -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 (
<div className={cn("h-full flex-1 flex-col space-y-8 p-8 md:flex", className)}>
{header}
{stats}
{filters}
{children}
</div>
)
}

View File

@@ -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<StudentAttendanceSummary | null> {
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<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
absent: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
late: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
earlyLeave: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
excused: sql<number>`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({

View File

@@ -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<Buffer> {
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,
},
],
})
}

View File

@@ -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<never> => {
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<ReturnType<typeof requirePermission>>
): 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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<{ enrolled: number; waitlist: number }>> {
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<ActionState<string>> {
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<ActionState<string>> {
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)
}
}

View File

@@ -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 (
<div className={cn("flex h-full flex-col space-y-8 p-8", className)}>
{header}
{children}
</div>
)
}

View File

@@ -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<string, string> = {
"周一": "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<Parameters<typeof db.transaction>[0]>[0],
studentId: string,
newCourseId: string
): Promise<boolean> {
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<Parameters<typeof db.transaction>[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

View File

@@ -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<Map<string, string | null>> => {
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<string, string | null>()
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<string | null> => {
return getStudentActiveGradeId(studentId)
return getStudentGradeResolver().getStudentActiveGradeId(studentId)
})
export const getAvailableCoursesForStudent = cache(

View File

@@ -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<string, string | null>
subjectNames: Map<string, string>
gradeNames: Map<string, string>
}> => {
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<string, string | null>()
@@ -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

View File

@@ -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<Buffer> {
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<Buffer> {
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,
},
],
})
}

View File

@@ -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<Map<string, { name: string | null }>>
/** 获取所有科目选项 */
getSubjectOptions: () => Promise<Array<{ id: string; name: string }>>
/** 获取所有年级选项 */
getGradeOptions: () => Promise<Array<{ id: string; name: string }>>
}
/** 学生年级解析接口 */
export interface StudentGradeResolver {
/** 获取学生当前激活的年级 ID */
getStudentActiveGradeId: (studentId: string) => Promise<string | null>
}
/**
* 默认实现:委托给各模块的 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
}

View File

@@ -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<number>(-1)
const gridRef = useRef<HTMLDivElement>(null)
const recordMap = new Map<string, ParentAttendanceListItem>()
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<HTMLDivElement>) => {
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<HTMLElement>(`[data-day-idx="${nextIdx}"]`)
cell?.focus()
}
}
const usedStatuses = new Set<ParentAttendanceStatus>()
@@ -155,26 +213,39 @@ export function ParentAttendanceCalendar({
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
<div
ref={gridRef}
className="grid grid-cols-7 gap-1"
onKeyDown={handleKeyDown}
role="grid"
aria-label={monthLabel}
>
{days.map((d, idx) => {
if (!d) return <div key={`empty-${idx}`} className="aspect-square" />
const key = formatDateKey(d)
const record = recordMap.get(key)
const isToday = isSameDay(d, now)
const isFocused = focusedIdx === idx
return (
<div
key={key}
data-day-idx={idx}
tabIndex={isFocused || (focusedIdx < 0 && isToday) ? 0 : -1}
onFocus={() => 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}
>
<span className="tabular-nums">{d.getDate()}</span>
{record ? (

View File

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

View File

@@ -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<string | undefined>(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"))
}
}
}

View File

@@ -81,6 +81,7 @@ export function CreateChapterDialog({
return (
<Dialog open={open} onOpenChange={setOpen}>
{triggerNode ? <DialogTrigger asChild>{triggerNode}</DialogTrigger> : null}
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.chapter.createTitle")}</DialogTitle>

View File

@@ -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 }}
>
<Handle type="target" position={Position.Top} className="opacity-0" />
<div className="flex items-start justify-between gap-2 mb-1">
<span className="text-xs font-medium line-clamp-2 flex-1">{kp.name}</span>
{/* 任意值 text-[10px]图谱节点空间受限text-xs(12px) 过大 */}
{kp.questionCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary shrink-0">
{kp.questionCount} {t("graph.node.questions")}

View File

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

View File

@@ -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({
</SelectContent>
</Select>
{/* 任意值 min-w-[120px]:搜索框最小宽度,防止压缩过窄无法输入 */}
<div className="relative flex-1 min-w-[120px]">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
@@ -81,6 +85,10 @@ export function GraphToolbar({
<RotateCcw className="h-3 w-3 mr-1" />
<span className="text-xs">{t("graph.toolbar.resetView")}</span>
</Button>
{isRefreshing && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" aria-label={t("graph.toolbar.refreshing")} />
)}
</div>
)
}

View File

@@ -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<GraphViewMode>(initialViewMode)
@@ -77,9 +76,10 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
const [newPrereqId, setNewPrereqId] = useState<string>("")
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}
/>
<div className="flex-1 min-h-0 relative">
<ReactFlow
@@ -315,6 +316,7 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
</div>
{selectedKp && (
// 任意值 w-[300px]:详情面板固定宽度,保证内容可读性
<div className="w-[300px] shrink-0">
<GraphNodeDetailPanel
kp={selectedKp}
@@ -322,7 +324,6 @@ function KnowledgeGraphInner({ textbookId, initialViewMode = "structure" }: Know
prerequisites={prerequisites}
successors={successors}
canEdit={canEdit}
textbookId={textbookId}
onClose={() => setSelectedKpId(null)}
onJumpToKp={onJumpToKp}
onAddPrerequisite={() => setAddPrereqOpen(true)}

View File

@@ -144,6 +144,7 @@ export function KnowledgePointDialogs({
className="text-sm font-mono"
required
/>
{/* 任意值 text-[10px]辅助提示文案text-xs(12px) 过大 */}
<p className="text-[10px] text-muted-foreground mt-1">{t("anchorTextHint")}</p>
</div>
</div>

View File

@@ -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 (
<div className="flex h-full items-center justify-center text-muted-foreground p-4 text-center text-sm">
{t("reader.emptyKnowledge")}
</div>
<EmptyState
icon={Tag}
title={t("reader.emptyKnowledge")}
description={t("reader.emptyKnowledgeDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
@@ -57,6 +61,7 @@ export function KnowledgePointList({
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-none">{kp.name}</h4>
<div className="flex items-center gap-1">
{/* 任意值 text-[10px]紧凑徽章text-xs(12px) 在列表项中过大 */}
<Badge variant="outline" className="text-[10px] h-5 px-1">
{t("panel.level")}
{kp.level}

View File

@@ -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 最小高度,保证视觉占位
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 p-6 text-center">
<AlertCircle className="h-8 w-8 text-muted-foreground" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.fallbackTitle ?? "区块加载失败"}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
</p>
</div>
{title && (
<p className="text-sm font-medium text-foreground">{title}</p>
)}
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{retryLabel && (
<Button size="sm" variant="outline" onClick={this.handleReset}>
{this.props.retryLabel ?? "重试"}
{retryLabel}
</Button>
)}
</div>
)
}

View File

@@ -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 ComponentTextbookReader
* 传递函数 proprenderQuestionCreator。此包装组件在客户端层组装
* 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 => (
<CreateQuestionDialog
open={open}
onOpenChange={onOpenChange}
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
defaultType="text"
/>
)
return (
<TextbookReader
key={textbookId}
chapters={chapters}
textbookId={textbookId}
renderQuestionCreator={renderQuestionCreator}
/>
)
}

View File

@@ -103,6 +103,7 @@ export function TextbookCard({ textbook, hrefBase, hideActions }: TextbookCardPr
</div>
<div className="flex items-center gap-1.5">
<Building2 className="h-3.5 w-3.5" />
{/* 任意值 max-w-[120px]:出版社名称截断宽度,防止卡片布局错乱 */}
<span className="truncate max-w-[120px]" title={textbook.publisher || ""}>
{textbook.publisher || t("card.publisherNA")}
</span>

View File

@@ -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<boolean | void>
startEditing: () => void
cancelEditing: () => void
saveContent: () => void
@@ -68,9 +65,12 @@ export function TextbookContentPanel({
if (!selected) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground px-2">
{t("reader.selectChapter")}
</div>
<EmptyState
icon={BookOpen}
title={t("reader.selectChapter")}
description={t("reader.selectChapterDesc")}
className="h-full border-none shadow-none bg-transparent"
/>
)
}
@@ -105,6 +105,7 @@ export function TextbookContentPanel({
<RichTextEditor
value={editContent}
onChange={setEditContent}
// 任意值 min-h-[500px]:编辑器最小高度,保证可编辑区域可用空间
className="min-h-[500px] border-none shadow-none"
/>
</div>
@@ -129,6 +130,10 @@ export function TextbookContentPanel({
return (
<span
data-kp-id={id}
role="button"
tabIndex={0}
aria-label={t("reader.clickToViewKp")}
aria-pressed={isHighlighted}
className={cn(
"font-medium text-primary cursor-pointer hover:underline decoration-dashed underline-offset-4 transition-all duration-300",
isHighlighted && "bg-yellow-300 dark:bg-yellow-600 text-black dark:text-white rounded px-1 py-0.5 shadow-sm scale-110 inline-block mx-0.5 font-bold ring-2 ring-yellow-400/50 dark:ring-yellow-500/50"
@@ -138,6 +143,13 @@ export function TextbookContentPanel({
onHighlight(id)
onSwitchToKnowledgeTab()
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onHighlight(id)
onSwitchToKnowledgeTab()
}
}}
title={t("reader.clickToViewKp")}
>
{children}
@@ -152,9 +164,12 @@ export function TextbookContentPanel({
</ReactMarkdown>
</div>
) : (
<div className="text-muted-foreground italic py-8 text-center">
{t("reader.emptyContent")}
</div>
<EmptyState
icon={BookOpen}
title={t("reader.emptyContent")}
description={t("reader.emptyContentDesc")}
className="h-64 border-none shadow-none bg-transparent"
/>
)}
</div>
</ContextMenuTrigger>

View File

@@ -39,6 +39,7 @@ export function TextbookFilters() {
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
{/* 任意值 w-[140px]:学科选择器固定宽度,保证触发器不随内容变化 */}
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("field.subject")} />
</SelectTrigger>
@@ -53,6 +54,7 @@ export function TextbookFilters() {
</Select>
<Select value={grade} onValueChange={(val) => setGrade(val === "all" ? null : val)}>
{/* 任意值 w-[130px]:年级选择器固定宽度,保证触发器不随内容变化 */}
<SelectTrigger className="w-[130px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("field.grade")} />
</SelectTrigger>

View File

@@ -64,6 +64,7 @@ export function TextbookFormDialog() {
{t("list.add")}
</Button>
</DialogTrigger>
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.create.title")}</DialogTitle>

View File

@@ -2,9 +2,10 @@
import { useMemo, useState, useEffect, useRef, type ReactNode } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Tag, List, Share2, Menu } from "lucide-react"
import { Tag, List, Share2, Menu, GraduationCap } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import Link from "next/link"
import type { Chapter, KnowledgePoint } from "../types"
import { updateChapterContentAction, getKnowledgePointsByChapterAction } from "../actions"
@@ -77,6 +78,7 @@ export function TextbookReader({
// P0-2 前端权限改由 usePermission 判断,不再接受外部 canEdit 硬编码
const canEdit = hasPermission(Permissions.TEXTBOOK_UPDATE)
const canCreateQuestion = hasPermission(Permissions.QUESTION_CREATE)
const canCreateLessonPlan = hasPermission(Permissions.LESSON_PLAN_CREATE)
const [chapterId, setChapterId] = useQueryState("chapterId", parseAsString.withDefault(""))
const [activeTab, setActiveTab] = useState("chapters")
@@ -249,6 +251,7 @@ export function TextbookReader({
<TabsTrigger value="knowledge" className="gap-2" disabled={!selectedId}>
<Tag className="h-4 w-4" />
{t("reader.tabs.knowledge")}
{/* 任意值 text-[10px]紧凑徽章text-xs(12px) 在标签栏中过大 */}
{currentChapterKPs.length > 0 && (
<Badge variant="secondary" className="ml-1 px-1 py-0 h-5 text-[10px]">
{currentChapterKPs.length}
@@ -341,6 +344,7 @@ export function TextbookReader({
{/* P2-4 移动端侧栏lg 以下用 Sheet 抽屉式展示 */}
<Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
{/* 任意值 w-[85vw]移动端抽屉占视口宽度max-w-sm 防止超宽屏过大 */}
<SheetContent side="left" className="w-[85vw] max-w-sm p-0 flex flex-col">
<SheetHeader className="px-4 py-3 border-b shrink-0">
<SheetTitle className="text-left">{t("reader.sidebar")}</SheetTitle>
@@ -350,12 +354,13 @@ export function TextbookReader({
</Sheet>
<div className="lg:col-span-8 flex flex-col min-h-0 relative">
{/* P2-4 移动端侧栏触发按钮 */}
<div className="lg:hidden flex items-center gap-2 mb-3 px-2 shrink-0">
{/* P2-4 移动端侧栏触发按钮 + 为此课文备课按钮 */}
<div className="flex items-center gap-2 mb-3 px-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => setMobileSidebarOpen(true)}
className="lg:hidden"
aria-expanded={mobileSidebarOpen}
aria-controls="mobile-sidebar-sheet"
>
@@ -363,10 +368,20 @@ export function TextbookReader({
{t("reader.openSidebar")}
</Button>
{selected && (
<span className="text-sm text-muted-foreground truncate">
<span className="text-sm text-muted-foreground truncate hidden lg:inline">
{selected.title}
</span>
)}
{selected && canCreateLessonPlan && (
<Button asChild variant="default" size="sm" className="ml-auto">
<Link
href={`/teacher/lesson-plans/new?textbookId=${encodeURIComponent(textbookId)}&chapterId=${encodeURIComponent(selected.id)}`}
>
<GraduationCap className="mr-2 h-4 w-4" />
{t("reader.prepareLesson")}
</Link>
</Button>
)}
</div>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>

View File

@@ -99,6 +99,7 @@ export function TextbookSettingsDialog({ textbook, trigger }: TextbookSettingsDi
</Button>
)}
</DialogTrigger>
{/* 任意值 sm:max-w-[425px]shadcn/ui Dialog 标准宽度约定 */}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t("dialog.settings.title")}</DialogTitle>

View File

@@ -57,14 +57,18 @@ export const getKnowledgePointsWithRelations = cache(async (
questionCountMap.set(r.knowledgePointId, Number(r.count))
}
// 3. 查询前置依赖(批量)
// 3. 查询前置依赖(批量,仅查询属于当前教材知识点的依赖
// 双向过滤knowledgePointId 和 prerequisiteKpId 都必须在当前教材的知识点集合内
const prereqRows = await db
.select({
knowledgePointId: knowledgePointPrerequisites.knowledgePointId,
prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId,
})
.from(knowledgePointPrerequisites)
.where(inArray(knowledgePointPrerequisites.knowledgePointId, kpIds))
.where(and(
inArray(knowledgePointPrerequisites.knowledgePointId, kpIds),
inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds),
))
const prereqMap = new Map<string, string[]>()
for (const r of prereqRows) {

View File

@@ -60,5 +60,69 @@ describe("textbooks/graph-layout", () => {
expect(node.position.y).toBeGreaterThanOrEqual(0)
}
})
it("should not create edge for non-existent parentId", () => {
const layout = computeGraphLayout([makeKp("1", "nonexistent")])
expect(layout.nodes).toHaveLength(1)
expect(layout.edges).toHaveLength(0)
})
it("should not create edge for non-existent prerequisiteIds", () => {
const layout = computeGraphLayout([makeKp("1", null, ["nonexistent"])])
expect(layout.nodes).toHaveLength(1)
expect(layout.edges).toHaveLength(0)
})
it("should generate both parent and prerequisite edges for one node", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2"),
makeKp("3", "1", ["2"]),
])
const parentEdge = layout.edges.find((e) => e.id === "parent-1-3")
const prereqEdge = layout.edges.find((e) => e.id === "prereq-2-3")
expect(parentEdge).toBeDefined()
expect(prereqEdge).toBeDefined()
expect(layout.edges).toHaveLength(2)
})
it("should return positive width/height for non-empty graph", () => {
const layout = computeGraphLayout([
makeKp("1"),
makeKp("2", "1"),
makeKp("3", "1"),
])
expect(layout.width).toBeGreaterThan(0)
expect(layout.height).toBeGreaterThan(0)
})
it("should handle deeply nested parent chain (boundary)", () => {
const depth = 20
const kps: KpWithRelations[] = [makeKp("0")]
for (let i = 1; i < depth; i++) {
kps.push(makeKp(String(i), String(i - 1)))
}
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(depth)
// 应生成 depth-1 条 parent 边
const parentEdges = layout.edges.filter((e) => e.id.startsWith("parent-"))
expect(parentEdges).toHaveLength(depth - 1)
})
it("should handle many nodes (boundary, 50 nodes)", () => {
const count = 50
const kps: KpWithRelations[] = []
for (let i = 0; i < count; i++) {
kps.push(makeKp(`n${i}`))
}
const layout = computeGraphLayout(kps)
expect(layout.nodes).toHaveLength(count)
expect(layout.edges).toHaveLength(0)
// 所有节点都应有有效位置
for (const node of layout.nodes) {
expect(Number.isFinite(node.position.x)).toBe(true)
expect(Number.isFinite(node.position.y)).toBe(true)
}
})
})
})

View File

@@ -6,7 +6,10 @@ import type { GraphViewMode, KnowledgeGraphData } from "../types"
interface UseGraphDataResult {
data: KnowledgeGraphData | null
/** 首次加载(无任何数据时) */
isLoading: boolean
/** 切换模式时的刷新(保留旧数据,显示轻量指示器) */
isRefreshing: boolean
error: string | null
reload: () => void
}
@@ -15,7 +18,8 @@ interface UseGraphDataResult {
* 图谱数据加载 Hook。
*
* 按 textbookId + viewMode 加载,切换 viewMode 时重新加载。
* 使用派生值模式(isLoading 从 data.viewMode 派生),避免 effect 中同步 setState。
* 区分 isLoading(首次加载)和 isRefreshing切换模式刷新
* 切换模式时保留旧数据避免 UI 闪烁。
*/
export function useGraphData(
textbookId: string,
@@ -30,8 +34,10 @@ export function useGraphData(
setReloadTrigger((n) => n + 1)
}, [])
// 派生 loading 状态:无数据或当前数据不匹配请求的 viewMode
const isLoading = data === null || data.viewMode !== viewMode
// 首次加载:完全无数据
const isLoading = data === null && error === null
// 切换模式刷新:有旧数据但 viewMode 不匹配
const isRefreshing = data !== null && data.viewMode !== viewMode
useEffect(() => {
if (!textbookId) return
@@ -50,13 +56,14 @@ export function useGraphData(
setError(null)
} else {
setData(null)
setError(result.message ?? "Unknown error")
// 错误消息使用通用 key由组件层翻译
setError(result.message ?? "graph.error.loadFailed")
}
})
.catch((e: unknown) => {
if (!cancelled) {
setData(null)
setError(e instanceof Error ? e.message : "Unknown error")
setError(e instanceof Error ? e.message : "graph.error.loadFailed")
}
})
@@ -65,5 +72,5 @@ export function useGraphData(
}
}, [textbookId, viewMode, reloadTrigger])
return { data, isLoading, error, reload }
return { data, isLoading, error, reload, isRefreshing }
}

View File

@@ -1,16 +1,14 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { KnowledgePoint } from "../types"
import {
createKnowledgePointAction,
deleteKnowledgePointAction,
updateKnowledgePointAction,
} from "../actions"
import { useKpDialogState } from "./use-kp-dialog-state"
import { useKpCrud } from "./use-kp-crud"
/**
* 知识点操作 Hook门面
*
* 组合 useKpDialogState对话框状态和 useKpCrudCRUD 操作),
* 对外保持原有 API 不变,避免调用方修改。
*/
export function useKnowledgePointActions(
textbookId: string | undefined,
selectedChapterId: string | null,
@@ -19,105 +17,33 @@ export function useKnowledgePointActions(
setHighlightedKpId: (id: string | null) => void,
onKpCreated?: () => void,
) {
const t = useTranslations("textbooks")
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
const dialog = useKpDialogState()
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const handleCreateKnowledgePoint = async (formData: FormData) => {
if (!selectedChapterId || !selectedChapterTextbookId) return
try {
const result = await createKnowledgePointAction(
const crud = useKpCrud({
textbookId,
selectedChapterId,
selectedChapterTextbookId,
null,
formData,
)
if (result.success) {
toast.success(t("action.kpCreateSuccess"))
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
} else {
toast.error(result.message || t("action.kpCreateFailed"))
return false
}
} catch {
toast.error(t("action.errorOccurred"))
return false
}
}
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent) => {
e.stopPropagation()
setPendingDeleteKpId(kpId)
setDeleteConfirmOpen(true)
}
const confirmDeleteKnowledgePoint = async () => {
if (!pendingDeleteKpId || !textbookId) return
setDeleteConfirmOpen(false)
try {
const result = await deleteKnowledgePointAction(pendingDeleteKpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === pendingDeleteKpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.deleteFailed"))
} finally {
setPendingDeleteKpId(null)
}
}
const handleUpdateKnowledgePoint = async (formData: FormData) => {
if (!editingKp || !textbookId) return
setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
setEditKpDialogOpen(false)
setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.updateFailedGeneric"))
} finally {
setIsUpdatingKp(false)
}
}
highlightedKpId,
setHighlightedKpId,
onKpCreated,
dialog,
})
return {
editingKp,
setEditingKp,
editKpDialogOpen,
setEditKpDialogOpen,
isUpdatingKp,
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
setTargetKpForQuestion,
deleteConfirmOpen,
setDeleteConfirmOpen,
handleCreateKnowledgePoint,
requestDeleteKnowledgePoint,
confirmDeleteKnowledgePoint,
handleUpdateKnowledgePoint,
editingKp: dialog.editingKp,
setEditingKp: dialog.setEditingKp,
editKpDialogOpen: dialog.editKpDialogOpen,
setEditKpDialogOpen: dialog.setEditKpDialogOpen,
isUpdatingKp: dialog.isUpdatingKp,
questionDialogOpen: dialog.questionDialogOpen,
setQuestionDialogOpen: dialog.setQuestionDialogOpen,
targetKpForQuestion: dialog.targetKpForQuestion,
setTargetKpForQuestion: dialog.setTargetKpForQuestion,
deleteConfirmOpen: dialog.deleteConfirmOpen,
setDeleteConfirmOpen: dialog.setDeleteConfirmOpen,
handleCreateKnowledgePoint: crud.handleCreateKnowledgePoint,
requestDeleteKnowledgePoint: crud.requestDeleteKnowledgePoint,
confirmDeleteKnowledgePoint: crud.confirmDeleteKnowledgePoint,
handleUpdateKnowledgePoint: crud.handleUpdateKnowledgePoint,
}
}

View File

@@ -0,0 +1,122 @@
"use client"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import type { KnowledgePoint } from "../types"
import {
createKnowledgePointAction,
deleteKnowledgePointAction,
updateKnowledgePointAction,
} from "../actions"
import type { useKpDialogState } from "./use-kp-dialog-state"
type DialogState = ReturnType<typeof useKpDialogState>
interface UseKpCrudArgs {
textbookId: string | undefined
selectedChapterId: string | null
selectedChapterTextbookId: string | undefined
highlightedKpId: string | null
setHighlightedKpId: (id: string | null) => void
onKpCreated?: () => void
dialog: DialogState
}
/**
* 知识点 CRUD 操作 Hook。
*
* 依赖 useKpDialogState 提供的对话框状态,执行创建/更新/删除操作。
*/
export function useKpCrud({
textbookId,
selectedChapterId,
selectedChapterTextbookId,
highlightedKpId,
setHighlightedKpId,
onKpCreated,
dialog,
}: UseKpCrudArgs) {
const t = useTranslations("textbooks")
const handleCreateKnowledgePoint = async (formData: FormData): Promise<boolean> => {
if (!selectedChapterId || !selectedChapterTextbookId) return false
try {
const result = await createKnowledgePointAction(
selectedChapterId,
selectedChapterTextbookId,
null,
formData,
)
if (result.success) {
toast.success(t("action.kpCreateSuccess"))
onKpCreated?.()
window.getSelection()?.removeAllRanges()
return true
}
toast.error(result.message || t("action.kpCreateFailed"))
return false
} catch {
toast.error(t("action.errorOccurred"))
return false
}
}
const requestDeleteKnowledgePoint = (kpId: string, e: React.MouseEvent): void => {
e.stopPropagation()
dialog.setPendingDeleteKpId(kpId)
dialog.setDeleteConfirmOpen(true)
}
const confirmDeleteKnowledgePoint = async (): Promise<void> => {
if (!dialog.pendingDeleteKpId || !textbookId) return
dialog.setDeleteConfirmOpen(false)
try {
const result = await deleteKnowledgePointAction(dialog.pendingDeleteKpId, textbookId)
if (result.success) {
toast.success(result.message)
if (highlightedKpId === dialog.pendingDeleteKpId) {
setHighlightedKpId(null)
}
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.deleteFailed"))
} finally {
dialog.setPendingDeleteKpId(null)
}
}
const handleUpdateKnowledgePoint = async (formData: FormData): Promise<void> => {
if (!dialog.editingKp || !textbookId) return
dialog.setIsUpdatingKp(true)
try {
const result = await updateKnowledgePointAction(dialog.editingKp.id, textbookId, null, formData)
if (result.success) {
toast.success(result.message)
dialog.setEditKpDialogOpen(false)
dialog.setEditingKp(null)
} else {
toast.error(result.message)
}
} catch {
toast.error(t("action.updateFailedGeneric"))
} finally {
dialog.setIsUpdatingKp(false)
}
}
return {
handleCreateKnowledgePoint,
requestDeleteKnowledgePoint,
confirmDeleteKnowledgePoint,
handleUpdateKnowledgePoint,
}
}
export type { KnowledgePoint }

View File

@@ -0,0 +1,38 @@
"use client"
import { useState } from "react"
import type { KnowledgePoint } from "../types"
/**
* 知识点相关对话框状态管理 Hook。
*
* 管理:编辑对话框、题目创建对话框、删除确认对话框。
*/
export function useKpDialogState() {
const [editingKp, setEditingKp] = useState<KnowledgePoint | null>(null)
const [editKpDialogOpen, setEditKpDialogOpen] = useState(false)
const [isUpdatingKp, setIsUpdatingKp] = useState(false)
const [questionDialogOpen, setQuestionDialogOpen] = useState(false)
const [targetKpForQuestion, setTargetKpForQuestion] = useState<KnowledgePoint | null>(null)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [pendingDeleteKpId, setPendingDeleteKpId] = useState<string | null>(null)
return {
editingKp,
setEditingKp,
editKpDialogOpen,
setEditKpDialogOpen,
isUpdatingKp,
setIsUpdatingKp,
questionDialogOpen,
setQuestionDialogOpen,
targetKpForQuestion,
setTargetKpForQuestion,
deleteConfirmOpen,
setDeleteConfirmOpen,
pendingDeleteKpId,
setPendingDeleteKpId,
}
}

View File

@@ -67,7 +67,8 @@ export const CreatePrerequisiteSchema = z.object({
knowledgePointId: z.string().min(1),
prerequisiteKpId: z.string().min(1),
}).refine((data) => data.knowledgePointId !== data.prerequisiteKpId, {
message: "知识点不能作为自己的前置",
// Zod message 仅为开发期提示Action 层返回的 t("invalidInput") 是用户可见文案
message: "A knowledge point cannot be a prerequisite of itself",
})
export type CreatePrerequisiteInput = z.infer<typeof CreatePrerequisiteSchema>

View File

@@ -263,4 +263,23 @@ describe("textbooks/utils - cycle detection", () => {
const edges: Array<[string, string]> = [["a", "b"], ["a", "c"], ["b", "d"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
it("should detect self-loop as cycle (a->a)", () => {
expect(hasCycleAfterAddingEdge([], "a", "a")).toBe(true)
})
it("should not detect cycle for duplicate edge (a->b already exists)", () => {
const edges: Array<[string, string]> = [["a", "b"]]
expect(hasCycleAfterAddingEdge(edges, "a", "b")).toBe(false)
})
it("should detect longer indirect cycle (a->b->c->d then d->a)", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"], ["c", "d"]]
expect(hasCycleAfterAddingEdge(edges, "d", "a")).toBe(true)
})
it("should not detect cycle when adding edge to unrelated node", () => {
const edges: Array<[string, string]> = [["a", "b"], ["b", "c"]]
expect(hasCycleAfterAddingEdge(edges, "d", "e")).toBe(false)
})
})

View File

@@ -88,9 +88,21 @@
"errors": {
"notFound": "Attendance record not found",
"noOwnership": "You do not own this attendance record",
"noClassOwnership": "You do not own this class",
"insufficientPermissions": "Insufficient permissions",
"invalidForm": "Invalid form data",
"missingRecords": "Missing records data",
"invalidRecordsJson": "Invalid attendance data format",
"unexpected": "Unexpected error"
},
"messages": {
"recorded": "Attendance recorded",
"batchRecorded": "Recorded attendance for {count} students",
"updated": "Attendance updated",
"deleted": "Attendance record deleted",
"rulesSaved": "Attendance rules saved",
"ownershipCheckFailed": "Ownership check failed"
},
"parent": {
"warningTitle": "Attendance Warnings",
"rateCardTitle": "Attendance Rate Summary",

View File

@@ -95,7 +95,19 @@
"alreadySelected": "You have already selected this course",
"selectionClosed": "Selection is closed",
"gradeMismatch": "Your grade does not match the course requirement",
"scheduleConflict": "Schedule conflicts with your existing courses",
"creditExceeded": "Credit limit exceeded ({current}/{max})",
"invalidForm": "Invalid form data",
"unexpected": "Unexpected error"
},
"messages": {
"created": "Elective course created",
"updated": "Elective course updated",
"deleted": "Elective course deleted",
"selectionOpened": "Selection opened",
"selectionClosed": "Selection closed",
"lotteryCompleted": "Lottery completed: {enrolled} enrolled, {waitlist} waitlisted",
"courseDropped": "Course dropped",
"ownershipCheckFailed": "Ownership check failed"
}
}

View File

@@ -58,7 +58,8 @@
"noChapters": "No chapters",
"noChaptersDesc": "This textbook has no chapters yet.",
"sidebar": "Chapters & Knowledge",
"openSidebar": "Open Sidebar"
"openSidebar": "Open Sidebar",
"prepareLesson": "Prepare Lesson for This Text"
},
"dialog": {
"create": {
@@ -91,6 +92,7 @@
"titlePlaceholder": "e.g. Chapter 1: Introduction",
"toggle": "Toggle",
"orderUpdated": "Order updated",
"orderUpdateFailed": "Failed to update order",
"cancel": "Cancel",
"dragHandle": "Drag to reorder"
},
@@ -251,7 +253,8 @@
"toolbar": {
"search": "Search knowledge points",
"filterByChapter": "Filter by chapter",
"resetView": "Reset view"
"resetView": "Reset view",
"refreshing": "Refreshing..."
},
"empty": {
"noPrerequisites": "No prerequisite relationships",

View File

@@ -88,9 +88,21 @@
"errors": {
"notFound": "考勤记录不存在",
"noOwnership": "您无权操作此考勤记录",
"noClassOwnership": "您无权操作此班级",
"insufficientPermissions": "权限不足",
"invalidForm": "表单数据无效",
"missingRecords": "缺少考勤数据",
"invalidRecordsJson": "考勤数据格式无效",
"unexpected": "发生未知错误"
},
"messages": {
"recorded": "考勤已记录",
"batchRecorded": "已为 {count} 名学生记录考勤",
"updated": "考勤已更新",
"deleted": "考勤记录已删除",
"rulesSaved": "考勤规则已保存",
"ownershipCheckFailed": "归属校验失败"
},
"parent": {
"warningTitle": "考勤异常预警",
"rateCardTitle": "出勤率汇总",

View File

@@ -95,7 +95,19 @@
"alreadySelected": "您已选过此课程",
"selectionClosed": "选课已关闭",
"gradeMismatch": "您的年级不符合课程要求",
"scheduleConflict": "时间与已选课程冲突",
"creditExceeded": "学分超限({current}/{max}",
"invalidForm": "表单数据无效",
"unexpected": "发生未知错误"
},
"messages": {
"created": "选修课程已创建",
"updated": "选修课程已更新",
"deleted": "选修课程已删除",
"selectionOpened": "选课已开放",
"selectionClosed": "选课已关闭",
"lotteryCompleted": "抽签完成:录取 {enrolled} 人,候补 {waitlist} 人",
"courseDropped": "已退选课程",
"ownershipCheckFailed": "归属校验失败"
}
}

View File

@@ -58,7 +58,8 @@
"noChapters": "暂无章节",
"noChaptersDesc": "这本教材还没有章节。",
"sidebar": "目录与知识点",
"openSidebar": "打开目录"
"openSidebar": "打开目录",
"prepareLesson": "为此课文备课"
},
"dialog": {
"create": {
@@ -91,6 +92,7 @@
"titlePlaceholder": "例如:第一章:入门",
"toggle": "展开/折叠",
"orderUpdated": "顺序已更新",
"orderUpdateFailed": "顺序更新失败",
"cancel": "取消",
"dragHandle": "拖拽排序"
},
@@ -251,7 +253,8 @@
"toolbar": {
"search": "搜索知识点",
"filterByChapter": "按章节筛选",
"resetView": "重置视图"
"resetView": "重置视图",
"refreshing": "刷新中..."
},
"empty": {
"noPrerequisites": "暂无前置依赖关系",