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:
@@ -451,7 +451,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| **表单字段** | `TextareaField` | `components/form-fields/textarea-field.tsx` | 通用多行文本字段(FormField + Textarea 包装) | 1 个(P1-2: create-question-dialog) |
|
||||
| **图表组件** | `ChartCardShell` | `components/charts/chart-card-shell.tsx` | 图表卡片外壳(Card+Header+EmptyState+Content 统一结构) | 8 个(P3-c) |
|
||||
| **图表组件** | `TrendLineChart` | `components/charts/trend-line-chart.tsx` | 趋势折线图(LineChart 统一配置,支持单/多系列) | 8 个(P3-c: grade-trend-chart 等) |
|
||||
| **图表组件** | `SimpleBarChart` | `components/charts/simple-bar-chart.tsx` | 柱状图(BarChart 统一配置,支持单/多 Bar + Cell 分桶着色) | 8 个(P3-c: grade-distribution-chart 等) |
|
||||
| **图表组件** | `SimpleBarChart` | `components/charts/simple-bar-chart.tsx` | 柱状图(BarChart 统一配置,支持单/多 Bar + Cell 分桶着色 + defs 自定义 SVG 图案) | 8 个(P3-c: grade-distribution-chart 等) |
|
||||
| **图表组件** | `ComparisonRadarChart` | `components/charts/comparison-radar-chart.tsx` | 对比雷达图(RadarChart 统一配置,支持双 Radar 对比) | 8 个(P3-c: subject-comparison-chart, mastery-radar-chart 等) |
|
||||
| **课表组件** | `ScheduleList` / `ScheduleListItem` | `components/schedule/schedule-list.tsx` | 课表列表+列表项(课程+时间+地点+班级徽章,separator/card 两种变体) | 3 个(P3-a: student-today-schedule-card, child-schedule-card, student-schedule-view) |
|
||||
| **题库组件** | `QuestionBankFilters` | `components/question/question-bank-filters.tsx` | 题库筛选栏(搜索+题型+难度,default/compact 两种布局) | 2 个(P3-d: exam-assembly, question-bank-picker) |
|
||||
@@ -766,6 +766,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ v3-P2 改进(2026-06-23):`getGradeTrend`/`getGradeDistribution`/`getSubjectComparison`/`getClassComparison` 均新增 `semester` 和 `examId` 可选参数,支持按学期和考试筛选分析
|
||||
- ✅ v3-P2 改进(2026-06-23):新增 `getSchoolWideGradeSummary` data-access 函数 + `SchoolWideSummaryCard` 组件 + `SchoolWideGradeSummary`/`SchoolWideGradeSummaryItem` 类型,管理员全校成绩汇总视图(按年级聚合平均分/及格率/优秀率/学生数/班级数,加权平均计算全校汇总);admin/school/grades/insights/page.tsx 顶部新增 SchoolWideSummaryCard
|
||||
- ✅ v3-P2 改进(2026-06-23):parent/grades/page.tsx 为每个子女并行查询 `getClassAverageTrend`,渲染 GradeTrendCard
|
||||
- ✅ v3-P3-4 改进(2026-06-23):GradeTrendCard 新增日期范围选择器(全部/近7天/近30天/近90天),通过 nuqs `trendRange` URL 参数持久化,useEffect 中计算截止时间戳避免渲染阶段调用 Date.now()
|
||||
- ✅ P3 修复(2026-06-23):~~`lib/grade-utils.ts` 72 行超 40 行工具函数上限~~ P3-26 将 `buildScopeClassFilter` 迁移至 `lib/scope-filter.ts`,grade-utils.ts 仅保留 toNumber/normalize(20 行)
|
||||
- ✅ P3 修复(2026-06-23):~~`stats-service.ts` createDefaultBuckets 不必要导出~~ P3-10 移除 export 关键字,改为内部函数
|
||||
- ✅ P3 修复(2026-06-23):~~`stats-service.ts` buildGradeTrendPoints 使用 as 断言~~ P3-24 新增 isGradeTrendType 类型守卫函数替代 as 断言
|
||||
@@ -1111,7 +1112,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ V2-P1-2 已修复:~~MessageList 客户端过滤冗余~~ 客户端过滤仅在初始数据(type=all)时执行,搜索结果已由服务端按 tab 过滤
|
||||
- ✅ V2-P1-3 已修复:~~消息详情页分散编排~~ 新增 `getMessageDetailPageData` 编排函数,替代 page.tsx 中 `after()` + `getMessageById` + `markMessageAsRead` 的分散编排
|
||||
- ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `message-compose.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(receiverId/subject/content)
|
||||
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(60_000ms)
|
||||
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(~~60_000ms~~ → 30_000ms,✅ V2-P3 与通知组件保持一致)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
@@ -1128,7 +1129,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `components/message-list.tsx` | 消息列表(✅ P1-7:使用 `useMessageSearch` hook + 客户端分页 UI,PAGE_SIZE=20;✅ V2-P1-2:客户端过滤仅在初始数据时执行) |
|
||||
| `components/message-detail.tsx` | 消息详情(含回复) |
|
||||
| `components/message-compose.tsx` | 撰写新消息(✅ V2-P1-4:fieldErrors + aria-invalid 字段级错误展示) |
|
||||
| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 60 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1:POLL_INTERVAL_MS 常量) |
|
||||
| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 30 秒轮询 `getUnreadMessageCountAction`;✅ V2-P2-1:POLL_INTERVAL_MS 常量;✅ V2-P3:间隔从 60s 缩短为 30s 与通知一致) |
|
||||
|
||||
**客户端行为**:
|
||||
- `message-list.tsx`:客户端调用 `getMessagesAction` 搜索消息(useMessageSearch hook,400ms 防抖,请求竞态取消);V2-P1-2 优化:客户端过滤仅在初始数据(type=all)时执行
|
||||
@@ -1147,6 +1148,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
||||
- Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
|
||||
- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移)
|
||||
- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId)
|
||||
@@ -1160,6 +1162,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ P2-11 已修复:~~通知标记已读无埋点~~ `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` 新增 `trackEvent` 埋点(notification.marked_read / notification.marked_all_read)
|
||||
- ✅ V2-P0-1 已修复:~~通知 i18n 键混在 messages.json 中~~ 新增独立的 `notifications.json` 命名空间(zh-CN/en),通知组件 `useTranslations` 从 `"messages"` 切换到 `"notifications"`;`src/i18n/request.ts` 新增 notifications 命名空间加载
|
||||
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(30_000ms)
|
||||
- ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 Server Actions,间隔 30 秒)
|
||||
- ⚠️ P1:发送日志仅 console,无 `notification_logs` 表
|
||||
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
|
||||
|
||||
@@ -1174,16 +1177,18 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `index.ts` | ~75 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出) |
|
||||
| `channels/*` | 5 文件 | 4 个渠道实现 |
|
||||
| `components/notification-list.tsx` | ~140 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件 |
|
||||
| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件 |
|
||||
| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级 |
|
||||
| `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级) |
|
||||
|
||||
**组件清单**:
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications") |
|
||||
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,每 30 秒轮询 `getNotificationsAction` + `getUnreadNotificationCountAction`;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) |
|
||||
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) |
|
||||
|
||||
**客户端行为**:
|
||||
- `notification-dropdown.tsx`:每 `POLL_INTERVAL_MS`(30_000ms)轮询 `getNotificationsAction`(pageSize=10)和 `getUnreadNotificationCountAction` 刷新通知和未读计数
|
||||
- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒)
|
||||
- `hooks/use-notification-stream.ts`:✅ V2-P3 新增,管理 SSE 连接生命周期(EventSource onopen/onmessage/onerror),降级时通过 `pollFnRef` 调用 Server Actions 轮询
|
||||
|
||||
---
|
||||
|
||||
@@ -1509,13 +1514,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`generateStudentReportAction` / `generateClassReportAction` / `publishReportAction` / `deleteReportAction`(v2-P2-3 修复:删除死代码 `getDiagnosticReportsAction` / `getDiagnosticReportByIdAction`,页面直接调用 data-access 并自行权限校验)
|
||||
- Data-access:`updateMasteryFromSubmission`(v2-P1-8 修复:累积模式;v2-P2-5 修复:db.transaction 包裹)/ `getStudentMastery` / `getStudentMasterySummary` / `getClassMasterySummary`(v2-P2-4 修复:totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`(v2-P1-7 修复:页面先查班级再传参)
|
||||
- Data-access-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据)/ `getDiagnosticReports` / `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization)
|
||||
- Stats-service(✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses` / `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数)
|
||||
- Data-access:`updateMasteryFromSubmission`(v2-P1-8 修复:累积模式;v2-P2-5 修复:db.transaction 包裹)/ `getStudentMastery`(P3-19 修复:移除 export,改为模块内部函数)/ `getStudentMasterySummary`(P3-18 修复:getUserNamesByIds 与 getStudentMastery 并行查询)/ `getClassMasterySummary`(v2-P2-4 修复:totalStudents 语义 + 班级平均掌握度按学生平均)/ `getKnowledgePointStats`(v2-P1-7 修复:页面先查班级再传参)
|
||||
- Data-access-reports:`generateDiagnosticReport` / `generateClassDiagnosticReport`(v2-P2-6 修复:校验掌握度数据;P3-27 修复:使用 DiagnosticReportError 结构化错误码)/ `getDiagnosticReports`(P3-15 修复:支持分页 limit/offset,返回 { reports, total } 结构)/ `getDiagnosticReportById` / `publishDiagnosticReport` / `deleteDiagnosticReport`(✅ P2 已修复:使用 `React.cache()` 包装实现请求级 memoization;P3-1 修复:toNumber 从 grades 模块导入)/ `DiagnosticReportError`(P3-27 新增:结构化错误码类)
|
||||
- Stats-service(✅ v2-P1-6 新增):`serializeMasteryWithKp` / `computeAverageMastery` / `classifyStrengthsWeaknesses`(P3-16 修复:弱项阈值从 <60 改为 <80,消除 60-79 盲区)/ `buildStudentMasterySummary` / `aggregateClassMastery` / `computeKpStats` / `computeClassAverageMastery` / `buildStudentsNeedingAttention` / `buildClassMasterySummary` / `buildStudentReportContent` / `buildClassReportContent` / `computeMasteryLevel` / `serializeMastery`(从 data-access / data-access-reports 抽取的纯统计函数)
|
||||
- Schema:`GenerateStudentReportSchema` / `GenerateClassReportSchema` / `PublishReportSchema` / `DeleteReportSchema`(v2-P2-3 修复:删除死代码 `GetDiagnosticReportsSchema` / `GetDiagnosticReportByIdSchema`)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId;v2-P1-7 新增 getStudentActiveClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId)
|
||||
- 依赖:`shared/*`、`@/auth`、`exams`(✅ P1-1 已修复:通过 exams data-access.getExamSubmissionWithAnswers)、`questions`(✅ P1-1 已修复:通过 questions data-access.getKnowledgePointsForQuestions)、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getActiveStudentIdsByClassId;v2-P1-7 新增 getStudentActiveClassId)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds/getUserIdsByGradeId)、`grades`(P3-1 修复:通过 grades/lib/grade-utils.toNumber 复用工具函数)
|
||||
- 被依赖:无
|
||||
|
||||
**已知问题**:
|
||||
@@ -1539,6 +1544,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ v2-P2-7 已修复:~~`report-list.tsx` 过滤器 Label 缺少 `htmlFor`~~ 添加 `htmlFor` 和 `id`
|
||||
- ✅ 与 grades 模块无职责重叠
|
||||
- ✅ v3-P2 改进(2026-06-23):`StudentDiagnosticView` 新增 `practiceHrefBase` prop(string | null),null 时隐藏练习按钮;teacher/diagnostic/student/[studentId] 传入 `practiceHrefBase="/teacher/questions"`,parent/diagnostic 传入 `practiceHrefBase={null}` 隐藏练习按钮
|
||||
- ✅ P3-1 已修复(2026-06-23):~~`data-access-reports.ts` 本地定义 `toNumber` 与 `grades/lib/grade-utils.ts` 重复~~ 改为从 grades 模块导入
|
||||
- ✅ P3-15 已修复(2026-06-23):~~`getDiagnosticReports` 无分页,可能返回大量数据~~ 添加 limit/offset 分页支持,返回 `{ reports, total }` 结构,Promise.all 并行查询总数和数据
|
||||
- ✅ P3-16 已修复(2026-06-23):~~强弱项分类存在 60-79 盲区~~ 弱项阈值从 <60 改为 <80,确保所有知识点都被分类
|
||||
- ✅ P3-17 已修复:~~班级报告 strengths 无数量上限~~ `buildClassReportContent` 中 strengths 已限制为前 5 个(按掌握度降序)
|
||||
- ✅ P3-18 已修复(2026-06-23):~~`getStudentMasterySummary` 串行查询用户名和掌握度~~ 改为 Promise.all 并行查询
|
||||
- ✅ P3-19 已修复(2026-06-23):~~`getStudentMastery` 使用 export 但仅内部使用~~ 移除 export,改为模块内部函数
|
||||
- ✅ P3-27 已修复(2026-06-23):~~`generateDiagnosticReport` / `generateClassDiagnosticReport` 中 `throw new Error("...")` 直接暴露给用户~~ 改为使用 `DiagnosticReportError` 结构化错误码(继承 BusinessError)
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
@@ -1952,6 +1964,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| **Server Actions** | `recommendStudyPathAction` | `modules/ai/actions.ts` | 学习路径推荐(权限:AI_CHAT)— V2 新增 |
|
||||
| **Server Actions** | `getAiUsageStatsAction` | `modules/ai/actions.ts` | AI 使用统计(权限:AI_CONFIGURE)— V2 新增 |
|
||||
| **SSE Route** | `POST /api/ai/chat/stream` | `app/api/ai/chat/stream/route.ts` | 流式 AI 对话(SSE)— V2 新增 |
|
||||
| **SSE Route** | `GET /api/notifications/stream` | `app/api/notifications/stream/route.ts` | 通知实时推送(SSE,15s 心跳 + 5min 超时)— V2-P3 新增 |
|
||||
| **Service** | `AiService` | `modules/ai/types.ts` | 服务端 AI 服务接口(含 8 个方法) |
|
||||
| **Service** | `AiClientService` | `modules/ai/types.ts` | 客户端 AI 服务接口(Server Action 引用集合) |
|
||||
| **Provider** | `AiClientProvider` | `modules/ai/context/ai-client-provider.tsx` | React Context Provider,注入 AiClientService |
|
||||
@@ -2036,6 +2049,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `modules/ai/hooks/use-ai-chat.ts` | ~57 | 非流式 AI 对话 Hook |
|
||||
| `modules/ai/hooks/use-ai-suggestion.ts` | ~72 | AI 建议 Hook |
|
||||
| `app/api/ai/chat/stream/route.ts` | ~160 | SSE 流式端点 — V2 新增 |
|
||||
| `app/api/notifications/stream/route.ts` | ~120 | 通知实时推送 SSE 端点 — V2-P3 新增 |
|
||||
|
||||
**i18n**:
|
||||
- 翻译文件:`shared/i18n/messages/{locale}/ai.json`
|
||||
|
||||
@@ -7031,19 +7031,121 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "toggleTwoFactorAction",
|
||||
"name": "setupTwoFactorAction",
|
||||
"file": "actions-security.ts",
|
||||
"permission": "USER_PROFILE_UPDATE",
|
||||
"signature": "(enabled: boolean) => Promise<ActionState<TwoFactorStatus>>",
|
||||
"purpose": "启用/禁用 2FA(P2-9 新增:占位实现;v2 已禁用开关,显示'即将推出'提示,避免虚假安全感)",
|
||||
"signature": "() => Promise<ActionState<TwoFactorSetupData>>",
|
||||
"purpose": "2FA 启用流程第一步:生成 TOTP 密钥 + QR 码 Data URL(v3 新增:完整 TOTP 实现,替代 v2 的占位开关)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access-system-settings.upsertSystemSetting"
|
||||
"users/data-access.getUserProfile",
|
||||
"data-access-two-factor.getTwoFactorEnabled",
|
||||
"data-access-two-factor.setTotpSecret",
|
||||
"lib/totp.generateTotpSecret",
|
||||
"lib/totp.buildOtpAuthUrl",
|
||||
"lib/totp.generateQrCodeDataUrl"
|
||||
],
|
||||
"usedBy": [
|
||||
"components/security-center-card.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "verifyTwoFactorAction",
|
||||
"file": "actions-security.ts",
|
||||
"permission": "USER_PROFILE_UPDATE",
|
||||
"signature": "(token: string) => Promise<ActionState<{ backupCodes: string[]; status: TwoFactorStatus }>>",
|
||||
"purpose": "2FA 启用流程第二步:校验一次性码 + 生成 10 个备份码(bcrypt 哈希存储)+ 启用 2FA(v3 新增)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access-two-factor.getTotpSecret",
|
||||
"data-access-two-factor.setBackupCodesHashed",
|
||||
"data-access-two-factor.setTwoFactorEnabled",
|
||||
"data-access-two-factor.setTwoFactorEnabledAt",
|
||||
"lib/totp.verifyTotpCode",
|
||||
"lib/totp.generateBackupCodes",
|
||||
"lib/totp.hashBackupCodes"
|
||||
],
|
||||
"usedBy": [
|
||||
"components/security-center-card.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "disableTwoFactorAction",
|
||||
"file": "actions-security.ts",
|
||||
"permission": "USER_PROFILE_UPDATE",
|
||||
"signature": "(token: string) => Promise<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/<60;v4-P3-4 改进:每个分数段使用不同 SVG pattern + 颜色双重编码,色盲友好)",
|
||||
"deps": [
|
||||
"recharts",
|
||||
"shared/components/ui/chart"
|
||||
@@ -10864,7 +10966,7 @@
|
||||
{
|
||||
"name": "UnreadMessageBadge",
|
||||
"file": "components/unread-message-badge.tsx",
|
||||
"purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 60 秒轮询 getUnreadMessageCountAction;V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量)"
|
||||
"purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 30 秒轮询 getUnreadMessageCountAction;V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量;V2-P3 优化:间隔从 60s 缩短为 30s 与通知组件保持一致)"
|
||||
}
|
||||
],
|
||||
"hooks": [
|
||||
@@ -10922,7 +11024,7 @@
|
||||
"data-access.getNotifications"
|
||||
],
|
||||
"usedBy": [
|
||||
"notification-dropdown.tsx",
|
||||
"hooks/use-notification-stream.ts",
|
||||
"notification-list.tsx"
|
||||
]
|
||||
},
|
||||
@@ -10936,7 +11038,7 @@
|
||||
"data-access.getUnreadNotificationCount"
|
||||
],
|
||||
"usedBy": [
|
||||
"notification-dropdown.tsx"
|
||||
"hooks/use-notification-stream.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -12629,22 +12731,21 @@
|
||||
"name": "getStudentMastery",
|
||||
"signature": "(studentId: string) => Promise<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=draft,studentId 存生成者 ID)",
|
||||
"purpose": "生成班级诊断报告(聚合班级掌握度,识别薄弱知识点,status=draft,studentId 存生成者 ID。P3-27 修复:使用 DiagnosticReportError 结构化错误码)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.learningDiagnosticReports",
|
||||
@@ -12738,19 +12840,20 @@
|
||||
},
|
||||
{
|
||||
"name": "getDiagnosticReports",
|
||||
"signature": "(filters: DiagnosticReportQueryParams) => Promise<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 prop,null 时隐藏练习按钮)",
|
||||
"purpose": "学生诊断视图(概览卡片、雷达图、强项/弱项列表、生成报告表单[DIAGNOSTIC_MANAGE]、最新报告与建议展示;v3-P2 新增:practiceHrefBase prop,null 时隐藏练习按钮;P3-22 改进:练习按钮添加 aria-label 含知识点名)",
|
||||
"props": "{ studentId, summary, classAverage?, reports?, practiceHrefBase?: string | null }",
|
||||
"deps": [
|
||||
"usePermission",
|
||||
@@ -16131,6 +16243,12 @@
|
||||
"type": "data-access",
|
||||
"description": "关联考试提交(examSubmissions/submissionAnswers)"
|
||||
},
|
||||
{
|
||||
"from": "diagnostic",
|
||||
"to": "grades",
|
||||
"type": "lib-import",
|
||||
"description": "复用 toNumber 工具函数(P3-1 修复:从 grades/lib/grade-utils 导入)"
|
||||
},
|
||||
{
|
||||
"from": "elective",
|
||||
"to": "school",
|
||||
@@ -17467,7 +17585,7 @@
|
||||
"grades/data-access-analytics.getClassAverageTrend"
|
||||
],
|
||||
"permission": "grade_record:read",
|
||||
"description": "家长成绩视图(按 DataScope.children 过滤;v4 新增 ParentExportButton 占位;v3-P2 更新:为每个子女并行查询 getClassAverageTrend,渲染 GradeTrendCard)"
|
||||
"description": "家长成绩视图(按 DataScope.children 过滤;v4 新增 ParentExportButton 占位;v3-P2 更新:为每个子女并行查询 getClassAverageTrend,渲染 GradeTrendCard;v3-P3-4 更新:GradeTrendCard 新增日期范围选择器,通过 nuqs trendRange URL 参数持久化)"
|
||||
},
|
||||
"/parent/diagnostic": {
|
||||
"component": "子女学情诊断",
|
||||
@@ -17642,6 +17760,30 @@
|
||||
],
|
||||
"studentMode": "强制苏格拉底式引导系统提示"
|
||||
},
|
||||
"/api/notifications/stream": {
|
||||
"methods": [
|
||||
"GET"
|
||||
],
|
||||
"handler": "通知实时推送 SSE 端点(ReadableStream + setInterval 定时推送)",
|
||||
"auth": "MESSAGE_READ",
|
||||
"validation": "requirePermission 权限校验",
|
||||
"protocol": "Server-Sent Events",
|
||||
"events": [
|
||||
"update — 未读数 + 最新通知列表(连接建立时立即推送,之后每 15 秒推送)",
|
||||
"error — 权限拒绝或内部错误",
|
||||
"[DONE] — 连接超时(5 分钟)自动关闭"
|
||||
],
|
||||
"pushStrategy": "连接建立立即推送 + 15 秒间隔定时推送 + 5 分钟超时自动关闭",
|
||||
"module": "notifications",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.getUnreadNotificationCount",
|
||||
"data-access.getNotifications"
|
||||
],
|
||||
"usedBy": [
|
||||
"hooks/use-notification-stream.ts"
|
||||
]
|
||||
},
|
||||
"/api/onboarding/complete": {
|
||||
"methods": [
|
||||
"POST"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
38
src/modules/attendance/components/attendance-page-layout.tsx
Normal file
38
src/modules/attendance/components/attendance-page-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
90
src/modules/attendance/export.ts
Normal file
90
src/modules/attendance/export.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
30
src/modules/elective/components/elective-page-layout.tsx
Normal file
30
src/modules/elective/components/elective-page-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
102
src/modules/elective/export.ts
Normal file
102
src/modules/elective/export.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
83
src/modules/elective/resolvers.ts
Normal file
83
src/modules/elective/resolvers.ts
Normal 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
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { TextbookReader, type TextbookReaderProps } from "./textbook-reader"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import type { KnowledgePoint } from "../types"
|
||||
|
||||
/**
|
||||
* 教师端 TextbookReader 包装组件。
|
||||
*
|
||||
* 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader)
|
||||
* 传递函数 prop(renderQuestionCreator)。此包装组件在客户端层组装
|
||||
* renderQuestionCreator,避免违反 Next.js App Router 的 Server→Client 序列化约束。
|
||||
*/
|
||||
export function TeacherTextbookReader({
|
||||
chapters,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: TextbookReaderProps["chapters"]
|
||||
textbookId: string
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetKp,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
targetKp: KnowledgePoint | null
|
||||
}): ReactNode => (
|
||||
<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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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(对话框状态)和 useKpCrud(CRUD 操作),
|
||||
* 对外保持原有 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,
|
||||
}
|
||||
}
|
||||
|
||||
122
src/modules/textbooks/hooks/use-kp-crud.ts
Normal file
122
src/modules/textbooks/hooks/use-kp-crud.ts
Normal 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 }
|
||||
38
src/modules/textbooks/hooks/use-kp-dialog-state.ts
Normal file
38
src/modules/textbooks/hooks/use-kp-dialog-state.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -88,9 +88,21 @@
|
||||
"errors": {
|
||||
"notFound": "考勤记录不存在",
|
||||
"noOwnership": "您无权操作此考勤记录",
|
||||
"noClassOwnership": "您无权操作此班级",
|
||||
"insufficientPermissions": "权限不足",
|
||||
"invalidForm": "表单数据无效",
|
||||
"missingRecords": "缺少考勤数据",
|
||||
"invalidRecordsJson": "考勤数据格式无效",
|
||||
"unexpected": "发生未知错误"
|
||||
},
|
||||
"messages": {
|
||||
"recorded": "考勤已记录",
|
||||
"batchRecorded": "已为 {count} 名学生记录考勤",
|
||||
"updated": "考勤已更新",
|
||||
"deleted": "考勤记录已删除",
|
||||
"rulesSaved": "考勤规则已保存",
|
||||
"ownershipCheckFailed": "归属校验失败"
|
||||
},
|
||||
"parent": {
|
||||
"warningTitle": "考勤异常预警",
|
||||
"rateCardTitle": "出勤率汇总",
|
||||
|
||||
@@ -95,7 +95,19 @@
|
||||
"alreadySelected": "您已选过此课程",
|
||||
"selectionClosed": "选课已关闭",
|
||||
"gradeMismatch": "您的年级不符合课程要求",
|
||||
"scheduleConflict": "时间与已选课程冲突",
|
||||
"creditExceeded": "学分超限({current}/{max})",
|
||||
"invalidForm": "表单数据无效",
|
||||
"unexpected": "发生未知错误"
|
||||
},
|
||||
"messages": {
|
||||
"created": "选修课程已创建",
|
||||
"updated": "选修课程已更新",
|
||||
"deleted": "选修课程已删除",
|
||||
"selectionOpened": "选课已开放",
|
||||
"selectionClosed": "选课已关闭",
|
||||
"lotteryCompleted": "抽签完成:录取 {enrolled} 人,候补 {waitlist} 人",
|
||||
"courseDropped": "已退选课程",
|
||||
"ownershipCheckFailed": "归属校验失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "暂无前置依赖关系",
|
||||
|
||||
Reference in New Issue
Block a user