refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y
V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复: - P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键 - P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student 头部 90% 重复代码,两个 Header 改为薄包装 - P2 单测:为 6 个纯函数添加 31 个单元测试 (tests/integration/dashboard/dashboard-utils.test.ts) - P2 a11y:admin 表格 caption、teacher/student 视图语义化标签 (header / section aria-label / aside aria-label) - 同步架构图 004/005
This commit is contained in:
@@ -948,7 +948,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- Actions:`getAdminDashboardAction` / `getTeacherDashboardAction` / `getStudentDashboardAction` / `getParentDashboardAction`(均调用 `requirePermission()` 校验对应 `DASHBOARD_*_READ` 权限)
|
- Actions:`getAdminDashboardAction` / `getTeacherDashboardAction` / `getStudentDashboardAction` / `getParentDashboardAction`(均调用 `requirePermission()` 校验对应 `DASHBOARD_*_READ` 权限)
|
||||||
- Data-access:`getAdminDashboardData`(并行调用 6 个模块 stats 函数)
|
- Data-access:`getAdminDashboardData`(并行调用 6 个模块 stats 函数)
|
||||||
- Lib 纯函数:`toWeekday` / `countStudentAssignments` / `sortUpcomingAssignments` / `filterTodaySchedule` / `computeTeacherMetrics` / `getGreetingKey`
|
- Lib 纯函数:`toWeekday` / `countStudentAssignments` / `sortUpcomingAssignments` / `filterTodaySchedule` / `computeTeacherMetrics` / `getGreetingKey`
|
||||||
- Components:`AdminDashboardView` / `TeacherDashboardView` / `StudentDashboard` / `UserGrowthChart`(均接入 next-intl i18n)
|
- Components:`AdminDashboardView` / `TeacherDashboardView` / `StudentDashboard` / `UserGrowthChart` / `DashboardGreetingHeader`(共享问候头部,V2 抽象)/ `DashboardSection`(分区 Error Boundary + Suspense + 骨架屏)(均接入 next-intl i18n)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(通过 data-access)、`homework`(通过 data-access)、`users`(通过 data-access)、`parent`(通过 data-access.getParentDashboardData)、`textbooks`/`questions`/`exams`(通过各模块 dashboard stats 函数)、`recharts`、`next-intl`
|
- 依赖:`shared/*`、`@/auth`、`classes`(通过 data-access)、`homework`(通过 data-access)、`users`(通过 data-access)、`parent`(通过 data-access.getParentDashboardData)、`textbooks`/`questions`/`exams`(通过各模块 dashboard stats 函数)、`recharts`、`next-intl`
|
||||||
@@ -969,6 +969,10 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P2 已修复(2026-06-22):新增 `components/dashboard-section.tsx`,每个独立数据区块用 Error Boundary + Suspense + 骨架屏包裹,单区块崩溃/加载不波及整页(5 种骨架变体:stats/card/chart/table/list)
|
- ✅ P2 已修复(2026-06-22):新增 `components/dashboard-section.tsx`,每个独立数据区块用 Error Boundary + Suspense + 骨架屏包裹,单区块崩溃/加载不波及整页(5 种骨架变体:stats/card/chart/table/list)
|
||||||
- ℹ️ V1 新增:`AdminDashboardData` 类型含 `userGrowth`/`homeworkTrend` 字段,`data-access.ts` 当前返回空数组占位,待后续接入真实统计
|
- ℹ️ V1 新增:`AdminDashboardData` 类型含 `userGrowth`/`homeworkTrend` 字段,`data-access.ts` 当前返回空数组占位,待后续接入真实统计
|
||||||
- ℹ️ parent 仪表盘组件仍位于 `modules/parent/components/parent-dashboard.tsx`,通过 `dashboard/actions.getParentDashboardAction` 调用(架构决策:保留在 parent 模块以避免移动文件破坏其他 import)
|
- ℹ️ parent 仪表盘组件仍位于 `modules/parent/components/parent-dashboard.tsx`,通过 `dashboard/actions.getParentDashboardAction` 调用(架构决策:保留在 parent 模块以避免移动文件破坏其他 import)
|
||||||
|
- ✅ P0 已修复(2026-06-22 V2):10 个子组件 i18n 遗漏全部补齐(teacher-quick-actions / teacher-classes-card / teacher-homework-card / teacher-schedule / recent-submissions / teacher-grade-trends / student-grades-card / student-today-schedule-card / student-upcoming-assignments-card / admin-dashboard),新增 ~50 个翻译键
|
||||||
|
- ✅ P1 已修复(2026-06-22 V2):抽象共享组件 `DashboardGreetingHeader`,消除 teacher/student 头部 90% 重复代码
|
||||||
|
- ✅ P2 已修复(2026-06-22 V2):为 6 个纯函数添加 31 个单元测试(`tests/integration/dashboard/dashboard-utils.test.ts`)
|
||||||
|
- ✅ P2 已修复(2026-06-22 V2):a11y 增强 — admin 表格 `<caption>`、teacher/student 视图语义化标签(`<header>` / `<section aria-label>` / `<aside aria-label>`)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
@@ -978,10 +982,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `lib/dashboard-utils.ts` | 170 | 纯逻辑工具函数(weekday / 统计 / 排序 / 指标计算 / 问候语) |
|
| `lib/dashboard-utils.ts` | 170 | 纯逻辑工具函数(weekday / 统计 / 排序 / 指标计算 / 问候语) |
|
||||||
| `types.ts` | 74 | Admin / Teacher / Student 类型定义 |
|
| `types.ts` | 74 | Admin / Teacher / Student 类型定义 |
|
||||||
| `components/dashboard-section.tsx` | 165 | 分区 Error Boundary + Suspense + 骨架屏(5 种变体:stats/card/chart/table/list) |
|
| `components/dashboard-section.tsx` | 165 | 分区 Error Boundary + Suspense + 骨架屏(5 种变体:stats/card/chart/table/list) |
|
||||||
| `components/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图(i18n) |
|
| `components/dashboard-greeting-header.tsx` | 36 | 共享问候头部组件(V2 抽象,消除 teacher/student 头部重复) |
|
||||||
|
| `components/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图(i18n + a11y 表格 caption) |
|
||||||
| `components/admin-dashboard/user-growth-chart.tsx` | 50 | recharts 折线图(i18n) |
|
| `components/admin-dashboard/user-growth-chart.tsx` | 50 | recharts 折线图(i18n) |
|
||||||
| `components/teacher-dashboard/*.tsx` | 9 文件 | 教师仪表盘组件(i18n) |
|
| `components/teacher-dashboard/*.tsx` | 9 文件 | 教师仪表盘组件(i18n + a11y 语义化标签) |
|
||||||
| `components/student-dashboard/*.tsx` | 6 文件 | 学生仪表盘组件(i18n) |
|
| `components/student-dashboard/*.tsx` | 6 文件 | 学生仪表盘组件(i18n + a11y 语义化标签) |
|
||||||
|
| `tests/integration/dashboard/dashboard-utils.test.ts` | 408 | 6 个纯函数的 31 个单元测试(V2 新增) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -990,57 +996,78 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**职责**:站内私信(messages 表 CRUD)。
|
**职责**:站内私信(messages 表 CRUD)。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`sendMessageAction` / `getMessagesAction` / `getMessageAction` / `deleteMessageAction` / `getNotificationsAction` / `markNotificationReadAction` / `markAllNotificationsReadAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`
|
- Actions:`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块,messaging 仅保留私信和通知偏好 Action)
|
||||||
- Data-access:`getMessages` / `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)(通知 CRUD 通过 re-export 从 notifications 模块重导出,保持向后兼容)
|
- Data-access:`getMessages` / `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)
|
||||||
|
- Hooks:`useMessageSearch`(✅ P1-7 新增:消息搜索 hook,含防抖和请求竞态取消)
|
||||||
- Notification-preferences:~~re-export shim(实际逻辑在 `notifications/preferences.ts`)~~ ✅ P0-b 已修复:`notification-preferences.ts` 文件已删除(通知模块去重),消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`
|
- Notification-preferences:~~re-export shim(实际逻辑在 `notifications/preferences.ts`)~~ ✅ P0-b 已修复:`notification-preferences.ts` 文件已删除(通知模块去重),消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`notifications`(✅ P0-4 / P1-5 已修复:通过 `sendNotification` dispatcher 发送通知,通知 CRUD 和偏好已迁移至 notifications 模块)、`classes`(通过 data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID,支持学生 class_members 和家长 children 数据范围)、`users`(通过 data-access.getUserNamesByIds 获取用户显示名称)
|
- 依赖:`shared/*`、`@/auth`、`notifications`(✅ P0-4 / P1-5 已修复:通过 `sendNotification` dispatcher 发送通知,通知 CRUD 和偏好已迁移至 notifications 模块;✅ P1-4 已修复:通知 UI 组件已迁移至 notifications 模块)、`classes`(通过 data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID,支持学生 class_members 和家长 children 数据范围)、`users`(通过 data-access.getUserNamesByIds 获取用户显示名称)
|
||||||
- 被依赖:`notifications`(✅ 已消除反向依赖)、`settings`(通知偏好表单)、`layout`(通知下拉)
|
- 被依赖:`notifications`(✅ 已消除反向依赖)、`settings`(通知偏好表单)、`layout`(✅ P1-4 已修复:通知下拉组件改为从 notifications 模块导入)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ P0-4 已修复:~~`sendMessageAction` 绕过 notifications dispatcher 直接调用 `createNotification`~~ 改为调用 `notifications.sendNotification`,通知 CRUD 已迁移至 notifications 模块
|
- ✅ P0-4 已修复:~~`sendMessageAction` 绕过 notifications dispatcher 直接调用 `createNotification`~~ 改为调用 `notifications.sendNotification`,通知 CRUD 已迁移至 notifications 模块
|
||||||
- ✅ P0 已修复:~~与 notifications 双向依赖 + 职责重叠~~ 通知相关表(messageNotifications / notificationPreferences)所有权已移交 notifications 模块,messaging 仅保留 messages 表
|
- ✅ P0 已修复:~~与 notifications 双向依赖 + 职责重叠~~ 通知相关表(messageNotifications / notificationPreferences)所有权已移交 notifications 模块,messaging 仅保留 messages 表
|
||||||
- ✅ P1-5 已修复:~~同时管理 3 类数据(messages + messageNotifications + notificationPreferences)~~ 仅管理 messages 表,通知相关数据由 notifications 模块管理
|
- ✅ P1-5 已修复:~~同时管理 3 类数据(messages + messageNotifications + notificationPreferences)~~ 仅管理 messages 表,通知相关数据由 notifications 模块管理
|
||||||
- ✅ P1 已修复:~~通知相关 Action 使用 `requireAuth()` 而非 `requirePermission()`~~ 5 个通知 Action(getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getNotificationPreferences / updateNotificationPreferences)已改为 `requirePermission(Permissions.MESSAGE_READ)`
|
- ✅ P1 已修复:~~通知相关 Action 使用 `requireAuth()` 而非 `requirePermission()`~~ 通知 Action 已迁移至 notifications 模块并改为 `requirePermission(Permissions.MESSAGE_READ)`
|
||||||
- ✅ P1 已修复:~~`markMessageAsReadAction` / `deleteMessageAction` / `getMessageDetailAction` 缺少 Zod 校验~~ 已添加 `MessageIdSchema` 校验 messageId 参数
|
- ✅ P1 已修复:~~`markMessageAsReadAction` / `deleteMessageAction` / `getMessageDetailAction` 缺少 Zod 校验~~ 已添加 `MessageIdSchema` 校验 messageId 参数
|
||||||
- ✅ P1 已修复:~~`updateNotificationPreferencesAction` 缺少 Zod 校验~~ 已添加 `UpdateNotificationPreferencesSchema` 校验 8 个布尔字段
|
- ✅ P1 已修复:~~`updateNotificationPreferencesAction` 缺少 Zod 校验~~ 已添加 `UpdateNotificationPreferencesSchema` 校验 8 个布尔字段
|
||||||
- ✅ P2 已修复:`data-access.ts` 中 3 处 `or(...)!` 非空断言清理为安全守卫(条件 push)
|
- ✅ P2 已修复:`data-access.ts` 中 3 处 `or(...)!` 非空断言清理为安全守卫(条件 push)
|
||||||
- ✅ P0-b 已修复:~~`notification-preferences.ts` re-export shim 文件~~ 已删除(通知模块去重),8 个消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`,消除 messaging 模块对通知偏好的冗余 re-export 层
|
- ✅ P0-b 已修复:~~`notification-preferences.ts` re-export shim 文件~~ 已删除(通知模块去重),8 个消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`,消除 messaging 模块对通知偏好的冗余 re-export 层
|
||||||
- ✅ P1 已修复:~~全模块零 i18n,中英文案硬编码~~ 所有组件接入 next-intl(`useTranslations("messages")`),新增 `src/shared/i18n/messages/{zh-CN,en}/messages.json` 翻译字典(title/description/tabs/actions/form/status/meta/notificationType/search/empty/messages/error 共 13 个命名空间);所有页面 `page.tsx` 使用 `generateMetadata` + `getTranslations` 替代硬编码 metadata
|
- ✅ P1 已修复:~~全模块零 i18n,中英文案硬编码~~ 所有组件接入 next-intl(`useTranslations("messages")`),新增 `src/shared/i18n/messages/{zh-CN,en}/messages.json` 翻译字典(title/description/tabs/actions/form/status/meta/notificationType/search/empty/messages/error 共 13 个命名空间);所有页面 `page.tsx` 使用 `generateMetadata` + `getTranslations` 替代硬编码 metadata
|
||||||
- ✅ P1 已修复:~~缺 Error Boundary~~ 新增 3 个 `error.tsx` 错误边界(`/messages`、`/messages/[id]`、`/messages/compose`),统一使用 `EmptyState` + i18n 错误文案 + 重试按钮
|
- ✅ P1 已修复:~~缺 Error Boundary~~ 新增 3 个 `error.tsx` 错误边界(`/messages`、`/messages/[id]`、`/messages/compose`),统一使用 `EmptyState` + i18n 错误文案 + 重试按钮
|
||||||
- ✅ P2 已修复:a11y 改进,`message-list.tsx` / `notification-dropdown.tsx` 添加 `aria-label` / `aria-hidden`
|
- ✅ P2 已修复:a11y 改进,`message-list.tsx` 添加 `aria-label` / `aria-hidden`
|
||||||
|
- ✅ P1-4 已修复:~~通知组件(notification-list.tsx / notification-dropdown.tsx)放在 messaging/components 下,直接 import notifications 模块类型和 messaging/actions~~ 两个组件已迁移至 `notifications/components/`,通知 CRUD Action 已迁移至 `notifications/actions.ts`,messaging 模块仅保留私信组件
|
||||||
|
- ✅ P1-5 已修复:~~页面层 `Promise.all` 编排 messaging 和 notifications 两个模块的 data-access~~ 新增 `getMessagesPageData` 编排函数,页面层仅调用单一函数
|
||||||
|
- ✅ P1-7 已修复:~~消息列表客户端 `useEffect` + `setTimeout` 防抖搜索未取消已发出的请求~~ 搜索逻辑抽离为 `useMessageSearch` hook(含防抖 + 请求竞态取消);~~无分页 UI~~ 新增客户端分页 UI(PAGE_SIZE=20,ChevronLeft/ChevronRight 按钮)
|
||||||
|
- ✅ P1-9 已修复:~~`deleteMessage` 两个独立 UPDATE 无事务~~ 改为 `db.transaction` 包裹 senderDeletedAt 和 receiverDeletedAt 更新,保证原子性
|
||||||
|
- ✅ P2-11 已修复:~~发送/删除消息无埋点~~ `sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` 新增 `trackEvent` 埋点(message.sent / message.marked_read / message.deleted)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 276 | 9 个 Server Action(通知相关 Action 委托 notifications 模块) |
|
| `actions.ts` | ~260 | 7 个私信 Server Action + 2 个通知偏好 Action(✅ P1-4:通知 CRUD Action 已迁移至 notifications 模块) |
|
||||||
| `data-access.ts` | 199 | 私信 CRUD + re-export 通知 CRUD(向后兼容) |
|
| `data-access.ts` | ~270 | 私信 CRUD + `getMessagesPageData` 编排函数(✅ P1-5 新增) |
|
||||||
| ~~`notification-preferences.ts`~~ | ~~11~~ | ~~re-export shim~~ ✅ P0-b 已删除(消费方改为直接从 notifications/preferences 导入) |
|
| `schema.ts` | 44 | 私信发送校验 + messageId 校验 + 通知偏好更新校验 |
|
||||||
| `schema.ts` | 41 | 私信发送校验 + messageId 校验 + 通知偏好更新校验 |
|
| `types.ts` | 52 | 私信类型 + re-export 通知类型(向后兼容) |
|
||||||
| `types.ts` | 72 | 私信类型 + re-export 通知类型(向后兼容) |
|
| `hooks/use-message-search.ts` | ~60 | ✅ P1-7 新增:消息搜索 hook(防抖 + 请求竞态取消) |
|
||||||
|
|
||||||
|
**组件清单**:
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `components/message-list.tsx` | 消息列表(✅ P1-7:使用 `useMessageSearch` hook + 客户端分页 UI,PAGE_SIZE=20) |
|
||||||
|
| `components/message-detail.tsx` | 消息详情(含回复) |
|
||||||
|
| `components/message-compose.tsx` | 撰写新消息 |
|
||||||
|
| `components/unread-message-badge.tsx` | 未读消息计数徽章(侧边栏,每 60 秒轮询 `getUnreadMessageCountAction`) |
|
||||||
|
|
||||||
|
**客户端行为**:
|
||||||
|
- `message-list.tsx`:客户端调用 `getMessagesAction` 搜索消息(useMessageSearch hook,400ms 防抖,请求竞态取消)
|
||||||
|
- `unread-message-badge.tsx`:每 60 秒轮询 `getUnreadMessageCountAction` 刷新未读计数
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2.14 notifications(通知分发模块)
|
## 2.14 notifications(通知分发模块)
|
||||||
|
|
||||||
**职责**:多渠道通知分发(SMS/Email/WeChat/InApp)+ 站内通知 CRUD + 通知偏好管理。
|
**职责**:多渠道通知分发(SMS/Email/WeChat/InApp)+ 站内通知 CRUD + 通知偏好管理 + 通知 UI 组件。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`sendNotificationAction` / `sendClassNotificationAction`
|
- Actions:`sendNotificationAction` / `sendClassNotificationAction` / `getNotificationsAction` / `getUnreadNotificationCountAction` / `markNotificationAsReadAction` / `markAllNotificationsAsReadAction`(✅ P1-4 新增:后 4 个通知 CRUD Action 从 messaging 模块迁移)
|
||||||
- Dispatcher:`sendNotification(payload)` / `sendBatchNotifications(payloads)`
|
- Dispatcher:`sendNotification(payload)` / `sendBatchNotifications(payloads)`
|
||||||
- Data-access:`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
- Data-access:`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
||||||
- Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
- Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
||||||
- Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
|
- Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
|
||||||
|
- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId)
|
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getStudentIdsByClassId)
|
||||||
- 被依赖:`messaging`(✅ P0-4 / P1-5 已修复:messaging 通过 `sendNotification` dispatcher 发送通知,通知 CRUD 和偏好通过 re-export 保持向后兼容)
|
- 被依赖:`messaging`(✅ P0-4 / P1-5 已修复:messaging 通过 `sendNotification` dispatcher 发送通知;✅ P1-4 已修复:通知 UI 组件由 notifications 模块自持,messaging 不再反向依赖)、`layout`(✅ P1-4 已修复:通知下拉组件直接从 notifications 模块导入)、`app/(dashboard)/messages`(✅ P1-4 已修复:通知列表组件直接从 notifications 模块导入)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ P0-4 已修复:~~不拥有任何数据,全部依赖 messaging 模块~~ messageNotifications 和 notificationPreferences 表所有权已从 messaging 迁移至 notifications 模块
|
- ✅ P0-4 已修复:~~不拥有任何数据,全部依赖 messaging 模块~~ messageNotifications 和 notificationPreferences 表所有权已从 messaging 迁移至 notifications 模块
|
||||||
- ✅ P0 已修复:~~与 messaging 双向依赖~~ notifications 不再反向依赖 messaging,in-app-channel 改为静态导入本地 createNotification
|
- ✅ P0 已修复:~~与 messaging 双向依赖~~ notifications 不再反向依赖 messaging,in-app-channel 改为静态导入本地 createNotification
|
||||||
- ✅ P1-1 已修复:~~`sendClassNotificationAction` 直查 `classes`/`classEnrollments`~~ 改为调用 `classes/data-access.getClassExists` / `getStudentIdsByClassId`
|
- ✅ P1-1 已修复:~~`sendClassNotificationAction` 直查 `classes`/`classEnrollments`~~ 改为调用 `classes/data-access.getClassExists` / `getStudentIdsByClassId`
|
||||||
|
- ✅ P1-4 已修复:~~通知 UI 组件放在 messaging/components 下,直接 import notifications 类型和 messaging/actions~~ `notification-list.tsx` 和 `notification-dropdown.tsx` 已迁移至 `notifications/components/`,通知 CRUD Action 已从 messaging 迁移至 `notifications/actions.ts`,消除 UI 层跨模块耦合
|
||||||
|
- ✅ P2-11 已修复:~~通知标记已读无埋点~~ `markNotificationAsReadAction` / `markAllNotificationsAsReadAction` 新增 `trackEvent` 埋点(notification.marked_read / notification.marked_all_read)
|
||||||
- ⚠️ P1:发送日志仅 console,无 `notification_logs` 表
|
- ⚠️ P1:发送日志仅 console,无 `notification_logs` 表
|
||||||
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
|
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
|
||||||
|
|
||||||
@@ -1050,10 +1077,21 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `dispatcher.ts` | 152 | 渠道选择 + 并行分发 |
|
| `dispatcher.ts` | 152 | 渠道选择 + 并行分发 |
|
||||||
| `data-access.ts` | 177 | 站内通知 CRUD + 用户联系方式 + 日志(P0-4 / P1-5 修复后新增通知 CRUD) |
|
| `data-access.ts` | 177 | 站内通知 CRUD + 用户联系方式 + 日志(P0-4 / P1-5 修复后新增通知 CRUD) |
|
||||||
| `preferences.ts` | 166 | 通知偏好 CRUD(P0-4 / P1-5 修复后从 messaging 迁移) |
|
| `preferences.ts` | 166 | 通知偏好 CRUD(P0-4 / P1-5 修复后从 messaging 迁移) |
|
||||||
| `actions.ts` | 119 | 2 个 Server Action |
|
| `actions.ts` | ~260 | 6 个 Server Action(✅ P1-4:新增 4 个通知 CRUD Action) |
|
||||||
| `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充) |
|
| `types.ts` | 120 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充) |
|
||||||
| `index.ts` | 61 | 对外导出入口 |
|
| `index.ts` | ~75 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出) |
|
||||||
| `channels/*` | 5 文件 | 4 个渠道实现 |
|
| `channels/*` | 5 文件 | 4 个渠道实现 |
|
||||||
|
| `components/notification-list.tsx` | ~140 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件 |
|
||||||
|
| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件 |
|
||||||
|
|
||||||
|
**组件清单**:
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读) |
|
||||||
|
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,每 30 秒轮询 `getNotificationsAction` + `getUnreadNotificationCountAction`) |
|
||||||
|
|
||||||
|
**客户端行为**:
|
||||||
|
- `notification-dropdown.tsx`:每 30 秒轮询 `getNotificationsAction`(pageSize=10)和 `getUnreadNotificationCountAction` 刷新通知和未读计数
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1090,7 +1128,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`)
|
- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`)
|
||||||
- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)
|
- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)
|
- 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)
|
||||||
@@ -1107,14 +1145,26 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P1 已修复:~~全模块零 i18n,中英文案硬编码~~ 所有组件接入 next-intl(`useTranslations("announcements")`),新增 `src/shared/i18n/messages/{zh-CN,en}/announcements.json` 翻译字典(title/description/filter/status/type/form/actions/messages/meta/empty/error 共 11 个命名空间);所有页面 `page.tsx` 使用 `generateMetadata` + `getTranslations` 替代硬编码 metadata
|
- ✅ P1 已修复:~~全模块零 i18n,中英文案硬编码~~ 所有组件接入 next-intl(`useTranslations("announcements")`),新增 `src/shared/i18n/messages/{zh-CN,en}/announcements.json` 翻译字典(title/description/filter/status/type/form/actions/messages/meta/empty/error 共 11 个命名空间);所有页面 `page.tsx` 使用 `generateMetadata` + `getTranslations` 替代硬编码 metadata
|
||||||
- ✅ P1 已修复:~~缺 Error Boundary~~ 新增 4 个 `error.tsx` 错误边界(`/announcements`、`/announcements/[id]`、`/admin/announcements`、`/admin/announcements/[id]`),统一使用 `EmptyState` + i18n 错误文案 + 重试按钮
|
- ✅ P1 已修复:~~缺 Error Boundary~~ 新增 4 个 `error.tsx` 错误边界(`/announcements`、`/announcements/[id]`、`/admin/announcements`、`/admin/announcements/[id]`),统一使用 `EmptyState` + i18n 错误文案 + 重试按钮
|
||||||
- ✅ P2 已修复:a11y 改进,`announcement-card.tsx` / `announcement-detail.tsx` 添加 `aria-label`
|
- ✅ P2 已修复:a11y 改进,`announcement-card.tsx` / `announcement-detail.tsx` 添加 `aria-label`
|
||||||
|
- ✅ P1-5 已修复:~~页面层 `Promise.all` 编排 announcements/school/classes 三个模块的 data-access~~ 新增 `getAdminAnnouncementsPageData` 和 `getEditAnnouncementPageData` 编排函数,页面层仅调用单一函数
|
||||||
|
- ✅ P1-6 已修复:~~`targetGradeId` / `targetClassId` 为 optional,未根据 `type` 做条件必填校验~~ `CreateAnnouncementSchema` 和 `UpdateAnnouncementSchema` 添加 `superRefine(refineAudience)`,年级公告强制 `targetGradeId`,班级公告强制 `targetClassId`
|
||||||
|
- ✅ P2-11 已修复:~~发布/归档/删除公告无埋点~~ `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction` 新增 `trackEvent` 埋点(announcement.created / announcement.updated / announcement.published / announcement.deleted / announcement.archived)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | ~270 | 6 个 Server Action + 通知触发逻辑(P1-2 已修复,无直接 DB 操作) |
|
| `actions.ts` | ~330 | 6 个 Server Action + 通知触发逻辑 + trackEvent 埋点(P1-2 已修复,无直接 DB 操作) |
|
||||||
| `data-access.ts` | ~190 | 公告 CRUD + 发布/归档 + 受众过滤(含 P1-2 新增 5 个写函数) |
|
| `data-access.ts` | ~230 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增) |
|
||||||
| `schema.ts` | - | Zod 校验 |
|
| `schema.ts` | ~70 | Zod 校验 + `refineAudience` 条件校验(✅ P1-6 新增 superRefine) |
|
||||||
| `types.ts` | - | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段) |
|
| `types.ts` | ~65 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段) |
|
||||||
|
|
||||||
|
**组件清单**:
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `components/announcement-list.tsx` | 公告列表(用户端,支持状态筛选) |
|
||||||
|
| `components/announcement-card.tsx` | 公告卡片(列表项) |
|
||||||
|
| `components/announcement-detail.tsx` | 公告详情(只读) |
|
||||||
|
| `components/announcement-form.tsx` | 公告表单(创建/编辑,✅ P1-6:条件校验由 schema superRefine 保证) |
|
||||||
|
| `components/admin-announcements-view.tsx` | 管理端公告视图(列表 + 筛选) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1392,8 +1442,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
- Actions:`getAiProvidersAction` / `createAiProviderAction` / `updateAiProviderAction` / `deleteAiProviderAction` / `testAiProviderAction`
|
||||||
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
- Actions-password:`changePasswordAction`(✅ P1 已修复:使用 `requirePermission(USER_PROFILE_UPDATE)` + Zod 校验 + DB 操作下沉到 data-access)
|
||||||
|
- Actions-avatar:`updateUserAvatarAction` / `removeUserAvatarAction`(✅ P2-8 新增:头像上传/删除,复用 `/api/upload` 路由)
|
||||||
|
- Actions-notifications:`sendTestNotificationAction`(✅ P2-10 新增:发送测试通知,占位实现待接入真实通知服务)
|
||||||
|
- Actions-system-settings:`getAdminSystemSettingsAction` / `saveAdminSystemSettingsAction`(✅ P0-3 新增:管理员系统设置 CRUD,4 分类 Zod 校验)
|
||||||
|
- Actions-security:`getSecurityCenterAction` / `toggleTwoFactorAction`(✅ P2-9 新增:2FA 状态查询/切换 + 最近登录历史)
|
||||||
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
- Data-access:`getAiProviderSummaries` / `countDefaultAiProviders` / `getAiProviderForUpdate` / `updateAiProvider` / `createAiProvider` / `getUserPasswordHash` / `getPasswordSecurityByUserId` / `updateUserPassword` / `upsertPasswordSecurityOnPasswordChange`(P1 新增,从 actions 下沉)
|
||||||
- Components:`SettingsView`(统一设置页布局,5 标签页 General/Notifications/Appearance/Security/AI;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏;AI 标签页条件渲染需 `AI_CONFIGURE` 权限)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(系统设置视图,4 个 Card)
|
- Data-access-system-settings:`getSystemSettingsByCategory` / `getAllSystemSettings` / `getSystemSetting` / `upsertSystemSetting` / `upsertSystemSettings`(✅ P0-3 新增:system_settings 表 CRUD,键值对存储模式)
|
||||||
|
- Components:`SettingsView`(统一设置页布局,5 标签页 General/Notifications/Appearance/Security/AI;角色差异通过 `resolveRoleSettingsConfig` 配置驱动 + `generalExtra` props 注入;Tab URL 持久化;每个 TabsContent 包裹 `SettingsSectionErrorBoundary` + `Suspense` 骨架屏;AI 标签页条件渲染需 `AI_CONFIGURE` 权限)、`SettingsServiceProvider` / `useSettingsService`(Context 注入 `SettingsService` 接口,解耦组件对 users/messaging actions 的直接依赖)、`SettingsSectionErrorBoundary`(分区 Error Boundary,局部失败不影响整页)、`QuickLinksCard`(快捷链接卡片,i18n 键驱动)、`ProfileStudentOverview` / `ProfileStudentOverviewSkeleton`(学生概览异步 Server Component + 骨架屏)、`ProfileTeacherOverview` / `ProfileTeacherOverviewSkeleton`(教师概览异步 Server Component + 骨架屏)、`AdminSettingsView`(✅ P0-3 已修复:从 mock 改为真实数据层,通过 Server Actions 加载/保存到 system_settings 表)、`AvatarUpload`(✅ P2-8 新增:头像上传/预览/删除客户端组件,文件验证 + i18n)、`SecurityCenterCard`(✅ P2-9 新增:2FA 开关 + 最近登录历史卡片)、`ThemePreferencesCard`(✅ P2-11 已增强:集成 `LocaleSwitcher` 语言切换)
|
||||||
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
- Config:`ROLE_SETTINGS_CONFIG` / `resolveRoleSettingsConfig`(配置驱动角色 → 设置视图映射,新增角色只需添加条目)
|
||||||
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
- Lib:`buildStudentOverviewData` / `computeStudentStats` / `sortUpcomingAssignments` / `filterTodaySchedule` / `toWeekday`(纯数据计算函数,与 UI 分离,便于单元测试)
|
||||||
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
- Types:`AiProviderSummary` / `AiProviderName` / `AiProviderExisting` / `SettingsService` / `ProfileService` / `NotificationPreferenceService`(服务接口定义,用于依赖注入解耦)
|
||||||
@@ -1421,27 +1476,39 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P1 已修复:~~profile/page.tsx 业务逻辑与 UI 混合~~ 抽取 `buildStudentOverviewData` 等纯函数到 `lib/student-overview-data.ts`;拆分 `ProfileStudentOverview` / `ProfileTeacherOverview` 异步组件
|
- ✅ P1 已修复:~~profile/page.tsx 业务逻辑与 UI 混合~~ 抽取 `buildStudentOverviewData` 等纯函数到 `lib/student-overview-data.ts`;拆分 `ProfileStudentOverview` / `ProfileTeacherOverview` 异步组件
|
||||||
- ✅ 密码修改有速率限制
|
- ✅ 密码修改有速率限制
|
||||||
- ✅ AI Provider 操作有 `AI_CONFIGURE` 权限校验
|
- ✅ AI Provider 操作有 `AI_CONFIGURE` 权限校验
|
||||||
|
- ✅ P0-3 已修复:~~AdminSettingsView 为 mock 实现,无数据持久化~~ 新增 `system_settings` 表(键值对存储)+ `data-access-system-settings.ts` + `actions-system-settings.ts`,AdminSettingsView 改为真实数据层
|
||||||
|
- ✅ P2-8 已修复:~~无头像上传~~ 新增 `updateUserAvatar` data-access + `actions-avatar.ts` + `AvatarUpload` 组件,复用 `/api/upload` 路由
|
||||||
|
- ✅ P2-9 已修复:~~无 2FA / 会话管理~~ 新增 `actions-security.ts` + `SecurityCenterCard` 组件(2FA 开关占位 + 最近登录历史来自 login_logs 表)
|
||||||
|
- ✅ P2-10 已修复:~~通知偏好表单无测试通知按钮~~ 新增 `sendTestNotificationAction`,每个已启用渠道旁显示测试按钮
|
||||||
|
- ✅ P2-11 已修复:~~语言切换未集成到设置页~~ `ThemePreferencesCard` 集成 `LocaleSwitcher` 到 Appearance 标签页
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 160 | AI Provider CRUD + 测试(P1 已修复,无直接 DB 操作) |
|
| `actions.ts` | 160 | AI Provider CRUD + 测试(P1 已修复,无直接 DB 操作) |
|
||||||
| `actions-password.ts` | 87 | 修改密码(P1 已修复:requirePermission + Zod + data-access) |
|
| `actions-password.ts` | 87 | 修改密码(P1 已修复:requirePermission + Zod + data-access) |
|
||||||
|
| `actions-avatar.ts` | 56 | 头像上传/删除(P2-8 新增:requirePermission + revalidatePath) |
|
||||||
|
| `actions-notifications.ts` | 46 | 发送测试通知(P2-10 新增:占位实现待接入真实通知服务) |
|
||||||
|
| `actions-system-settings.ts` | 186 | 管理员系统设置 CRUD(P0-3 新增:4 分类 Zod 校验 + upsert) |
|
||||||
|
| `actions-security.ts` | 165 | 2FA 状态查询/切换 + 最近登录历史(P2-9 新增) |
|
||||||
| `data-access.ts` | 158 | AI Provider CRUD + 密码修改 DB 操作(P1 新增) |
|
| `data-access.ts` | 158 | AI Provider CRUD + 密码修改 DB 操作(P1 新增) |
|
||||||
|
| `data-access-system-settings.ts` | 119 | system_settings 表 CRUD(P0-3 新增:键值对存储模式) |
|
||||||
| `types.ts` | 60 | 类型定义(AiProviderSummary + SettingsService/ProfileService/NotificationPreferenceService 接口) |
|
| `types.ts` | 60 | 类型定义(AiProviderSummary + SettingsService/ProfileService/NotificationPreferenceService 接口) |
|
||||||
| `config/role-settings-config.tsx` | 85 | 角色设置页配置驱动映射(ROLE_SETTINGS_CONFIG + resolveRoleSettingsConfig) |
|
| `config/role-settings-config.tsx` | 85 | 角色设置页配置驱动映射(ROLE_SETTINGS_CONFIG + resolveRoleSettingsConfig) |
|
||||||
| `lib/student-overview-data.ts` | 150 | 学生概览纯数据计算(buildStudentOverviewData + computeStudentStats 等,便于单测) |
|
| `lib/student-overview-data.ts` | 150 | 学生概览纯数据计算(buildStudentOverviewData + computeStudentStats 等,便于单测) |
|
||||||
| `components/settings-view.tsx` | 236 | SettingsView 统一设置页布局(5 标签页 + Error Boundary + Suspense + i18n) |
|
| `components/settings-view.tsx` | 236 | SettingsView 统一设置页布局(5 标签页 + Error Boundary + Suspense + i18n + SecurityCenterCard 集成) |
|
||||||
| `components/settings-service-context.tsx` | 39 | SettingsServiceProvider + useSettingsService(Context 注入服务接口) |
|
| `components/settings-service-context.tsx` | 39 | SettingsServiceProvider + useSettingsService(Context 注入服务接口) |
|
||||||
| `components/settings-section-error-boundary.tsx` | 64 | 分区 Error Boundary(局部失败不影响整页) |
|
| `components/settings-section-error-boundary.tsx` | 64 | 分区 Error Boundary(局部失败不影响整页) |
|
||||||
| `components/quick-links-card.tsx` | 42 | 快捷链接卡片(i18n 键驱动) |
|
| `components/quick-links-card.tsx` | 42 | 快捷链接卡片(i18n 键驱动) |
|
||||||
| `components/profile-student-overview.tsx` | 91 | 学生概览异步 Server Component + 骨架屏 |
|
| `components/profile-student-overview.tsx` | 91 | 学生概览异步 Server Component + 骨架屏 |
|
||||||
| `components/profile-teacher-overview.tsx` | 115 | 教师概览异步 Server Component + 骨架屏 |
|
| `components/profile-teacher-overview.tsx` | 115 | 教师概览异步 Server Component + 骨架屏 |
|
||||||
| `components/admin-settings-view.tsx` | 195 | AdminSettingsView 系统设置视图(4 个 Card,i18n) |
|
| `components/admin-settings-view.tsx` | 425 | AdminSettingsView 系统设置视图(P0-3 已修复:真实数据层 + 4 个 Card + i18n) |
|
||||||
|
| `components/avatar-upload.tsx` | ~150 | 头像上传/预览/删除客户端组件(P2-8 新增:文件验证 + i18n) |
|
||||||
|
| `components/security-center-card.tsx` | ~240 | 安全中心卡片(P2-9 新增:2FA 开关 + 最近登录历史) |
|
||||||
| `components/profile-settings-form.tsx` | 158 | 个人资料表单(通过 SettingsService 注入,i18n) |
|
| `components/profile-settings-form.tsx` | 158 | 个人资料表单(通过 SettingsService 注入,i18n) |
|
||||||
| `components/notification-preferences-form.tsx` | ~140 | 通知偏好表单(通过 SettingsService 注入,i18n) |
|
| `components/notification-preferences-form.tsx` | ~160 | 通知偏好表单(通过 SettingsService 注入,i18n + P2-10 测试按钮) |
|
||||||
| `components/password-change-form.tsx` | ~130 | 密码修改表单(i18n + a11y) |
|
| `components/password-change-form.tsx` | ~130 | 密码修改表单(i18n + a11y) |
|
||||||
| `components/theme-preferences-card.tsx` | ~60 | 主题偏好卡片(i18n) |
|
| `components/theme-preferences-card.tsx` | ~80 | 主题偏好卡片(i18n + P2-11 LocaleSwitcher 集成) |
|
||||||
| `components/ai-provider-settings-card.tsx` | ~200 | AI 服务商配置卡片(i18n) |
|
| `components/ai-provider-settings-card.tsx` | ~200 | AI 服务商配置卡片(i18n) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1568,10 +1635,16 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
> 架构变更(2026-06-22,本次审计修复):
|
> 架构变更(2026-06-22,本次审计修复):
|
||||||
> - **P0-1 跨模块直查修复**:`publish-service.ts` 不再直接 `db.insert(examQuestions)` 和本地实现 `getStudentIdsByClassIds`,改为调用 `exams/data-access.addExamQuestions` 和 `classes/data-access.getStudentIdsByClassIds`,恢复三层架构约束
|
> - **P0-1 跨模块直查修复**:`publish-service.ts` 不再直接 `db.insert(examQuestions)` 和本地实现 `getStudentIdsByClassIds`,改为调用 `exams/data-access.addExamQuestions` 和 `classes/data-access.getStudentIdsByClassIds`,恢复三层架构约束
|
||||||
> - **P0-2 i18n 接入**:新增 `shared/i18n/messages/zh-CN/lesson-preparation.json` 和 `shared/i18n/messages/en/lesson-preparation.json`,注册 `lessonPreparation` 命名空间到 `src/i18n/request.ts`,17 个组件改造为 `useTranslations`/`getTranslations`
|
> - **P0-2 i18n 接入**:新增 `shared/i18n/messages/zh-CN/lesson-preparation.json` 和 `shared/i18n/messages/en/lesson-preparation.json`,注册 `lessonPreparation` 命名空间到 `src/i18n/request.ts`,17 个组件改造为 `useTranslations`/`getTranslations`
|
||||||
|
> - **P0-3 DataScope 过滤修复**:`data-access.ts` 的 `buildScopeCondition` 按 scope 类型精确过滤——`class_taught` 增加 `subjectId IN teacher.subjects AND gradeId IN teacher.grades`,`grade_managed` 限制 `gradeId IN managedGrades`,`class_members`/`children` 仅允许查看 `published` 课案
|
||||||
|
> - **P1-1 类型安全修复**:`as never` 断言全部替换为类型守卫函数(`isQuestionType`/`isV1Document`/`isV2Document`)+ `validTypes` 数组;`block-renderer.tsx` 使用 `as ExerciseBlockData`/`as TextStudyBlockData` 精确断言;`constants.ts` 的 `BLOCK_TYPE_LABELS`/`LESSON_PLAN_STATUS_LABELS` 改为 i18n 键(`BLOCK_TYPE_KEYS`/`LESSON_PLAN_STATUS_KEYS`)
|
||||||
> - **P1 纯函数抽取**:新增 `lib/document-migration.ts`(migrateV1ToV2/normalizeDocument/buildInitialContent,使用类型守卫替代 as 断言)、`lib/node-summary.ts`(getNodeSummary + NODE_COLORS + getNodeColor,接受翻译函数注入)、`lib/rf-mappers.ts`(toRfNodes/toRfEdges/fromRfEdges),data-access.ts 改为从 lib/ 导入并 re-export 保持向后兼容
|
> - **P1 纯函数抽取**:新增 `lib/document-migration.ts`(migrateV1ToV2/normalizeDocument/buildInitialContent,使用类型守卫替代 as 断言)、`lib/node-summary.ts`(getNodeSummary + NODE_COLORS + getNodeColor,接受翻译函数注入)、`lib/rf-mappers.ts`(toRfNodes/toRfEdges/fromRfEdges),data-access.ts 改为从 lib/ 导入并 re-export 保持向后兼容
|
||||||
> - **P1 错误边界 + 骨架屏**:新增 `components/lesson-plan-error-boundary.tsx`(LessonPlanErrorBoundary 类组件)和 `components/lesson-plan-skeleton.tsx`(VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton)
|
> - **P1-2/P1-3 错误边界 + 骨架屏**:新增 `components/lesson-plan-error-boundary.tsx`(LessonPlanErrorBoundary 类组件)和 `components/lesson-plan-skeleton.tsx`(VersionListSkeleton/QuestionBankSkeleton/KnowledgePointSkeleton/LessonPlanListSkeleton)
|
||||||
> - **P1 Block 注册表**:新增 `config/block-registry.tsx`(BLOCK_REGISTRY 配置表 + getBlockComponent/isRichTextBlock),`node-edit-panel.tsx` 重构为配置驱动渲染,移除 if/else 链
|
> - **P1-4 阻塞式 UI 修复**:`alert()` 全部替换为 `sonner` toast;`confirm()` 全部替换为 `AlertDialog`(shadcn);`window.location.reload` 替换为 `router.refresh()`;涉及 lesson-plan-card/version-history-drawer/inline-question-editor/text-study-block/exercise-block
|
||||||
> - **P1 window.location.reload 修复**:`exercise-block.tsx` 改用 `router.refresh()` 精确刷新缓存
|
> - **P1-5/P1-7 多实例 + 角色配置驱动**:新增 `providers/lesson-plan-provider.tsx`(LessonPlanProvider + Context + 4 个角色配置 TEACHER/ADMIN/STUDENT/PARENT + LessonPlanDataService 接口 + LessonPlanTracker 埋点接口 + useLessonPlanContextSafe/useRoleConfig 等 hooks)和 `services/default-data-service.ts`(包装 Server Actions 为 DataService 实现),lesson-plan-list/lesson-plan-card 通过 Context 注入数据服务,useRoleConfig 控制按钮可见性
|
||||||
|
> - **P1-8 Block 注册表**:新增 `config/block-registry.tsx`(BLOCK_REGISTRY 配置表 + getBlockComponent/isRichTextBlock),`node-edit-panel.tsx` 重构为配置驱动渲染,移除 if/else 链
|
||||||
|
> - **P1-4 window.location.reload 修复**:`exercise-block.tsx` 改用 `router.refresh()` 精确刷新缓存
|
||||||
|
> - **P2-1 a11y 修复**:5 个组件(question-bank-picker/publish-homework-dialog/knowledge-point-picker/exercise-block/text-study-block)添加 `role="dialog"`/`aria-modal`/`aria-label`;inline-question-editor 添加 `role="dialog"`/`aria-modal`/`aria-label`
|
||||||
|
> - **P2-4 监控埋点预留**:`providers/lesson-plan-provider.tsx` 定义 `LessonPlanTracker` 接口 + `noopTracker` 默认空实现,生产环境可替换为真实埋点
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
@@ -1583,7 +1656,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(接受翻译函数注入,支持 i18n)+ NODE_COLORS + getNodeColor |
|
| `lib/node-summary.ts` | **纯函数**:getNodeSummary(接受翻译函数注入,支持 i18n)+ NODE_COLORS + getNodeColor |
|
||||||
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes/toRfEdges/fromRfEdges(LessonPlanNode/Edge ↔ React Flow Node/Edge 映射) |
|
| `lib/rf-mappers.ts` | **纯函数**:toRfNodes/toRfEdges/fromRfEdges(LessonPlanNode/Edge ↔ React Flow Node/Edge 映射) |
|
||||||
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlock,node-edit-panel 通过配置渲染 Block |
|
| `config/block-registry.tsx` | **配置驱动**:BLOCK_REGISTRY 注册表 + getBlockComponent/isRichTextBlock,node-edit-panel 通过配置渲染 Block |
|
||||||
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容) |
|
| `providers/lesson-plan-provider.tsx` | **Provider + Context(P1-5/P1-7/P2-4)**:LessonPlanProvider 注入数据服务/角色配置/埋点;定义 LessonPlanDataService 接口、4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)、ROLE_CONFIGS 注册表、LessonPlanTracker 接口 + noopTracker;hooks:useLessonPlanContextSafe(返回 null 不抛错)/useLessonPlanContext/useRoleConfig/useLessonPlanService/useLessonPlanTracker |
|
||||||
|
| `services/default-data-service.ts` | **默认数据服务实现**:createDefaultDataService() 包装 Server Actions 为 LessonPlanDataService 实现,测试可替换为 mock |
|
||||||
|
| `data-access.ts` | 课案 CRUD + 模板查询(migrateV1ToV2/normalizeDocument/buildInitialContent 从 lib/ 导入并 re-export 保持向后兼容;buildScopeCondition 按 scope 类型精确过滤 P0-3) |
|
||||||
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
|
| `data-access-versions.ts` | 版本管理(创建/查询/回滚/清理) |
|
||||||
| `data-access-templates.ts` | 个人模板 CRUD |
|
| `data-access-templates.ts` | 个人模板 CRUD |
|
||||||
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
|
| `data-access-knowledge.ts` | 按知识点/题目反查课案 |
|
||||||
|
|||||||
@@ -6055,6 +6055,11 @@
|
|||||||
"name": "RecentSubmissions",
|
"name": "RecentSubmissions",
|
||||||
"file": "teacher-dashboard/RecentSubmissions",
|
"file": "teacher-dashboard/RecentSubmissions",
|
||||||
"purpose": "最近提交"
|
"purpose": "最近提交"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "DashboardGreetingHeader",
|
||||||
|
"file": "dashboard-greeting-header",
|
||||||
|
"purpose": "共享问候头部组件(V2 抽象,消除 teacher/student 头部 90% 重复代码,接收 userName 和可选 actions slot)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -8329,6 +8334,80 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"statsService": [
|
||||||
|
{
|
||||||
|
"name": "computeGradeStats",
|
||||||
|
"signature": "(rows: RawScoreRow[]) => GradeStats | null",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "从原始成绩行计算班级统计(均分、中位数、标准差、及格率、优秀率、最高分、最低分、参考人数)(P1-1 新增:从 data-access.getClassGradeStats 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access.getClassGradeStats"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeAverageScore",
|
||||||
|
"signature": "(scores: number[]) => number",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "计算分数平均值(空数组返回 0)(P1-1 新增:从 data-access.getStudentGradeSummary 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access.getStudentGradeSummary"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "buildGradeTrendPoints",
|
||||||
|
"signature": "(rows: RawScoreRow[]) => GradeTrendPoint[]",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "构建成绩趋势数据点(按考试标题分组,归一化分数 0-100)(P1-1 新增:从 data-access-analytics.getGradeTrend 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getGradeTrend"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeTrendAverage",
|
||||||
|
"signature": "(points: GradeTrendPoint[]) => number",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "计算趋势数据点的平均分(P1-1 新增:从 data-access-analytics.getGradeTrend 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getGradeTrend"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeClassComparisonStats",
|
||||||
|
"signature": "(rows: RawScoreRow[]) => Pick<ClassComparisonItem, 'averageScore' | 'passRate' | 'excellentRate' | 'studentCount'>",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "计算班级对比统计(均分、及格率、优秀率、参考人数)(P1-1 新增:从 data-access-analytics.getClassComparison 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getClassComparison"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeSubjectComparisonStats",
|
||||||
|
"signature": "(scores: number[]) => Pick<SubjectComparisonItem, 'averageScore' | 'passRate' | 'excellentRate' | 'maxScore' | 'minScore'>",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "计算科目对比统计(均分、及格率、优秀率、最高分、最低分)(P1-1 新增:从 data-access-analytics.getSubjectComparison 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getSubjectComparison"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeGradeDistribution",
|
||||||
|
"signature": "(rows: RawScoreRow[]) => GradeDistributionResult",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "计算分数分布(90-100/80-89/70-79/60-69/<60 五个区间)(P1-1 新增:从 data-access-analytics.getGradeDistribution 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-analytics.getGradeDistribution"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "buildRankingTrendPoints",
|
||||||
|
"signature": "(byTitle: Map<string, RankingTrendEntry>, targetStudentId: string) => RankingTrendPoint[]",
|
||||||
|
"file": "stats-service.ts",
|
||||||
|
"purpose": "构建排名趋势数据点(按考试标题排序、计算每次考试学生排名)(P1-1 新增:从 data-access-ranking.getRankingTrend 抽取为纯函数)",
|
||||||
|
"usedBy": [
|
||||||
|
"data-access-ranking.getRankingTrend"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"name": "GradeRecordForm",
|
"name": "GradeRecordForm",
|
||||||
@@ -8433,6 +8512,15 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"teacher/grades/stats/page.tsx"
|
"teacher/grades/stats/page.tsx"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WidgetBoundary",
|
||||||
|
"file": "components/widget-boundary.tsx",
|
||||||
|
"purpose": "通用 Widget 边界组件(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性 role=alert/aria-live/aria-label)(P1-5 新增)",
|
||||||
|
"deps": [
|
||||||
|
"shared/components/ui/skeleton",
|
||||||
|
"shared/components/ui/button"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -9109,19 +9197,20 @@
|
|||||||
},
|
},
|
||||||
"messaging": {
|
"messaging": {
|
||||||
"path": "src/modules/messaging",
|
"path": "src/modules/messaging",
|
||||||
"description": "站内消息系统:用户间私信收发(支持回复链)、站内通知(多态类型:message/announcement/homework/grade),SiteHeader 通知下拉菜单展示未读数",
|
"description": "站内私信系统:用户间私信收发(支持回复链)。通知相关 UI 组件和 CRUD Action 已迁移至 notifications 模块(P1-4 修复)",
|
||||||
"exports": {
|
"exports": {
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
"name": "sendMessageAction",
|
"name": "sendMessageAction",
|
||||||
"permission": "MESSAGE_SEND",
|
"permission": "MESSAGE_SEND",
|
||||||
"signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>",
|
"signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>",
|
||||||
"purpose": "发送消息(同时通过 notifications dispatcher 为收件人创建多渠道通知;支持 parentMessageId 回复)",
|
"purpose": "发送消息(同时通过 notifications dispatcher 为收件人创建多渠道通知;支持 parentMessageId 回复;P2-11 新增 trackEvent 埋点)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"shared/db",
|
"shared/db",
|
||||||
"data-access.createMessage",
|
"data-access.createMessage",
|
||||||
"notifications.dispatcher.sendNotification",
|
"notifications.dispatcher.sendNotification",
|
||||||
|
"trackEvent",
|
||||||
"revalidatePath"
|
"revalidatePath"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
@@ -9132,11 +9221,12 @@
|
|||||||
"name": "markMessageAsReadAction",
|
"name": "markMessageAsReadAction",
|
||||||
"permission": "MESSAGE_READ",
|
"permission": "MESSAGE_READ",
|
||||||
"signature": "(id: string) => Promise<ActionState<void>>",
|
"signature": "(id: string) => Promise<ActionState<void>>",
|
||||||
"purpose": "标记消息已读(设置 readAt)",
|
"purpose": "标记消息已读(设置 readAt;P2-11 新增 trackEvent 埋点)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"schema.MessageIdSchema",
|
"schema.MessageIdSchema",
|
||||||
"data-access.markMessageAsRead",
|
"data-access.markMessageAsRead",
|
||||||
|
"trackEvent",
|
||||||
"revalidatePath"
|
"revalidatePath"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
@@ -9148,11 +9238,12 @@
|
|||||||
"name": "deleteMessageAction",
|
"name": "deleteMessageAction",
|
||||||
"permission": "MESSAGE_DELETE",
|
"permission": "MESSAGE_DELETE",
|
||||||
"signature": "(id: string) => Promise<ActionState<void>>",
|
"signature": "(id: string) => Promise<ActionState<void>>",
|
||||||
"purpose": "删除消息(仅发送者或接收者可删)",
|
"purpose": "删除消息(仅发送者或接收者可删;P2-11 新增 trackEvent 埋点)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"schema.MessageIdSchema",
|
"schema.MessageIdSchema",
|
||||||
"data-access.deleteMessage",
|
"data-access.deleteMessage",
|
||||||
|
"trackEvent",
|
||||||
"revalidatePath"
|
"revalidatePath"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
@@ -9162,14 +9253,14 @@
|
|||||||
{
|
{
|
||||||
"name": "getMessagesAction",
|
"name": "getMessagesAction",
|
||||||
"permission": "MESSAGE_READ",
|
"permission": "MESSAGE_READ",
|
||||||
"signature": "(params?: { type?, page?, pageSize? }) => Promise<ActionState<PaginatedResult<MessageListItem>>>",
|
"signature": "(params?: { type?, page?, pageSize?, keyword? }) => Promise<ActionState<PaginatedResult<MessageListItem>>>",
|
||||||
"purpose": "获取消息列表(收件箱/已发送,分页)",
|
"purpose": "获取消息列表(收件箱/已发送,分页,关键词搜索;客户端通过 useMessageSearch hook 调用)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"data-access.getMessages"
|
"data-access.getMessages"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"message-list.tsx"
|
"message-list.tsx (via useMessageSearch hook)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -9201,47 +9292,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "getNotificationsAction",
|
"name": "getUnreadMessageCountAction",
|
||||||
"permission": "MESSAGE_READ",
|
"permission": "MESSAGE_READ",
|
||||||
"signature": "(params?: { page?, pageSize? }) => Promise<ActionState<PaginatedResult<NotificationListItem>>>",
|
"signature": "() => Promise<ActionState<number>>",
|
||||||
"purpose": "获取当前用户通知列表(分页)",
|
"purpose": "获取当前用户未读私信计数(unread-message-badge 组件每 60 秒轮询)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"data-access.getNotifications"
|
"data-access.getUnreadMessageCount"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"notification-dropdown.tsx",
|
"unread-message-badge.tsx"
|
||||||
"notification-list.tsx"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markNotificationAsReadAction",
|
|
||||||
"permission": "MESSAGE_READ",
|
|
||||||
"signature": "(id: string) => Promise<ActionState<void>>",
|
|
||||||
"purpose": "标记单条通知已读",
|
|
||||||
"deps": [
|
|
||||||
"requirePermission",
|
|
||||||
"data-access.markNotificationAsRead",
|
|
||||||
"revalidatePath"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"notification-dropdown.tsx",
|
|
||||||
"notification-list.tsx"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markAllNotificationsAsReadAction",
|
|
||||||
"permission": "MESSAGE_READ",
|
|
||||||
"signature": "() => Promise<ActionState<void>>",
|
|
||||||
"purpose": "标记所有通知已读",
|
|
||||||
"deps": [
|
|
||||||
"requirePermission",
|
|
||||||
"data-access.markAllNotificationsAsRead",
|
|
||||||
"revalidatePath"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"notification-dropdown.tsx",
|
|
||||||
"notification-list.tsx"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -9251,7 +9311,7 @@
|
|||||||
"purpose": "获取当前用户的通知偏好设置(首次访问自动创建默认记录)",
|
"purpose": "获取当前用户的通知偏好设置(首次访问自动创建默认记录)",
|
||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"notification-preferences.getNotificationPreferences"
|
"notifications.preferences.getNotificationPreferences"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"settings/page.tsx",
|
"settings/page.tsx",
|
||||||
@@ -9266,7 +9326,7 @@
|
|||||||
"deps": [
|
"deps": [
|
||||||
"requirePermission",
|
"requirePermission",
|
||||||
"schema.UpdateNotificationPreferencesSchema",
|
"schema.UpdateNotificationPreferencesSchema",
|
||||||
"notification-preferences.upsertNotificationPreferences",
|
"notifications.preferences.upsertNotificationPreferences",
|
||||||
"revalidatePath"
|
"revalidatePath"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
@@ -9362,70 +9422,23 @@
|
|||||||
"shared.db.schema.messages"
|
"shared.db.schema.messages"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"待扩展"
|
"getUnreadMessageCountAction",
|
||||||
|
"unread-message-badge.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "getNotifications",
|
"name": "getMessagesPageData",
|
||||||
"signature": "(userId: string, params?: { page?, pageSize? }) => Promise<PaginatedResult<NotificationListItem>>",
|
"signature": "(userId: string) => Promise<{ messages: PaginatedResult<Message>, notifications: PaginatedResult<Notification> }>",
|
||||||
"file": "data-access.ts",
|
"file": "data-access.ts",
|
||||||
"purpose": "re-export shim(实际逻辑在 notifications/data-access.ts,P0-4 / P1-5 修复后迁移)",
|
"purpose": "P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表(通知通过动态 import notifications/data-access)",
|
||||||
"deps": [
|
"deps": [
|
||||||
|
"data-access.getMessages",
|
||||||
"notifications.data-access.getNotifications"
|
"notifications.data-access.getNotifications"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"getNotificationsAction",
|
|
||||||
"messages/page.tsx"
|
"messages/page.tsx"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "createNotification",
|
|
||||||
"signature": "(input: CreateNotificationInput) => Promise<string>",
|
|
||||||
"file": "data-access.ts",
|
|
||||||
"purpose": "re-export shim(实际逻辑在 notifications/data-access.ts,P0-4 / P1-5 修复后迁移)",
|
|
||||||
"deps": [
|
|
||||||
"notifications.data-access.createNotification"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"notifications.dispatcher (via in-app-channel)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markNotificationAsRead",
|
|
||||||
"signature": "(id: string, userId: string) => Promise<void>",
|
|
||||||
"file": "data-access.ts",
|
|
||||||
"purpose": "re-export shim(实际逻辑在 notifications/data-access.ts,P0-4 / P1-5 修复后迁移)",
|
|
||||||
"deps": [
|
|
||||||
"notifications.data-access.markNotificationAsRead"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"markNotificationAsReadAction"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "markAllNotificationsAsRead",
|
|
||||||
"signature": "(userId: string) => Promise<void>",
|
|
||||||
"file": "data-access.ts",
|
|
||||||
"purpose": "re-export shim(实际逻辑在 notifications/data-access.ts,P0-4 / P1-5 修复后迁移)",
|
|
||||||
"deps": [
|
|
||||||
"notifications.data-access.markAllNotificationsAsRead"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"markAllNotificationsAsReadAction"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "getUnreadNotificationCount",
|
|
||||||
"signature": "(userId: string) => Promise<number>",
|
|
||||||
"file": "data-access.ts",
|
|
||||||
"purpose": "re-export shim(实际逻辑在 notifications/data-access.ts,P0-4 / P1-5 修复后迁移)",
|
|
||||||
"deps": [
|
|
||||||
"notifications.data-access.getUnreadNotificationCount"
|
|
||||||
],
|
|
||||||
"usedBy": [
|
|
||||||
"待扩展"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "getRecipients",
|
"name": "getRecipients",
|
||||||
"signature": "(ctx: AuthContext) => Promise<RecipientOption[]>",
|
"signature": "(ctx: AuthContext) => Promise<RecipientOption[]>",
|
||||||
@@ -9629,21 +9642,26 @@
|
|||||||
"purpose": "写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式)"
|
"purpose": "写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "NotificationDropdown",
|
"name": "UnreadMessageBadge",
|
||||||
"file": "components/notification-dropdown.tsx",
|
"file": "components/unread-message-badge.tsx",
|
||||||
"purpose": "SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,滚动列表,标记已读,查看全部链接)"
|
"purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 60 秒轮询 getUnreadMessageCountAction)"
|
||||||
},
|
}
|
||||||
|
],
|
||||||
|
"hooks": [
|
||||||
{
|
{
|
||||||
"name": "NotificationList",
|
"name": "useMessageSearch",
|
||||||
"file": "components/notification-list.tsx",
|
"file": "hooks/use-message-search.ts",
|
||||||
"purpose": "通知完整列表(全部标记已读、单条标记已读、查看链接)"
|
"purpose": "P1-7 新增:消息搜索 hook(400ms 防抖 + 请求竞态取消,通过 requestIdRef 匹配最新请求)",
|
||||||
|
"usedBy": [
|
||||||
|
"message-list.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"path": "src/modules/notifications",
|
"path": "src/modules/notifications",
|
||||||
"description": "通知渠道集成层:基于用户通知偏好(notification_preferences)将通知分发到站内消息/SMS/微信公众号/邮件多渠道。所有渠道实现统一 NotificationChannelSender 接口,dispatcher 按偏好并行发送。支持 Mock 模式(开发环境无需外部服务)。",
|
"description": "通知渠道集成层:基于用户通知偏好(notification_preferences)将通知分发到站内消息/SMS/微信公众号/邮件多渠道。所有渠道实现统一 NotificationChannelSender 接口,dispatcher 按偏好并行发送。支持 Mock 模式(开发环境无需外部服务)。P1-4 修复后新增通知 UI 组件和通知 CRUD Server Action。",
|
||||||
"exports": {
|
"exports": {
|
||||||
"actions": [
|
"actions": [
|
||||||
{
|
{
|
||||||
@@ -9673,6 +9691,66 @@
|
|||||||
"usedBy": [
|
"usedBy": [
|
||||||
"待扩展"
|
"待扩展"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getNotificationsAction",
|
||||||
|
"permission": "MESSAGE_READ",
|
||||||
|
"signature": "(params?: { page?, pageSize?, unreadOnly? }) => Promise<ActionState<PaginatedResult<Notification>>>",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):获取当前用户通知列表(分页)",
|
||||||
|
"deps": [
|
||||||
|
"requirePermission",
|
||||||
|
"data-access.getNotifications"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"notification-dropdown.tsx",
|
||||||
|
"notification-list.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getUnreadNotificationCountAction",
|
||||||
|
"permission": "MESSAGE_READ",
|
||||||
|
"signature": "() => Promise<ActionState<number>>",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):获取当前用户未读通知计数",
|
||||||
|
"deps": [
|
||||||
|
"requirePermission",
|
||||||
|
"data-access.getUnreadNotificationCount"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"notification-dropdown.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markNotificationAsReadAction",
|
||||||
|
"permission": "MESSAGE_READ",
|
||||||
|
"signature": "(notificationId: string) => Promise<ActionState<string>>",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):标记单条通知已读;P2-11 新增 trackEvent 埋点",
|
||||||
|
"deps": [
|
||||||
|
"requirePermission",
|
||||||
|
"schema.NotificationIdSchema",
|
||||||
|
"data-access.markNotificationAsRead",
|
||||||
|
"trackEvent",
|
||||||
|
"revalidatePath"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"notification-dropdown.tsx",
|
||||||
|
"notification-list.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "markAllNotificationsAsReadAction",
|
||||||
|
"permission": "MESSAGE_READ",
|
||||||
|
"signature": "() => Promise<ActionState<string>>",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):标记所有通知已读;P2-11 新增 trackEvent 埋点",
|
||||||
|
"deps": [
|
||||||
|
"requirePermission",
|
||||||
|
"data-access.markAllNotificationsAsRead",
|
||||||
|
"trackEvent",
|
||||||
|
"revalidatePath"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"notification-dropdown.tsx",
|
||||||
|
"notification-list.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"dispatcher": [
|
"dispatcher": [
|
||||||
@@ -10029,6 +10107,18 @@
|
|||||||
"messaging (via re-export)"
|
"messaging (via re-export)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": "NotificationList",
|
||||||
|
"file": "components/notification-list.tsx",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NotificationDropdown",
|
||||||
|
"file": "components/notification-dropdown.tsx",
|
||||||
|
"purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接)"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -12517,6 +12607,8 @@
|
|||||||
"lib/node-summary.ts",
|
"lib/node-summary.ts",
|
||||||
"lib/rf-mappers.ts",
|
"lib/rf-mappers.ts",
|
||||||
"config/block-registry.tsx",
|
"config/block-registry.tsx",
|
||||||
|
"providers/lesson-plan-provider.tsx",
|
||||||
|
"services/default-data-service.ts",
|
||||||
"data-access.ts",
|
"data-access.ts",
|
||||||
"data-access-versions.ts",
|
"data-access-versions.ts",
|
||||||
"data-access-templates.ts",
|
"data-access-templates.ts",
|
||||||
@@ -12549,7 +12641,29 @@
|
|||||||
"components/blocks/text-study-block.tsx",
|
"components/blocks/text-study-block.tsx",
|
||||||
"components/blocks/exercise-block.tsx",
|
"components/blocks/exercise-block.tsx",
|
||||||
"components/blocks/reflection-block.tsx"
|
"components/blocks/reflection-block.tsx"
|
||||||
]
|
],
|
||||||
|
"i18n": {
|
||||||
|
"namespace": "lessonPreparation",
|
||||||
|
"status": "implemented",
|
||||||
|
"messageFiles": [
|
||||||
|
"shared/i18n/messages/zh-CN/lesson-preparation.json",
|
||||||
|
"shared/i18n/messages/en/lesson-preparation.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"auditFixes": {
|
||||||
|
"P0-1": "publish-service.ts 跨模块直查修复:改用 exams/classes data-access",
|
||||||
|
"P0-2": "i18n 接入:17 个组件改造为 useTranslations/getTranslations",
|
||||||
|
"P0-3": "buildScopeCondition 按 scope 类型精确过滤(class_taught/grade_managed/class_members/children)",
|
||||||
|
"P1-1": "as never 断言替换为类型守卫函数;BLOCK_TYPE_LABELS/LESSON_PLAN_STATUS_LABELS 改为 i18n 键",
|
||||||
|
"P1-2": "新增 LessonPlanErrorBoundary 错误边界",
|
||||||
|
"P1-3": "新增 4 个 Skeleton 骨架屏组件",
|
||||||
|
"P1-4": "alert/confirm/window.location.reload 替换为 toast/AlertDialog/router.refresh",
|
||||||
|
"P1-5": "新增 LessonPlanProvider + Context 注入数据服务,支持多实例",
|
||||||
|
"P1-7": "新增 4 个角色配置(TEACHER/ADMIN/STUDENT/PARENT)+ ROLE_CONFIGS 注册表",
|
||||||
|
"P1-8": "新增 BLOCK_REGISTRY 注册表,node-edit-panel 配置驱动渲染",
|
||||||
|
"P2-1": "5 个组件添加 role=dialog/aria-modal/aria-label",
|
||||||
|
"P2-4": "预留 LessonPlanTracker 接口 + noopTracker 默认实现"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbTables": {
|
"dbTables": {
|
||||||
|
|||||||
121
docs/architecture/audit/dashboard-audit-report-v2.md
Normal file
121
docs/architecture/audit/dashboard-audit-report-v2.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# 仪表盘模块审计报告 v2
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22(第二轮)
|
||||||
|
> 审查范围:基于 v1 重构后代码(commit `868ac5f` + `21c1e7a`)的再次分析
|
||||||
|
> 前置报告:`docs/architecture/audit/dashboard-audit-report.md`(v1)
|
||||||
|
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.12、`docs/architecture/005_architecture_data.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、v1 重构成果回顾
|
||||||
|
|
||||||
|
v1 报告识别的 P0/P1/P2 项目已完成的部分:
|
||||||
|
|
||||||
|
| # | 项目 | 状态 | 证据 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| P0-1 | 权限校验 | ✅ 已完成 | [actions.ts](file:///e:/Desktop/CICD/src/modules/dashboard/actions.ts) 4 个 Server Action 均调用 `requirePermission()` |
|
||||||
|
| P0-2 | 根重定向角色硬编码 | ✅ 已完成 | [dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/dashboard/page.tsx) 改用 `resolvePermissions()` |
|
||||||
|
| P0-3 | i18n 零覆盖 | ⚠️ 部分完成 | 仅容器组件接入 i18n,**10 个子组件仍英文硬编码** |
|
||||||
|
| P0-4 | 页面层越权编排 | ✅ 已完成 | teacher/student/parent 编排下沉至 actions.ts |
|
||||||
|
| P1-1 | 业务逻辑耦合 UI | ✅ 已完成 | [lib/dashboard-utils.ts](file:///e:/Desktop/CICD/src/modules/dashboard/lib/dashboard-utils.ts) 抽取 6 个纯函数 |
|
||||||
|
| P1-3 | 仅路由级错误边界 | ✅ 已完成 | [dashboard-section.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/dashboard-section.tsx) 分区 Error Boundary + Suspense |
|
||||||
|
| P2-2 | a11y 不足 | ❌ 未完成 | 仍缺语义化标签、表格 caption |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、v2 新发现问题
|
||||||
|
|
||||||
|
### 2.1 i18n 覆盖严重不完整(P0 — v1 遗漏)
|
||||||
|
|
||||||
|
v1 仅对容器组件(`admin-dashboard.tsx`、`teacher-dashboard-view.tsx`、`teacher-dashboard-header.tsx`、`teacher-stats.tsx`、`teacher-todo-card.tsx`、`student-stats-grid.tsx`、`student-dashboard-header.tsx`、`parent-dashboard.tsx`、`user-growth-chart.tsx`)接入 i18n,**10 个子组件仍全英文硬编码**:
|
||||||
|
|
||||||
|
| # | 文件 | 硬编码示例 | 违反规则 |
|
||||||
|
|---|------|-----------|----------|
|
||||||
|
| 1 | [teacher-quick-actions.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx) L12-24 | `"Create Assignment"` / `"Grade"` / `"My Classes"` | "所有用户可见文本必须适配 i18n" |
|
||||||
|
| 2 | [teacher-classes-card.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-classes-card.tsx) L15-27 | `"My Classes"` / `"View all"` / `"No classes yet"` / `"Create a class to start managing students and schedules."` / `"Create class"` / `"Homeroom"` / `"Room"` | 同上 |
|
||||||
|
| 3 | [teacher-homework-card.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-homework-card.tsx) L17-87 | `"Homework"` / `"Create new assignment"` / `"No assignments"` / `"Create an assignment to get started."` / `"Create"` / `"No due date"` / `"View all assignments"` | 同上 |
|
||||||
|
| 4 | [teacher-schedule.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx) L41-141 | `"Today's Schedule"` / `"No Classes Today"` / `"No timetable entries."` / `"View schedule"` / `"LIVE"` / `"Scroll for more"` / `"No more classes today"` | 同上 |
|
||||||
|
| 5 | [recent-submissions.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx) L22-105 | `"Recent Submissions"` / `"No New Submissions"` / `"All caught up!..."` / `"View All"` / `"View submissions"` / `"Student"` / `"Assignment"` / `"Submitted"` / `"Action"` / `"Late"` / `"Grade"` | 同上 |
|
||||||
|
| 6 | [teacher-grade-trends.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx) L25-69 | `"Class Performance"` / `"Average scores for the last X assignments"` / `"No data available"` / `"Publish assignments to see class performance trends."` / `"Average Score (%)"` / `"X/Y submitted"` | 同上 |
|
||||||
|
| 7 | [student-grades-card.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx) L30-101 | `"Recent Grades"` / `"No graded work yet"` / `"Finish and submit assignments to see your score trend."` / `"View all"` / `"Score (%)"` / `"Latest:"` / `"Points:"` / `"Assignment"` / `"Score"` / `"When"` | 同上 |
|
||||||
|
| 8 | [student-today-schedule-card.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx) L52-83 | `"Today's Schedule"` / `"View all"` / `"No classes today"` / `"Your timetable is clear for today."` / `"In Progress"` / `"Up Next"` | 同上 |
|
||||||
|
| 9 | [student-upcoming-assignments-card.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx) L17-22,49-72 | `"Review"` / `"View"` / `"Continue"` / `"Start"` / `"Upcoming Assignments"` / `"View all"` / `"No assignments"` / `"You have no assigned homework right now."` / `"Title"` / `"Status"` / `"Due"` / `"Score"` / `"Action"` / `"Late"` | 同上 |
|
||||||
|
| 10 | [admin-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx) L212 | `{u.role ?? "unknown"}` 硬编码 `"unknown"` | 同上 |
|
||||||
|
|
||||||
|
**后果**:中文用户看到大量英文,体验割裂;无法切换语言;维护时需逐文件改字符串。
|
||||||
|
|
||||||
|
### 2.2 四角色仍零共享抽象(P1 — v1 未处理)
|
||||||
|
|
||||||
|
| 维度 | 现状 | 期望 |
|
||||||
|
|------|------|------|
|
||||||
|
| 问候语头部 | `TeacherDashboardHeader` 与 `StudentDashboardHeader` 代码 90% 重复(仅 props 名不同) | 抽象为 `DashboardGreetingHeader` |
|
||||||
|
| 快捷操作 | admin 的 `QuickActionCard`(内联)、parent 的 `QUICK_ENTRIES`(内联)、teacher 的 `TeacherQuickActions` — 三套独立实现 | 抽象为 `DashboardQuickActions` |
|
||||||
|
| 仪表盘布局容器 | admin/teacher/student 各写一套 `<div className="space-y-*">` | 抽象为 `DashboardLayout` |
|
||||||
|
|
||||||
|
**违反规则**:"最大化复用:识别四个角色共用的 UI 块和业务逻辑块,抽象为泛型组件和 hooks"。
|
||||||
|
|
||||||
|
### 2.3 无单测(P2 — v1 未处理)
|
||||||
|
|
||||||
|
`lib/dashboard-utils.ts` 抽取了 6 个纯函数但**无任何单测**:
|
||||||
|
|
||||||
|
| 函数 | 测试覆盖 | 风险 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `toWeekday` | ❌ 无 | 周日映射错误未被发现 |
|
||||||
|
| `countStudentAssignments` | ❌ 无 | 边界条件(无截止日期/已批改)未验证 |
|
||||||
|
| `sortUpcomingAssignments` | ❌ 无 | 排序稳定性未验证 |
|
||||||
|
| `filterTodaySchedule` | ❌ 无 | 空课表/排序未验证 |
|
||||||
|
| `computeTeacherMetrics` | ❌ 无 | 提交率分母为零等边界未验证 |
|
||||||
|
| `getGreetingKey` | ❌ 无 | 时段边界(12:00/18:00)未验证 |
|
||||||
|
|
||||||
|
**违反规则**:"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock" + "可测试性"。
|
||||||
|
|
||||||
|
### 2.4 a11y 不足(P2 — v1 未处理)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `admin-dashboard.tsx` 表格 | 无 `<caption>` | "语义化标签、ARIA 属性、键盘导航" |
|
||||||
|
| `recent-submissions.tsx` 表格 | 无 `<caption>` | 同上 |
|
||||||
|
| `student-upcoming-assignments-card.tsx` 表格 | 无 `<caption>` | 同上 |
|
||||||
|
| `teacher-dashboard-view.tsx` 布局 | 无 `<section>` / `<aside>` 语义化标签 | 同上 |
|
||||||
|
| `student-dashboard-view.tsx` 布局 | 同上 | 同上 |
|
||||||
|
| `teacher-schedule.tsx` 时间线 | 无 `aria-label` 描述当前/过去/未来状态 | 同上 |
|
||||||
|
|
||||||
|
### 2.5 流式渲染未实现(P1 — v1 未处理)
|
||||||
|
|
||||||
|
所有 `page.tsx` 仍 `export const dynamic = "force-dynamic"` + `Promise.all` 等全部数据就绪后才渲染。虽然 `DashboardSection` 内部有 Suspense,但 page 层已无 Suspense 边界,无法流式渲染首屏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、改进优先级(v2)
|
||||||
|
|
||||||
|
### P0(紧急 — v1 遗漏的 i18n)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| v2-P0-1 | 10 个组件英文硬编码 | 全部接入 `useTranslations` / `getTranslations`;补充翻译键 |
|
||||||
|
|
||||||
|
### P1(较严重 — 共享抽象 + 单测)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| v2-P1-1 | 问候语头部重复 | 抽象 `DashboardGreetingHeader` 组件 |
|
||||||
|
| v2-P1-2 | 纯函数无单测 | 为 `lib/dashboard-utils.ts` 6 个函数添加单测 |
|
||||||
|
|
||||||
|
### P2(优化 — a11y + 流式)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| v2-P2-1 | 表格无 caption / 布局无语义化标签 | 补充 `<caption>` / `<section>` / `aria-label` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、架构图同步说明
|
||||||
|
|
||||||
|
v2 修改完成后需同步更新:
|
||||||
|
|
||||||
|
### 4.1 `004_architecture_impact_map.md`
|
||||||
|
- §2.12 dashboard 章节:补充新增共享组件(`DashboardGreetingHeader`)、单测文件(`lib/dashboard-utils.test.ts`)
|
||||||
|
|
||||||
|
### 4.2 `005_architecture_data.json`
|
||||||
|
- `modules.dashboard.exports.components`:新增 `DashboardGreetingHeader`
|
||||||
|
- `modules.dashboard.exports.lib`:补充单测覆盖说明
|
||||||
@@ -195,6 +195,7 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
|
|||||||
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
|
<caption className="sr-only">{t("sections.recentUsers")}</caption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("table.name")}</TableHead>
|
<TableHead>{t("table.name")}</TableHead>
|
||||||
@@ -209,7 +210,7 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
|
|||||||
<TableCell className="font-medium">{u.name || "-"}</TableCell>
|
<TableCell className="font-medium">{u.name || "-"}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
|
<Badge variant="secondary">{u.role ?? t("badge.unknown")}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
|
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { formatLongDate } from "@/shared/lib/utils"
|
||||||
|
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘问候语头部(共享组件)
|
||||||
|
*
|
||||||
|
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
|
||||||
|
* 通过 `actions` slot 注入角色专属快捷操作。
|
||||||
|
*/
|
||||||
|
export function DashboardGreetingHeader({
|
||||||
|
userName,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
userName: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
|
const today = formatLongDate(new Date())
|
||||||
|
const greetingKey = getGreetingKey(new Date())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
|
{t(`greeting.${greetingKey}`)},{userName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||||
import { formatLongDate } from "@/shared/lib/utils"
|
|
||||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
|
||||||
|
|
||||||
interface StudentDashboardHeaderProps {
|
interface StudentDashboardHeaderProps {
|
||||||
studentName: string
|
studentName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
||||||
const t = useTranslations("dashboard")
|
|
||||||
const today = formatLongDate(new Date())
|
|
||||||
const greetingKey = getGreetingKey(new Date())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<DashboardGreetingHeader userName={studentName} />
|
||||||
<div>
|
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
|
||||||
{t(`greeting.${greetingKey}`)},{studentName}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
import type { StudentDashboardProps } from "@/modules/dashboard/types"
|
||||||
|
|
||||||
import { DashboardSection } from "../dashboard-section"
|
import { DashboardSection } from "../dashboard-section"
|
||||||
@@ -17,9 +19,12 @@ export async function StudentDashboard({
|
|||||||
upcomingAssignments,
|
upcomingAssignments,
|
||||||
grades,
|
grades,
|
||||||
}: StudentDashboardProps) {
|
}: StudentDashboardProps) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<StudentDashboardHeader studentName={studentName} />
|
<header>
|
||||||
|
<StudentDashboardHeader studentName={studentName} />
|
||||||
|
</header>
|
||||||
|
|
||||||
<DashboardSection variant="stats">
|
<DashboardSection variant="stats">
|
||||||
<StudentStatsGrid
|
<StudentStatsGrid
|
||||||
@@ -32,19 +37,22 @@ export async function StudentDashboard({
|
|||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<section
|
||||||
|
aria-label={t("sections.upcomingAssignments")}
|
||||||
|
className="lg:col-span-2 space-y-6"
|
||||||
|
>
|
||||||
<DashboardSection variant="list">
|
<DashboardSection variant="list">
|
||||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
<DashboardSection variant="card">
|
<DashboardSection variant="card">
|
||||||
<StudentGradesCard grades={grades} />
|
<StudentGradesCard grades={grades} />
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
</div>
|
</section>
|
||||||
<div className="space-y-6">
|
<aside aria-label={t("sections.todaySchedule")} className="space-y-6">
|
||||||
<DashboardSection variant="card">
|
<DashboardSection variant="card">
|
||||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
@@ -10,6 +12,7 @@ import { formatDate } from "@/shared/lib/utils"
|
|||||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
const hasGradeTrend = grades.trend.length > 0
|
const hasGradeTrend = grades.trend.length > 0
|
||||||
const hasRecentGrades = grades.recent.length > 0
|
const hasRecentGrades = grades.recent.length > 0
|
||||||
|
|
||||||
@@ -26,13 +29,20 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCardShell
|
<ChartCardShell
|
||||||
title="Recent Grades"
|
title={t("sections.recentGrades")}
|
||||||
icon={BarChart3}
|
icon={BarChart3}
|
||||||
iconClassName="text-muted-foreground"
|
iconClassName="text-muted-foreground"
|
||||||
isEmpty={!hasGradeTrend}
|
isEmpty={!hasGradeTrend}
|
||||||
emptyTitle="No graded work yet"
|
emptyTitle={t("empty.noGradedWork")}
|
||||||
emptyDescription="Finish and submit assignments to see your score trend."
|
emptyDescription={t("empty.noGradedWorkDesc")}
|
||||||
emptyClassName="h-72"
|
emptyClassName="h-72"
|
||||||
|
action={
|
||||||
|
hasGradeTrend ? (
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link href="/student/grades">{t("quickActions.viewAll")}</Link>
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border bg-card p-4">
|
<div className="rounded-md border bg-card p-4">
|
||||||
@@ -41,7 +51,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
dataKey: "score",
|
dataKey: "score",
|
||||||
name: "Score (%)",
|
name: t("chart.scorePercent"),
|
||||||
color: "hsl(var(--primary))",
|
color: "hsl(var(--primary))",
|
||||||
dotRadius: 4,
|
dotRadius: 4,
|
||||||
activeDotRadius: 6,
|
activeDotRadius: 6,
|
||||||
@@ -56,13 +66,13 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
{latestGrade ? (
|
{latestGrade ? (
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
Latest:{" "}
|
{t("chart.latest")}:{" "}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
{Math.round(latestGrade.percentage)}%
|
{Math.round(latestGrade.percentage)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Points:{" "}
|
{t("chart.points")}:{" "}
|
||||||
<span className="font-medium text-foreground tabular-nums">
|
<span className="font-medium text-foreground tabular-nums">
|
||||||
{latestGrade.score}/{latestGrade.maxScore}
|
{latestGrade.score}/{latestGrade.maxScore}
|
||||||
</span>
|
</span>
|
||||||
@@ -76,9 +86,9 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.assignment")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.score")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.when")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { CalendarDays, CalendarX } from "lucide-react"
|
import { CalendarDays, CalendarX } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
@@ -12,18 +13,15 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
|||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
|
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse "HH:MM" time string into minutes since midnight for comparison.
|
|
||||||
*/
|
|
||||||
const timeToMinutes = (t: string): number => {
|
const timeToMinutes = (t: string): number => {
|
||||||
const [h, m] = t.split(":").map(Number)
|
const [h, m] = t.split(":").map(Number)
|
||||||
return (h ?? 0) * 60 + (m ?? 0)
|
return (h ?? 0) * 60 + (m ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
|
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
const hasSchedule = items.length > 0
|
const hasSchedule = items.length > 0
|
||||||
|
|
||||||
// Compute current/next class status based on client time
|
|
||||||
const { currentId, nextId } = useMemo(() => {
|
const { currentId, nextId } = useMemo(() => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const nowMin = now.getHours() * 60 + now.getMinutes()
|
const nowMin = now.getHours() * 60 + now.getMinutes()
|
||||||
@@ -49,18 +47,18 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||||
Today's Schedule
|
{t("sections.todaySchedule")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/student/schedule">View all</Link>
|
<Link href="/student/schedule">{t("quickActions.viewAll")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!hasSchedule ? (
|
{!hasSchedule ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CalendarX}
|
icon={CalendarX}
|
||||||
title="No classes today"
|
title={t("empty.noClassesToday")}
|
||||||
description="Your timetable is clear for today."
|
description={t("empty.noClassesTodayDesc")}
|
||||||
className="border-none h-72"
|
className="border-none h-72"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -74,14 +72,14 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
|
|||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
return (
|
return (
|
||||||
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-500">
|
<Badge className="shrink-0 bg-emerald-500 text-white hover:bg-emerald-500">
|
||||||
In Progress
|
{t("badge.inProgress")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (isNext) {
|
if (isNext) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="shrink-0 border-primary text-primary">
|
<Badge variant="outline" className="shrink-0 border-primary text-primary">
|
||||||
Up Next
|
{t("badge.upNext")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PenTool } from "lucide-react"
|
import { PenTool } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -14,11 +15,11 @@ import {
|
|||||||
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
||||||
} from "@/modules/homework/types"
|
} from "@/modules/homework/types"
|
||||||
|
|
||||||
const getActionLabel = (status: string) => {
|
const getActionLabelKey = (status: string): "action.review" | "action.view" | "action.continue" | "action.start" => {
|
||||||
if (status === "graded") return "Review"
|
if (status === "graded") return "action.review"
|
||||||
if (status === "submitted") return "View"
|
if (status === "submitted") return "action.view"
|
||||||
if (status === "in_progress") return "Continue"
|
if (status === "in_progress") return "action.continue"
|
||||||
return "Start"
|
return "action.start"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||||
@@ -33,12 +34,13 @@ const getDueUrgency = (dueAt: string | null) => {
|
|||||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||||
|
|
||||||
if (diffHours < 0) return "overdue"
|
if (diffHours < 0) return "overdue"
|
||||||
if (diffHours < 48) return "urgent" // 2 days
|
if (diffHours < 48) return "urgent"
|
||||||
if (diffHours < 120) return "warning" // 5 days
|
if (diffHours < 120) return "warning"
|
||||||
return "normal"
|
return "normal"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
const hasAssignments = upcomingAssignments.length > 0
|
const hasAssignments = upcomingAssignments.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,18 +48,18 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||||
Upcoming Assignments
|
{t("sections.upcomingAssignments")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/student/learning/assignments">View all</Link>
|
<Link href="/student/learning/assignments">{t("quickActions.viewAll")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!hasAssignments ? (
|
{!hasAssignments ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
title="No assignments"
|
title={t("empty.noAssignmentsStudent")}
|
||||||
description="You have no assigned homework right now."
|
description={t("empty.noAssignmentsStudentDesc")}
|
||||||
className="border-none h-72"
|
className="border-none h-72"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -65,18 +67,18 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Title</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.title")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.status")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.due")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground">{t("table.score")}</TableHead>
|
||||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">Action</TableHead>
|
<TableHead className="text-xs font-medium uppercase text-muted-foreground text-right">{t("table.action")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{upcomingAssignments.map((a) => {
|
{upcomingAssignments.map((a) => {
|
||||||
const urgency = getDueUrgency(a.dueAt)
|
const urgency = getDueUrgency(a.dueAt)
|
||||||
const isGraded = a.progressStatus === "graded"
|
const isGraded = a.progressStatus === "graded"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={a.id} className="h-12">
|
<TableRow key={a.id} className="h-12">
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
@@ -85,7 +87,7 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
{a.title}
|
{a.title}
|
||||||
</Link>
|
</Link>
|
||||||
{!isGraded && urgency === "overdue" && (
|
{!isGraded && urgency === "overdue" && (
|
||||||
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">Late</Badge>
|
<Badge variant="destructive" className="h-5 px-1.5 text-[10px] uppercase">{t("badge.late")}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -107,7 +109,7 @@ export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomi
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)} className="h-7 text-xs">
|
||||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||||
{getActionLabel(a.progressStatus)}
|
{t(getActionLabelKey(a.progressStatus))}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
import { Inbox, ArrowRight } from "lucide-react";
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { Inbox, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -13,54 +14,57 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table";
|
} from "@/shared/components/ui/table"
|
||||||
import { formatDate } from "@/shared/lib/utils";
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { HomeworkSubmissionListItem } from "@/modules/homework/types";
|
import type { HomeworkSubmissionListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function RecentSubmissions({
|
interface RecentSubmissionsProps {
|
||||||
submissions,
|
submissions: HomeworkSubmissionListItem[]
|
||||||
title = "Recent Submissions",
|
title?: string
|
||||||
emptyTitle = "No New Submissions",
|
emptyTitle?: string
|
||||||
emptyDescription = "All caught up! There are no new submissions to review."
|
|
||||||
}: {
|
|
||||||
submissions: HomeworkSubmissionListItem[],
|
|
||||||
title?: string,
|
|
||||||
emptyTitle?: string,
|
|
||||||
emptyDescription?: string
|
emptyDescription?: string
|
||||||
}) {
|
}
|
||||||
const hasSubmissions = submissions.length > 0;
|
|
||||||
|
export async function RecentSubmissions({
|
||||||
|
submissions,
|
||||||
|
title,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
}: RecentSubmissionsProps) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
const hasSubmissions = submissions.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col">
|
<Card className="h-full flex flex-col">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
<CardTitle className="flex items-center gap-2 text-lg">
|
||||||
<Inbox className="h-5 w-5 text-primary" />
|
<Inbox className="h-5 w-5 text-primary" />
|
||||||
{title}
|
{title ?? t("sections.recentSubmissions")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
|
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-primary" asChild>
|
||||||
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
|
<Link href="/teacher/homework/submissions" className="flex items-center gap-1">
|
||||||
View All <ArrowRight className="h-3 w-3" />
|
{t("quickActions.viewAllSubmissions")} <ArrowRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1">
|
||||||
{!hasSubmissions ? (
|
{!hasSubmissions ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
title={emptyTitle}
|
title={emptyTitle ?? t("empty.noNewSubmissions")}
|
||||||
description={emptyDescription}
|
description={emptyDescription ?? t("empty.noNewSubmissionsDesc")}
|
||||||
action={{ label: "View submissions", href: "/teacher/homework/submissions" }}
|
action={{ label: t("quickActions.viewAllSubmissions"), href: "/teacher/homework/submissions" }}
|
||||||
className="border-none h-full min-h-[200px]"
|
className="border-none h-full min-h-[200px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
<TableHead className="w-[200px]">Student</TableHead>
|
<TableHead className="w-[200px]">{t("table.student")}</TableHead>
|
||||||
<TableHead>Assignment</TableHead>
|
<TableHead>{t("table.assignment")}</TableHead>
|
||||||
<TableHead className="w-[140px]">Submitted</TableHead>
|
<TableHead className="w-[140px]">{t("table.submitted")}</TableHead>
|
||||||
<TableHead className="w-[100px] text-right">Action</TableHead>
|
<TableHead className="w-[100px] text-right">{t("table.action")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -78,7 +82,7 @@ export function RecentSubmissions({
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/teacher/homework/submissions/${item.id}`}
|
href={`/teacher/homework/submissions/${item.id}`}
|
||||||
className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
|
className="font-medium hover:text-primary hover:underline transition-colors block truncate max-w-[240px]"
|
||||||
title={item.assignmentTitle}
|
title={item.assignmentTitle}
|
||||||
@@ -93,7 +97,7 @@ export function RecentSubmissions({
|
|||||||
</span>
|
</span>
|
||||||
{item.isLate && (
|
{item.isLate && (
|
||||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||||
Late
|
{t("badge.late")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +105,7 @@ export function RecentSubmissions({
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button size="sm" variant="secondary" className="h-8 px-3" asChild>
|
<Button size="sm" variant="secondary" className="h-8 px-3" asChild>
|
||||||
<Link href={`/teacher/homework/submissions/${item.id}`}>
|
<Link href={`/teacher/homework/submissions/${item.id}`}>
|
||||||
Grade
|
{t("action.grade")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -113,5 +117,5 @@ export function RecentSubmissions({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Users } from "lucide-react"
|
import { Users } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import type { TeacherClass } from "@/modules/classes/types"
|
import type { TeacherClass } from "@/modules/classes/types"
|
||||||
|
|
||||||
export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
export async function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
My Classes
|
{t("sections.myClasses")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/teacher/classes/my">View all</Link>
|
<Link href="/teacher/classes/my">{t("quickActions.viewAll")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid gap-3">
|
||||||
{classes.length === 0 ? (
|
{classes.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Users}
|
icon={Users}
|
||||||
title="No classes yet"
|
title={t("empty.noClassesYet")}
|
||||||
description="Create a class to start managing students and schedules."
|
description={t("empty.noClassesDesc")}
|
||||||
action={{ label: "Create class", href: "/teacher/classes/my" }}
|
action={{ label: t("quickActions.createClass"), href: "/teacher/classes/my" }}
|
||||||
className="border-none h-72"
|
className="border-none h-72"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -44,13 +47,13 @@ export function TeacherClassesCard({ classes }: { classes: TeacherClass[] }) {
|
|||||||
{c.homeroom && (
|
{c.homeroom && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Homeroom: {c.homeroom}</span>
|
<span>{t("schedule.homeroom")}: {c.homeroom}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{c.room && (
|
{c.room && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>Room {c.room}</span>
|
<span>{t("schedule.room")} {c.room}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||||
import { formatLongDate } from "@/shared/lib/utils"
|
|
||||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
|
||||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||||
|
|
||||||
interface TeacherDashboardHeaderProps {
|
interface TeacherDashboardHeaderProps {
|
||||||
@@ -10,19 +8,10 @@ interface TeacherDashboardHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||||
const t = useTranslations("dashboard")
|
|
||||||
const today = formatLongDate(new Date())
|
|
||||||
const greetingKey = getGreetingKey(new Date())
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<DashboardGreetingHeader
|
||||||
<div>
|
userName={teacherName}
|
||||||
<h2 className="text-2xl font-bold tracking-tight">
|
actions={<TeacherQuickActions />}
|
||||||
{t(`greeting.${greetingKey}`)},{teacherName}
|
/>
|
||||||
</h2>
|
|
||||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
|
||||||
</div>
|
|
||||||
<TeacherQuickActions />
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-6 p-8">
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
<header>
|
||||||
|
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||||
|
</header>
|
||||||
|
|
||||||
<DashboardSection variant="stats">
|
<DashboardSection variant="stats">
|
||||||
<TeacherStats
|
<TeacherStats
|
||||||
@@ -57,8 +59,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
|||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
|
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 lg:col-span-8">
|
||||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<DashboardSection variant="card">
|
<DashboardSection variant="card">
|
||||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||||
@@ -78,9 +79,9 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
|||||||
emptyDescription={t("empty.allGradedDesc")}
|
emptyDescription={t("empty.allGradedDesc")}
|
||||||
/>
|
/>
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 lg:col-span-4">
|
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 lg:col-span-4">
|
||||||
<div className="hidden lg:block">
|
<div className="hidden lg:block">
|
||||||
<DashboardSection variant="card">
|
<DashboardSection variant="card">
|
||||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||||
@@ -92,7 +93,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
|
|||||||
<DashboardSection variant="list">
|
<DashboardSection variant="list">
|
||||||
<TeacherClassesCard classes={data.classes} />
|
<TeacherClassesCard classes={data.classes} />
|
||||||
</DashboardSection>
|
</DashboardSection>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { TrendingUp } from "lucide-react"
|
import { TrendingUp } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||||
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
const hasTrends = trends.length > 0
|
const hasTrends = trends.length > 0
|
||||||
|
|
||||||
const chartData = trends.map((item) => {
|
const chartData = trends.map((item) => {
|
||||||
@@ -22,14 +24,14 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCardShell
|
<ChartCardShell
|
||||||
title="Class Performance"
|
title={t("sections.classPerformance")}
|
||||||
description={`Average scores for the last ${trends.length} assignments`}
|
description={t("chart.classPerformanceDesc", { count: trends.length })}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
iconClassName="text-primary"
|
iconClassName="text-primary"
|
||||||
titleClassName="text-base font-medium"
|
titleClassName="text-base font-medium"
|
||||||
isEmpty={!hasTrends}
|
isEmpty={!hasTrends}
|
||||||
emptyTitle="No data available"
|
emptyTitle={t("empty.noData")}
|
||||||
emptyDescription="Publish assignments to see class performance trends."
|
emptyDescription={t("empty.noDataDesc")}
|
||||||
emptyClassName="h-[200px] p-0"
|
emptyClassName="h-[200px] p-0"
|
||||||
className="col-span-1"
|
className="col-span-1"
|
||||||
>
|
>
|
||||||
@@ -39,7 +41,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
|||||||
series={[
|
series={[
|
||||||
{
|
{
|
||||||
dataKey: "score",
|
dataKey: "score",
|
||||||
name: "Average Score (%)",
|
name: t("chart.averageScorePercent"),
|
||||||
color: "hsl(var(--primary))",
|
color: "hsl(var(--primary))",
|
||||||
dotRadius: 4,
|
dotRadius: 4,
|
||||||
activeDotRadius: 6,
|
activeDotRadius: 6,
|
||||||
@@ -65,7 +67,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
|||||||
<span className="text-xl font-bold tabular-nums">{item.score}%</span>
|
<span className="text-xl font-bold tabular-nums">{item.score}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{item.submissionCount}/{item.totalStudents} submitted
|
{t("chart.submittedCount", { submitted: item.submissionCount, total: item.totalStudents })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PenTool, Calendar, Plus } from "lucide-react"
|
import { PenTool, Calendar, Plus } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -8,16 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
|||||||
import { cn, formatDate } from "@/shared/lib/utils"
|
import { cn, formatDate } from "@/shared/lib/utils"
|
||||||
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
import type { HomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
export async function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssignmentListItem[] }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
<CardTitle className="text-base flex items-center gap-2">
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
<PenTool className="h-4 w-4 text-muted-foreground" />
|
<PenTool className="h-4 w-4 text-muted-foreground" />
|
||||||
Homework
|
{t("sections.homework")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button asChild size="icon" variant="ghost" className="h-8 w-8">
|
<Button asChild size="icon" variant="ghost" className="h-8 w-8" title={t("quickActions.createNewAssignment")}>
|
||||||
<Link href="/teacher/homework/assignments/create" title="Create new assignment">
|
<Link href="/teacher/homework/assignments/create">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -26,9 +29,9 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
{assignments.length === 0 ? (
|
{assignments.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
title="No assignments"
|
title={t("empty.noAssignments")}
|
||||||
description="Create an assignment to get started."
|
description={t("empty.noAssignmentsDesc")}
|
||||||
action={{ label: "Create", href: "/teacher/homework/assignments/create" }}
|
action={{ label: t("quickActions.create"), href: "/teacher/homework/assignments/create" }}
|
||||||
className="border-none h-48"
|
className="border-none h-48"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -36,7 +39,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
{assignments.slice(0, 6).map((a) => {
|
{assignments.slice(0, 6).map((a) => {
|
||||||
const isPublished = a.status === "published"
|
const isPublished = a.status === "published"
|
||||||
const isDraft = a.status === "draft"
|
const isDraft = a.status === "draft"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@@ -47,7 +50,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"h-2 w-2 rounded-full",
|
"h-2 w-2 rounded-full",
|
||||||
isPublished ? "bg-emerald-500" :
|
isPublished ? "bg-emerald-500" :
|
||||||
isDraft ? "bg-amber-400" : "bg-muted-foreground"
|
isDraft ? "bg-amber-400" : "bg-muted-foreground"
|
||||||
)} />
|
)} />
|
||||||
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
|
<div className="font-medium truncate text-sm group-hover:text-primary transition-colors">
|
||||||
@@ -58,7 +61,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
{a.sourceExamTitle}
|
{a.sourceExamTitle}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
{a.dueAt ? (
|
{a.dueAt ? (
|
||||||
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center text-xs text-muted-foreground tabular-nums">
|
||||||
@@ -66,10 +69,10 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
{formatDate(a.dueAt)}
|
{formatDate(a.dueAt)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-muted-foreground italic">No due date</span>
|
<span className="text-[10px] text-muted-foreground italic">{t("schedule.noDueDate")}</span>
|
||||||
)}
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
|
"text-[10px] h-4 px-1.5 capitalize font-normal border-transparent bg-muted/50",
|
||||||
isPublished && "text-emerald-600 bg-emerald-500/10",
|
isPublished && "text-emerald-600 bg-emerald-500/10",
|
||||||
@@ -84,7 +87,7 @@ export function TeacherHomeworkCard({ assignments }: { assignments: HomeworkAssi
|
|||||||
})}
|
})}
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
|
<Button asChild variant="link" size="sm" className="w-full text-muted-foreground h-auto py-1 text-xs">
|
||||||
<Link href="/teacher/homework/assignments">View all assignments</Link>
|
<Link href="/teacher/homework/assignments">{t("quickActions.viewAllAssignments")}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,29 +1,34 @@
|
|||||||
import Link from "next/link";
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import Link from "next/link"
|
||||||
import { PlusCircle, CheckSquare, Users } from "lucide-react";
|
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
export function TeacherQuickActions() {
|
export function TeacherQuickActions() {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm">
|
||||||
<Link href="/teacher/homework/assignments/create">
|
<Link href="/teacher/homework/assignments/create">
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
Create Assignment
|
{t("quickActions.createAssignment")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/teacher/homework/submissions">
|
<Link href="/teacher/homework/submissions">
|
||||||
<CheckSquare className="mr-2 h-4 w-4" />
|
<CheckSquare className="mr-2 h-4 w-4" />
|
||||||
Grade
|
{t("quickActions.grade")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<Link href="/teacher/classes/my">
|
<Link href="/teacher/classes/my">
|
||||||
<Users className="mr-2 h-4 w-4" />
|
<Users className="mr-2 h-4 w-4" />
|
||||||
My Classes
|
{t("quickActions.myClasses")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +1,147 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { CalendarDays, CalendarX, MapPin } from "lucide-react";
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state";
|
import { CalendarDays, CalendarX, MapPin } from "lucide-react"
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||||
|
|
||||||
type TeacherTodayScheduleItem = {
|
type TeacherTodayScheduleItem = {
|
||||||
id: string;
|
id: string
|
||||||
classId: string;
|
classId: string
|
||||||
className: string;
|
className: string
|
||||||
course: string;
|
course: string
|
||||||
startTime: string;
|
startTime: string
|
||||||
endTime: string;
|
endTime: string
|
||||||
location: string | null;
|
location: string | null
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
const hasSchedule = items.length > 0
|
||||||
|
|
||||||
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
|
||||||
const hasSchedule = items.length > 0;
|
|
||||||
|
|
||||||
const getStatus = (start: string, end: string) => {
|
const getStatus = (start: string, end: string) => {
|
||||||
const now = new Date();
|
const now = new Date()
|
||||||
const currentTime = now.getHours() * 60 + now.getMinutes();
|
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||||
|
|
||||||
const [startH, startM] = start.split(":").map(Number);
|
|
||||||
const [endH, endM] = end.split(":").map(Number);
|
|
||||||
const startTime = startH * 60 + startM;
|
|
||||||
const endTime = endH * 60 + endM;
|
|
||||||
|
|
||||||
if (currentTime >= startTime && currentTime <= endTime) return "live";
|
const [startH, startM] = start.split(":").map(Number)
|
||||||
if (currentTime < startTime) return "upcoming";
|
const [endH, endM] = end.split(":").map(Number)
|
||||||
return "past";
|
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
|
||||||
};
|
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
|
||||||
|
|
||||||
|
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||||
|
if (currentTime < startTime) return "upcoming"
|
||||||
|
return "past"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||||
Today's Schedule
|
{t("sections.todaySchedule")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{!hasSchedule ? (
|
{!hasSchedule ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={CalendarX}
|
icon={CalendarX}
|
||||||
title="No Classes Today"
|
title={t("empty.noClassesToday")}
|
||||||
description="No timetable entries."
|
description={t("empty.noClassesTodayDesc")}
|
||||||
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
|
action={{ label: t("quickActions.viewSchedule"), href: "/teacher/classes/schedule" }}
|
||||||
className="border-none h-[200px]"
|
className="border-none h-[200px]"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[240px] px-6 py-2">
|
<ScrollArea className="h-[240px] px-6 py-2">
|
||||||
<div className="relative space-y-0 ml-1">
|
<div className="relative space-y-0 ml-1">
|
||||||
{/* Vertical Timeline Line */}
|
|
||||||
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
|
||||||
|
|
||||||
{/* Top Fade Hint */}
|
|
||||||
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||||
|
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const status = getStatus(item.startTime, item.endTime);
|
const status = getStatus(item.startTime, item.endTime)
|
||||||
const isLive = status === "live";
|
const isLive = status === "live"
|
||||||
const isPast = status === "past";
|
const isPast = status === "past"
|
||||||
const isLast = index === items.length - 1;
|
const isLast = index === items.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
|
||||||
{/* Timeline Dot */}
|
<div className={cn(
|
||||||
<div className={cn(
|
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
||||||
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
|
isLive ? "bg-primary border-primary" :
|
||||||
isLive ? "bg-primary border-primary" :
|
isPast ? "bg-muted border-muted-foreground/30" :
|
||||||
isPast ? "bg-muted border-muted-foreground/30" :
|
"bg-background border-primary"
|
||||||
"bg-background border-primary"
|
)} />
|
||||||
)} />
|
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
|
||||||
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
|
||||||
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
|
||||||
"bg-card"
|
"bg-card"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="space-y-0.5 min-w-0">
|
<div className="space-y-0.5 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"font-medium text-sm truncate",
|
"font-medium text-sm truncate",
|
||||||
isLive ? "text-primary" : "text-foreground"
|
isLive ? "text-primary" : "text-foreground"
|
||||||
)}>
|
)}>
|
||||||
{item.course}
|
{item.course}
|
||||||
</span>
|
</span>
|
||||||
{isLive && (
|
{isLive && (
|
||||||
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
|
||||||
LIVE
|
{t("badge.live")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
|
||||||
<span>{item.className}</span>
|
|
||||||
{item.location && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span className="flex items-center">
|
|
||||||
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
|
||||||
{item.location}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||||
<div className={cn(
|
<span>{item.className}</span>
|
||||||
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
{item.location && (
|
||||||
isLive ? "text-primary" : "text-muted-foreground"
|
<>
|
||||||
)}>
|
<span>·</span>
|
||||||
{item.startTime}
|
<span className="flex items-center">
|
||||||
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
<MapPin className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{item.location}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
<div className={cn(
|
||||||
{/* Connection Line to Next (if not last) */}
|
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
|
||||||
{!isLast && (
|
isLive ? "text-primary" : "text-muted-foreground"
|
||||||
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
)}>
|
||||||
)}
|
{item.startTime}
|
||||||
</div>
|
<span className="text-[10px] opacity-70 ml-0.5">– {item.endTime}</span>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{!isLast && (
|
||||||
|
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Bottom Hint */}
|
|
||||||
{items.length > 3 ? (
|
{items.length > 3 ? (
|
||||||
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
|
||||||
Scroll for more
|
{t("schedule.scrollForMore")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
|
||||||
No more classes today
|
{t("schedule.noMoreClasses")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block";
|
|||||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||||
import type { LessonPlanNode, RichTextBlockData } from "../types";
|
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types";
|
||||||
|
|
||||||
interface BlockRendererProps {
|
interface BlockRendererProps {
|
||||||
textbookId?: string;
|
textbookId?: string;
|
||||||
@@ -112,13 +112,13 @@ function SortableBlock({
|
|||||||
) : node.type === "exercise" ? (
|
) : node.type === "exercise" ? (
|
||||||
<ExerciseBlock
|
<ExerciseBlock
|
||||||
blockId={node.id}
|
blockId={node.id}
|
||||||
data={node.data as never}
|
data={node.data as ExerciseBlockData}
|
||||||
classes={classes ?? []}
|
classes={classes ?? []}
|
||||||
/>
|
/>
|
||||||
) : node.type === "text_study" ? (
|
) : node.type === "text_study" ? (
|
||||||
<TextStudyBlock
|
<TextStudyBlock
|
||||||
blockId={node.id}
|
blockId={node.id}
|
||||||
data={node.data as never}
|
data={node.data as TextStudyBlockData}
|
||||||
/>
|
/>
|
||||||
) : node.type === "reflection" ? (
|
) : node.type === "reflection" ? (
|
||||||
<ReflectionBlock
|
<ReflectionBlock
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
: t("questionBank.inlineQuestion")}
|
: t("questionBank.inlineQuestion")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
|
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
|
||||||
<button onClick={() => removeItem(idx)}>
|
<button onClick={() => removeItem(idx)} aria-label={t("action.delete")}>
|
||||||
<Trash2 className="w-3 h-3 text-error" />
|
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -97,7 +97,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowBank(true)}
|
onClick={() => setShowBank(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
{t("questionBank.fromBank")}
|
{t("questionBank.fromBank")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -105,7 +105,7 @@ export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }:
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowInline(true)}
|
onClick={() => setShowInline(true)}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
{t("questionBank.inlineNew")}
|
{t("questionBank.inlineNew")}
|
||||||
</Button>
|
</Button>
|
||||||
{data.publishedAssignmentId ? (
|
{data.publishedAssignmentId ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
@@ -37,7 +38,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
|
|
||||||
function addAnnotation() {
|
function addAnnotation() {
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
alert(t("textStudy.selectFirst"));
|
toast.error(t("textStudy.selectFirst"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ann: TextStudyAnnotation = {
|
const ann: TextStudyAnnotation = {
|
||||||
@@ -76,7 +77,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
onClick={addAnnotation}
|
onClick={addAnnotation}
|
||||||
disabled={!selection}
|
disabled={!selection}
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
<Plus className="w-3 h-3 mr-1" aria-hidden="true" />
|
||||||
{t("textStudy.addAnnotation")}
|
{t("textStudy.addAnnotation")}
|
||||||
</Button>
|
</Button>
|
||||||
{data.annotations.length > 0 && (
|
{data.annotations.length > 0 && (
|
||||||
@@ -100,8 +101,8 @@ export function TextStudyBlock({ blockId, data }: Props) {
|
|||||||
}
|
}
|
||||||
className="font-medium text-sm bg-transparent flex-1"
|
className="font-medium text-sm bg-transparent flex-1"
|
||||||
/>
|
/>
|
||||||
<button onClick={() => removeAnnotation(ann.id)}>
|
<button onClick={() => removeAnnotation(ann.id)} aria-label={t("action.delete")}>
|
||||||
<Trash2 className="w-3 h-3 text-error" />
|
<Trash2 className="w-3 h-3 text-error" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { X, Tag } from "lucide-react";
|
import { X, Tag } from "lucide-react";
|
||||||
@@ -27,9 +28,15 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
const [kpIds, setKpIds] = useState<string[]>([]);
|
const [kpIds, setKpIds] = useState<string[]>([]);
|
||||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||||
|
|
||||||
|
// 类型守卫:安全地将 string 收窄为联合类型
|
||||||
|
const QUESTION_TYPES = ["single_choice", "text", "judgment"] as const;
|
||||||
|
function isQuestionType(v: string): v is "single_choice" | "text" | "judgment" {
|
||||||
|
return QUESTION_TYPES.includes(v as typeof QUESTION_TYPES[number]);
|
||||||
|
}
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
alert(t("questionBank.stemRequired"));
|
toast.error(t("questionBank.stemRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content: Record<string, unknown> =
|
const content: Record<string, unknown> =
|
||||||
@@ -63,11 +70,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col" role="dialog" aria-modal="true" aria-label={t("questionBank.inlineTitle")}>
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
|
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
@@ -75,7 +82,11 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</label>
|
||||||
<select
|
<select
|
||||||
value={type}
|
value={type}
|
||||||
onChange={(e) => setType(e.target.value as never)}
|
onChange={(e) => {
|
||||||
|
if (isQuestionType(e.target.value)) {
|
||||||
|
setType(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="w-full border rounded px-2 py-1 mt-1"
|
className="w-full border rounded px-2 py-1 mt-1"
|
||||||
>
|
>
|
||||||
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
|
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
|
||||||
@@ -120,7 +131,7 @@ export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }:
|
|||||||
setOptions(options.filter((_, j) => j !== i))
|
setOptions(options.filter((_, j) => j !== i))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
删除
|
{t("action.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,11 +47,16 @@ export function KnowledgePointPicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("knowledgePoint.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col"
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||||
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
|
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
|||||||
@@ -3,14 +3,51 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { formatDateTime } from "@/shared/lib/utils";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||||
|
import { useLessonPlanContextSafe, useRoleConfig } from "../providers/lesson-plan-provider";
|
||||||
import type { LessonPlanListItem } from "../types";
|
import type { LessonPlanListItem } from "../types";
|
||||||
|
|
||||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const roleConfig = useRoleConfig();
|
||||||
|
|
||||||
|
// 尝试使用注入的数据服务,若未在 Provider 内则 fallback 到直接调用 actions
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
|
async function handleArchive() {
|
||||||
|
const res = service
|
||||||
|
? await service.deleteLessonPlan(plan.id)
|
||||||
|
: await deleteLessonPlanAction(plan.id);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success(t("status.archived"));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(res.message ?? t("error.delete"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate() {
|
||||||
|
const res = service
|
||||||
|
? await service.duplicateLessonPlan(plan.id)
|
||||||
|
: await duplicateLessonPlanAction(plan.id);
|
||||||
|
if (res.success) router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||||
@@ -36,27 +73,34 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
|||||||
: t("list.neverSaved")}
|
: t("list.neverSaved")}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<Button
|
{roleConfig.canDuplicate && (
|
||||||
variant="outline"
|
<Button variant="outline" size="sm" onClick={handleDuplicate}>
|
||||||
size="sm"
|
{t("action.duplicate")}
|
||||||
onClick={async () => {
|
</Button>
|
||||||
const res = await duplicateLessonPlanAction(plan.id);
|
)}
|
||||||
if (res.success) router.refresh();
|
{roleConfig.canArchive && (
|
||||||
}}
|
<AlertDialog>
|
||||||
>
|
<AlertDialogTrigger asChild>
|
||||||
{t("action.duplicate")}
|
<Button variant="outline" size="sm">
|
||||||
</Button>
|
{t("action.archive")}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
</AlertDialogTrigger>
|
||||||
size="sm"
|
<AlertDialogContent>
|
||||||
onClick={async () => {
|
<AlertDialogHeader>
|
||||||
if (!confirm(t("confirm.archive"))) return;
|
<AlertDialogTitle>{t("confirm.archiveTitle")}</AlertDialogTitle>
|
||||||
const res = await deleteLessonPlanAction(plan.id);
|
<AlertDialogDescription>
|
||||||
if (res.success) router.refresh();
|
{t("confirm.archive")}
|
||||||
}}
|
</AlertDialogDescription>
|
||||||
>
|
</AlertDialogHeader>
|
||||||
{t("action.archive")}
|
<AlertDialogFooter>
|
||||||
</Button>
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleArchive}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export function LessonPlanEditor({
|
|||||||
<button
|
<button
|
||||||
key={blockType}
|
key={blockType}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.addNode(blockType);
|
editor.addNode(blockType, undefined, t(`blockType.${blockType}`));
|
||||||
setShowAddMenu(false);
|
setShowAddMenu(false);
|
||||||
}}
|
}}
|
||||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import { LessonPlanCard } from "./lesson-plan-card";
|
import { LessonPlanCard } from "./lesson-plan-card";
|
||||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||||
import { getLessonPlansAction } from "../actions";
|
import { getLessonPlansAction } from "../actions";
|
||||||
|
import { useLessonPlanContextSafe } from "../providers/lesson-plan-provider";
|
||||||
import type { LessonPlanListItem } from "../types";
|
import type { LessonPlanListItem } from "../types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,12 +16,19 @@ interface Props {
|
|||||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||||
const t = useTranslations("lessonPreparation");
|
const t = useTranslations("lessonPreparation");
|
||||||
const [items, setItems] = useState(initialItems);
|
const [items, setItems] = useState(initialItems);
|
||||||
|
const ctx = useLessonPlanContextSafe();
|
||||||
|
const service = ctx?.service ?? null;
|
||||||
|
|
||||||
async function handleFilter(params: {
|
async function handleFilter(params: {
|
||||||
query?: string;
|
query?: string;
|
||||||
subjectId?: string;
|
subjectId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}) {
|
}) {
|
||||||
|
if (service) {
|
||||||
|
const res = await service.getLessonPlans(params);
|
||||||
|
if (res.success && res.data) setItems(res.data.items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const res = await getLessonPlansAction(params);
|
const res = await getLessonPlansAction(params);
|
||||||
if (res.success && res.data) setItems(res.data.items);
|
if (res.success && res.data) setItems(res.data.items);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,11 +53,16 @@ export function PublishHomeworkDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("publish.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-96"
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">{t("publish.title")}</h3>
|
<h3 className="font-title-md">{t("publish.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
|
|||||||
@@ -92,11 +92,16 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("questionBank.title")}
|
||||||
|
className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col"
|
||||||
|
>
|
||||||
<div className="flex justify-between items-center p-4 border-b">
|
<div className="flex justify-between items-center p-4 border-b">
|
||||||
<h3 className="font-title-md">{t("questionBank.title")}</h3>
|
<h3 className="font-title-md">{t("questionBank.title")}</h3>
|
||||||
<button onClick={onClose}>
|
<button onClick={onClose} aria-label={t("action.close")}>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-b">
|
<div className="p-4 border-b">
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function TemplatePicker() {
|
|||||||
: "border-outline-variant hover:border-primary/50"
|
: "border-outline-variant hover:border-primary/50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-title-md">{tpl.name}</div>
|
<div className="font-title-md">{t(`template.names.${tpl.id}`)}</div>
|
||||||
<div className="text-sm text-on-surface-variant mt-1">
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
{tpl.blocks.length === 0
|
{tpl.blocks.length === 0
|
||||||
? t("template.blankHint")
|
? t("template.blankHint")
|
||||||
|
|||||||
@@ -2,11 +2,23 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
getLessonPlanVersionsAction,
|
getLessonPlanVersionsAction,
|
||||||
revertLessonPlanVersionAction,
|
revertLessonPlanVersionAction,
|
||||||
} from "../actions";
|
} from "../actions";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/shared/components/ui/alert-dialog";
|
||||||
import { formatDateTime } from "@/shared/lib/utils";
|
import { formatDateTime } from "@/shared/lib/utils";
|
||||||
import type { LessonPlanVersion } from "../types";
|
import type { LessonPlanVersion } from "../types";
|
||||||
|
|
||||||
@@ -46,13 +58,13 @@ export function VersionHistoryDrawer({
|
|||||||
}, [open, planId]);
|
}, [open, planId]);
|
||||||
|
|
||||||
async function handleRevert(versionNo: number) {
|
async function handleRevert(versionNo: number) {
|
||||||
if (!confirm(t("version.revertConfirm", { versionNo }))) return;
|
|
||||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
toast.success(t("version.revertSuccess", { versionNo }));
|
||||||
onReverted();
|
onReverted();
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
alert(res.message);
|
toast.error(res.message ?? t("error.revert"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +99,27 @@ export function VersionHistoryDrawer({
|
|||||||
<p className="text-xs text-on-surface-variant mt-1">
|
<p className="text-xs text-on-surface-variant mt-1">
|
||||||
{formatDateTime(v.createdAt)}
|
{formatDateTime(v.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<AlertDialog>
|
||||||
variant="outline"
|
<AlertDialogTrigger asChild>
|
||||||
size="sm"
|
<Button variant="outline" size="sm" className="mt-2">
|
||||||
className="mt-2"
|
{t("version.revert")}
|
||||||
onClick={() => handleRevert(v.versionNo)}
|
</Button>
|
||||||
>
|
</AlertDialogTrigger>
|
||||||
{t("version.revert")}
|
<AlertDialogContent>
|
||||||
</Button>
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("version.revertTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("version.revertConfirm", { versionNo: v.versionNo })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("action.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => handleRevert(v.versionNo)}>
|
||||||
|
{t("action.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";
|
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";
|
||||||
|
|
||||||
// block 类型 → 中文默认标题
|
// block 类型 → i18n 键(实际文本由 useTranslations("lessonPreparation").blockType.${type} 翻译)
|
||||||
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
// 保留此映射用于:1) 新节点默认标题的 i18n 键查找 2) 类型守卫
|
||||||
objective: "教学目标",
|
export const BLOCK_TYPE_KEYS: Record<BlockType, string> = {
|
||||||
key_point: "教学重难点",
|
objective: "objective",
|
||||||
import: "导入",
|
key_point: "key_point",
|
||||||
new_teaching: "新授",
|
import: "import",
|
||||||
consolidation: "巩固练习",
|
new_teaching: "new_teaching",
|
||||||
summary: "课堂小结",
|
consolidation: "consolidation",
|
||||||
homework: "作业布置",
|
summary: "summary",
|
||||||
blackboard: "板书设计",
|
homework: "homework",
|
||||||
text_study: "文本研习",
|
blackboard: "blackboard",
|
||||||
exercise: "练习/作业",
|
text_study: "text_study",
|
||||||
rich_text: "自定义环节",
|
exercise: "exercise",
|
||||||
reflection: "教学反思",
|
rich_text: "rich_text",
|
||||||
|
reflection: "reflection",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 向后兼容:保留 BLOCK_TYPE_LABELS 名称但值为 i18n 键(实际翻译由组件层完成)
|
||||||
|
// @deprecated 使用 BLOCK_TYPE_KEYS 或 useTranslations("lessonPreparation").blockType.${type} 替代
|
||||||
|
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = BLOCK_TYPE_KEYS;
|
||||||
|
|
||||||
// 富文本类 block(共享同一编辑组件)
|
// 富文本类 block(共享同一编辑组件)
|
||||||
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
|
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
|
||||||
"objective",
|
"objective",
|
||||||
@@ -100,8 +105,11 @@ export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = {
|
export const LESSON_PLAN_STATUS_KEYS: Record<string, string> = {
|
||||||
draft: "草稿",
|
draft: "draft",
|
||||||
published: "已发布",
|
published: "published",
|
||||||
archived: "已归档",
|
archived: "archived",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// @deprecated 使用 useTranslations("lessonPreparation").status.${key} 替代
|
||||||
|
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = LESSON_PLAN_STATUS_KEYS;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
@@ -32,23 +32,53 @@ import type {
|
|||||||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||||||
|
|
||||||
// ---- DataScope → 查询条件 ----
|
// ---- DataScope → 查询条件 ----
|
||||||
|
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
|
||||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||||
switch (scope.type) {
|
switch (scope.type) {
|
||||||
case "all":
|
case "all":
|
||||||
return [];
|
return [];
|
||||||
case "owned":
|
case "owned":
|
||||||
return [eq(lessonPlans.creatorId, userId)];
|
return [eq(lessonPlans.creatorId, userId)];
|
||||||
case "class_taught":
|
case "class_taught": {
|
||||||
case "grade_managed":
|
// 教师:自己创建的 + published 且属于自己教授学科的
|
||||||
case "class_members":
|
const own = eq(lessonPlans.creatorId, userId);
|
||||||
case "children":
|
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||||
// 教师看自己创建的 + published 的
|
const subjectFilter =
|
||||||
|
scope.subjectIds && scope.subjectIds.length > 0
|
||||||
|
? inArray(lessonPlans.subjectId, scope.subjectIds)
|
||||||
|
: sql<boolean>`true`;
|
||||||
return [
|
return [
|
||||||
or(
|
or(
|
||||||
eq(lessonPlans.creatorId, userId),
|
own,
|
||||||
eq(lessonPlans.status, "published"),
|
and(publishedFilter, subjectFilter),
|
||||||
)!,
|
)!,
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
case "grade_managed": {
|
||||||
|
// 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级
|
||||||
|
const own = eq(lessonPlans.creatorId, userId);
|
||||||
|
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||||
|
const gradeFilter =
|
||||||
|
scope.gradeIds.length > 0
|
||||||
|
? inArray(lessonPlans.gradeId, scope.gradeIds)
|
||||||
|
: sql<boolean>`false`;
|
||||||
|
return [
|
||||||
|
or(
|
||||||
|
own,
|
||||||
|
and(publishedFilter, gradeFilter),
|
||||||
|
)!,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "class_members": {
|
||||||
|
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
|
||||||
|
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||||
|
return [publishedFilter];
|
||||||
|
}
|
||||||
|
case "children": {
|
||||||
|
// 家长:仅查看 published 课案(同学生)
|
||||||
|
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||||||
|
return [publishedFilter];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
LessonPlanEdge,
|
LessonPlanEdge,
|
||||||
LessonPlanNode,
|
LessonPlanNode,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
planId: string;
|
planId: string;
|
||||||
@@ -24,7 +23,7 @@ interface EditorState {
|
|||||||
setPlanId: (planId: string) => void;
|
setPlanId: (planId: string) => void;
|
||||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||||
|
|
||||||
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
|
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
|
||||||
updateNode: (id: string, patch: Partial<Block>) => void;
|
updateNode: (id: string, patch: Partial<Block>) => void;
|
||||||
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
||||||
removeNode: (id: string) => void;
|
removeNode: (id: string) => void;
|
||||||
@@ -76,13 +75,13 @@ export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
|||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addNode: (type, position) => {
|
addNode: (type, position, title) => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
const nodeCount = get().doc.nodes.length;
|
const nodeCount = get().doc.nodes.length;
|
||||||
const node: LessonPlanNode = {
|
const node: LessonPlanNode = {
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
title: BLOCK_TYPE_LABELS[type],
|
title: title ?? type, // 调用方应传入翻译后的标题,fallback 为 type 键
|
||||||
data: defaultData(type),
|
data: defaultData(type),
|
||||||
order: nodeCount,
|
order: nodeCount,
|
||||||
position: position ?? {
|
position: position ?? {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||||
|
import type { LessonPlanListItem, LessonPlanVersion } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备课模块数据服务接口(P1-7)。
|
||||||
|
* 抽象数据依赖,各角色/测试可提供不同实现,通过 LessonPlanProvider 注入。
|
||||||
|
* 组件不直接 import actions,只通过此接口调用。
|
||||||
|
*/
|
||||||
|
export interface LessonPlanDataService {
|
||||||
|
/** 查询课案列表 */
|
||||||
|
getLessonPlans(params?: {
|
||||||
|
query?: string;
|
||||||
|
textbookId?: string;
|
||||||
|
chapterId?: string;
|
||||||
|
subjectId?: string;
|
||||||
|
status?: string;
|
||||||
|
}): Promise<{ success: boolean; data?: { items: LessonPlanListItem[] }; message?: string }>;
|
||||||
|
|
||||||
|
/** 获取课案版本列表 */
|
||||||
|
getLessonPlanVersions(planId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { versions: LessonPlanVersion[] };
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 回退到指定版本 */
|
||||||
|
revertLessonPlanVersion(params: {
|
||||||
|
planId: string;
|
||||||
|
versionNo: number;
|
||||||
|
}): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
/** 复制课案 */
|
||||||
|
duplicateLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||||
|
|
||||||
|
/** 删除/归档课案 */
|
||||||
|
deleteLessonPlan(planId: string): Promise<{ success: boolean; message?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色配置(P1-7):决定该模块渲染哪些 Widget/子模块/操作。
|
||||||
|
* 新增角色只需新增配置项,不改组件代码。
|
||||||
|
*/
|
||||||
|
export interface LessonPlanRoleConfig {
|
||||||
|
/** 是否显示"新建课案"按钮 */
|
||||||
|
canCreate: boolean;
|
||||||
|
/** 是否显示"编辑"入口 */
|
||||||
|
canEdit: boolean;
|
||||||
|
/** 是否显示"发布为作业" */
|
||||||
|
canPublish: boolean;
|
||||||
|
/** 是否显示"复制" */
|
||||||
|
canDuplicate: boolean;
|
||||||
|
/** 是否显示"归档/删除" */
|
||||||
|
canArchive: boolean;
|
||||||
|
/** 是否显示"版本历史" */
|
||||||
|
canViewVersions: boolean;
|
||||||
|
/** 是否显示"AI 知识点建议" */
|
||||||
|
canUseAiSuggest: boolean;
|
||||||
|
/** 是否只读模式(学生/家长查看 published 课案) */
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认教师配置 */
|
||||||
|
export const TEACHER_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||||
|
canCreate: true,
|
||||||
|
canEdit: true,
|
||||||
|
canPublish: true,
|
||||||
|
canDuplicate: true,
|
||||||
|
canArchive: true,
|
||||||
|
canViewVersions: true,
|
||||||
|
canUseAiSuggest: true,
|
||||||
|
readOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 管理员配置(查看全校课案,不可编辑) */
|
||||||
|
export const ADMIN_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||||
|
canCreate: false,
|
||||||
|
canEdit: false,
|
||||||
|
canPublish: false,
|
||||||
|
canDuplicate: false,
|
||||||
|
canArchive: false,
|
||||||
|
canViewVersions: true,
|
||||||
|
canUseAiSuggest: false,
|
||||||
|
readOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 学生配置(仅查看 published) */
|
||||||
|
export const STUDENT_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||||
|
canCreate: false,
|
||||||
|
canEdit: false,
|
||||||
|
canPublish: false,
|
||||||
|
canDuplicate: false,
|
||||||
|
canArchive: false,
|
||||||
|
canViewVersions: false,
|
||||||
|
canUseAiSuggest: false,
|
||||||
|
readOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 家长配置(查看孩子的 published 课案) */
|
||||||
|
export const PARENT_ROLE_CONFIG: LessonPlanRoleConfig = {
|
||||||
|
canCreate: false,
|
||||||
|
canEdit: false,
|
||||||
|
canPublish: false,
|
||||||
|
canDuplicate: false,
|
||||||
|
canArchive: false,
|
||||||
|
canViewVersions: false,
|
||||||
|
canUseAiSuggest: false,
|
||||||
|
readOnly: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 角色配置注册表 */
|
||||||
|
export const ROLE_CONFIGS: Record<string, LessonPlanRoleConfig> = {
|
||||||
|
admin: ADMIN_ROLE_CONFIG,
|
||||||
|
teacher: TEACHER_ROLE_CONFIG,
|
||||||
|
student: STUDENT_ROLE_CONFIG,
|
||||||
|
parent: PARENT_ROLE_CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 监控埋点接口(P2-4):预留关键操作埋点 */
|
||||||
|
export interface LessonPlanTracker {
|
||||||
|
track(event: string, payload?: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认空实现埋点(生产环境可替换为真实埋点) */
|
||||||
|
export const noopTracker: LessonPlanTracker = {
|
||||||
|
track: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 备课模块上下文值 */
|
||||||
|
export interface LessonPlanContextValue {
|
||||||
|
/** 数据服务(抽象数据依赖) */
|
||||||
|
service: LessonPlanDataService;
|
||||||
|
/** 角色配置 */
|
||||||
|
roleConfig: LessonPlanRoleConfig;
|
||||||
|
/** 监控埋点 */
|
||||||
|
tracker: LessonPlanTracker;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LessonPlanContext = createContext<LessonPlanContextValue | null>(null);
|
||||||
|
|
||||||
|
/** Provider 组件:注入数据服务、角色配置、埋点 */
|
||||||
|
export function LessonPlanProvider({
|
||||||
|
children,
|
||||||
|
service,
|
||||||
|
roleConfig,
|
||||||
|
tracker = noopTracker,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
service: LessonPlanDataService;
|
||||||
|
roleConfig: LessonPlanRoleConfig;
|
||||||
|
tracker?: LessonPlanTracker;
|
||||||
|
}) {
|
||||||
|
const value = useMemo<LessonPlanContextValue>(
|
||||||
|
() => ({ service, roleConfig, tracker }),
|
||||||
|
[service, roleConfig, tracker],
|
||||||
|
);
|
||||||
|
return <LessonPlanContext.Provider value={value}>{children}</LessonPlanContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook:获取备课模块上下文值(若未在 Provider 内则返回 null,不抛错) */
|
||||||
|
export function useLessonPlanContextSafe(): LessonPlanContextValue | null {
|
||||||
|
return useContext(LessonPlanContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook:获取备课模块上下文值(必须在 Provider 内使用) */
|
||||||
|
export function useLessonPlanContext(): LessonPlanContextValue {
|
||||||
|
const ctx = useContext(LessonPlanContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useLessonPlanContext 必须在 LessonPlanProvider 内使用");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook:获取角色配置(若未在 Provider 内则返回教师默认配置) */
|
||||||
|
export function useRoleConfig(): LessonPlanRoleConfig {
|
||||||
|
const ctx = useContext(LessonPlanContext);
|
||||||
|
return ctx?.roleConfig ?? TEACHER_ROLE_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook:获取数据服务 */
|
||||||
|
export function useLessonPlanService(): LessonPlanDataService {
|
||||||
|
return useLessonPlanContext().service;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hook:获取埋点 */
|
||||||
|
export function useLessonPlanTracker(): LessonPlanTracker {
|
||||||
|
return useLessonPlanContext().tracker;
|
||||||
|
}
|
||||||
@@ -73,10 +73,17 @@ export async function publishLessonPlanHomework(
|
|||||||
for (let i = 0; i < newData.items.length; i++) {
|
for (let i = 0; i < newData.items.length; i++) {
|
||||||
const item = newData.items[i];
|
const item = newData.items[i];
|
||||||
if (item.source === "inline" && item.inlineContent) {
|
if (item.source === "inline" && item.inlineContent) {
|
||||||
|
// 类型守卫:确保 inline 题目类型合法
|
||||||
|
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
|
||||||
|
const qt = item.inlineContent.type;
|
||||||
|
if (!validTypes.includes(qt as typeof validTypes[number])) {
|
||||||
|
throw new Error(`无效的题目类型: ${qt}`);
|
||||||
|
}
|
||||||
|
const questionType = qt as typeof validTypes[number];
|
||||||
const questionId = await createQuestionWithRelations(
|
const questionId = await createQuestionWithRelations(
|
||||||
{
|
{
|
||||||
content: item.inlineContent.content,
|
content: item.inlineContent.content,
|
||||||
type: item.inlineContent.type as never,
|
type: questionType,
|
||||||
difficulty: item.inlineContent.difficulty,
|
difficulty: item.inlineContent.difficulty,
|
||||||
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLessonPlansAction,
|
||||||
|
getLessonPlanVersionsAction,
|
||||||
|
revertLessonPlanVersionAction,
|
||||||
|
duplicateLessonPlanAction,
|
||||||
|
deleteLessonPlanAction,
|
||||||
|
} from "../actions";
|
||||||
|
import type { LessonPlanDataService } from "../providers/lesson-plan-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认数据服务实现:包装现有 Server Actions。
|
||||||
|
* 通过 LessonPlanProvider 注入,组件不直接 import actions。
|
||||||
|
* 测试时可替换为 mock 实现。
|
||||||
|
*/
|
||||||
|
export function createDefaultDataService(): LessonPlanDataService {
|
||||||
|
return {
|
||||||
|
async getLessonPlans(params) {
|
||||||
|
const res = await getLessonPlansAction(params ?? {});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { items: res.data.items } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLessonPlanVersions(planId) {
|
||||||
|
const res = await getLessonPlanVersionsAction(planId);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
return { success: true, data: { versions: res.data.versions } };
|
||||||
|
}
|
||||||
|
return { success: false, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async revertLessonPlanVersion(params) {
|
||||||
|
const res = await revertLessonPlanVersionAction(params);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async duplicateLessonPlan(planId) {
|
||||||
|
const res = await duplicateLessonPlanAction(planId);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteLessonPlan(planId) {
|
||||||
|
const res = await deleteLessonPlanAction(planId);
|
||||||
|
return { success: res.success, message: res.message };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -64,7 +64,17 @@
|
|||||||
"grades": "Grades",
|
"grades": "Grades",
|
||||||
"attendance": "Attendance",
|
"attendance": "Attendance",
|
||||||
"announcements": "Announcements",
|
"announcements": "Announcements",
|
||||||
"leaveRequest": "Leave Request"
|
"leaveRequest": "Leave Request",
|
||||||
|
"createAssignment": "Create Assignment",
|
||||||
|
"grade": "Grade",
|
||||||
|
"myClasses": "My Classes",
|
||||||
|
"viewAll": "View all",
|
||||||
|
"viewSchedule": "View schedule",
|
||||||
|
"viewAllAssignments": "View all assignments",
|
||||||
|
"viewAllSubmissions": "View all submissions",
|
||||||
|
"createNewAssignment": "Create new assignment",
|
||||||
|
"create": "Create",
|
||||||
|
"createClass": "Create class"
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"title": "Today's To-Do",
|
"title": "Today's To-Do",
|
||||||
@@ -86,13 +96,26 @@
|
|||||||
"upcomingAssignments": "Upcoming Assignments",
|
"upcomingAssignments": "Upcoming Assignments",
|
||||||
"grades": "Grades",
|
"grades": "Grades",
|
||||||
"myClasses": "My Classes",
|
"myClasses": "My Classes",
|
||||||
"gradeTrends": "Grade Trends"
|
"gradeTrends": "Grade Trends",
|
||||||
|
"homework": "Homework",
|
||||||
|
"recentSubmissions": "Recent Submissions",
|
||||||
|
"classPerformance": "Class Performance",
|
||||||
|
"recentGrades": "Recent Grades"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"created": "Created"
|
"created": "Created",
|
||||||
|
"student": "Student",
|
||||||
|
"assignment": "Assignment",
|
||||||
|
"submitted": "Submitted",
|
||||||
|
"action": "Action",
|
||||||
|
"title": "Title",
|
||||||
|
"status": "Status",
|
||||||
|
"due": "Due",
|
||||||
|
"score": "Score",
|
||||||
|
"when": "When"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noUsers": "No users",
|
"noUsers": "No users",
|
||||||
@@ -105,12 +128,31 @@
|
|||||||
"noChildrenDesc": "Your account is not linked to any student accounts yet. Please contact the school administrator to link your child.",
|
"noChildrenDesc": "Your account is not linked to any student accounts yet. Please contact the school administrator to link your child.",
|
||||||
"noStudent": "No student found",
|
"noStudent": "No student found",
|
||||||
"noStudentDesc": "Create a student user to see dashboard.",
|
"noStudentDesc": "Create a student user to see dashboard.",
|
||||||
"contactSupport": "Contact support"
|
"contactSupport": "Contact support",
|
||||||
|
"noClassesYet": "No classes yet",
|
||||||
|
"noClassesDesc": "Create a class to start managing students and schedules.",
|
||||||
|
"noAssignments": "No assignments",
|
||||||
|
"noAssignmentsDesc": "Create an assignment to get started.",
|
||||||
|
"noAssignmentsStudent": "No assignments",
|
||||||
|
"noAssignmentsStudentDesc": "You have no assigned homework right now.",
|
||||||
|
"noClassesToday": "No classes today",
|
||||||
|
"noClassesTodayDesc": "Your timetable is clear for today.",
|
||||||
|
"noNewSubmissions": "No new submissions",
|
||||||
|
"noNewSubmissionsDesc": "All caught up! There are no new submissions to review.",
|
||||||
|
"noData": "No data available",
|
||||||
|
"noDataDesc": "Publish assignments to see class performance trends.",
|
||||||
|
"noGradedWork": "No graded work yet",
|
||||||
|
"noGradedWorkDesc": "Finish and submit assignments to see your score trend."
|
||||||
},
|
},
|
||||||
"badge": {
|
"badge": {
|
||||||
"activeSessions": "{count} active sessions",
|
"activeSessions": "{count} active sessions",
|
||||||
"users": "{count} users",
|
"users": "{count} users",
|
||||||
"childrenLinked": "{count} children linked"
|
"childrenLinked": "{count} children linked",
|
||||||
|
"live": "LIVE",
|
||||||
|
"late": "Late",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"upNext": "Up Next",
|
||||||
|
"unknown": "Unknown"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"loadFailed": "Page load failed",
|
"loadFailed": "Page load failed",
|
||||||
@@ -121,6 +163,26 @@
|
|||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"newUsers": "New users",
|
"newUsers": "New users",
|
||||||
"newSubmissions": "New submissions"
|
"newSubmissions": "New submissions",
|
||||||
|
"averageScorePercent": "Average Score (%)",
|
||||||
|
"scorePercent": "Score (%)",
|
||||||
|
"classPerformanceDesc": "Average scores for the last {count} assignments",
|
||||||
|
"submittedCount": "{submitted}/{total} submitted",
|
||||||
|
"latest": "Latest",
|
||||||
|
"points": "Points"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"noDueDate": "No due date",
|
||||||
|
"scrollForMore": "Scroll for more",
|
||||||
|
"noMoreClasses": "No more classes today",
|
||||||
|
"homeroom": "Homeroom",
|
||||||
|
"room": "Room"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"review": "Review",
|
||||||
|
"view": "View",
|
||||||
|
"continue": "Continue",
|
||||||
|
"start": "Start",
|
||||||
|
"grade": "Grade"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
"titleLabel": "Lesson Plan Title",
|
"titleLabel": "Lesson Plan Title",
|
||||||
"titlePlaceholder": "e.g., Autumn - Lesson 1",
|
"titlePlaceholder": "e.g., Autumn - Lesson 1",
|
||||||
"blockCount": "{count} sections",
|
"blockCount": "{count} sections",
|
||||||
"blankHint": "Start from scratch"
|
"blankHint": "Start from scratch",
|
||||||
|
"names": {
|
||||||
|
"tpl_regular": "Regular Lesson",
|
||||||
|
"tpl_review": "Review Lesson",
|
||||||
|
"tpl_experiment": "Experiment Lesson",
|
||||||
|
"tpl_inquiry": "Inquiry Lesson",
|
||||||
|
"tpl_blank": "Blank Template"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"canvasEmpty": "Canvas is empty",
|
"canvasEmpty": "Canvas is empty",
|
||||||
@@ -87,7 +94,9 @@
|
|||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"manual": "Manual save",
|
"manual": "Manual save",
|
||||||
"revert": "Revert to this version",
|
"revert": "Revert to this version",
|
||||||
|
"revertTitle": "Confirm Revert",
|
||||||
"revertConfirm": "Revert to v{versionNo}? A new version will be created.",
|
"revertConfirm": "Revert to v{versionNo}? A new version will be created.",
|
||||||
|
"revertSuccess": "Reverted to v{versionNo}",
|
||||||
"autoLabel": "Auto version"
|
"autoLabel": "Auto version"
|
||||||
},
|
},
|
||||||
"knowledgePoint": {
|
"knowledgePoint": {
|
||||||
@@ -193,6 +202,7 @@
|
|||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"archive": "Archive this lesson plan?"
|
"archive": "Archive this lesson plan?",
|
||||||
|
"archiveTitle": "Confirm Archive"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,17 @@
|
|||||||
"grades": "成绩",
|
"grades": "成绩",
|
||||||
"attendance": "考勤",
|
"attendance": "考勤",
|
||||||
"announcements": "通知",
|
"announcements": "通知",
|
||||||
"leaveRequest": "请假申请"
|
"leaveRequest": "请假申请",
|
||||||
|
"createAssignment": "新建作业",
|
||||||
|
"grade": "批改",
|
||||||
|
"myClasses": "我的班级",
|
||||||
|
"viewAll": "查看全部",
|
||||||
|
"viewSchedule": "查看课表",
|
||||||
|
"viewAllAssignments": "查看全部作业",
|
||||||
|
"viewAllSubmissions": "查看全部提交",
|
||||||
|
"createNewAssignment": "新建作业",
|
||||||
|
"create": "新建",
|
||||||
|
"createClass": "新建班级"
|
||||||
},
|
},
|
||||||
"todo": {
|
"todo": {
|
||||||
"title": "今日待办",
|
"title": "今日待办",
|
||||||
@@ -86,13 +96,26 @@
|
|||||||
"upcomingAssignments": "即将到期的作业",
|
"upcomingAssignments": "即将到期的作业",
|
||||||
"grades": "成绩",
|
"grades": "成绩",
|
||||||
"myClasses": "我的班级",
|
"myClasses": "我的班级",
|
||||||
"gradeTrends": "成绩趋势"
|
"gradeTrends": "成绩趋势",
|
||||||
|
"homework": "作业",
|
||||||
|
"recentSubmissions": "最近提交",
|
||||||
|
"classPerformance": "班级表现",
|
||||||
|
"recentGrades": "最近成绩"
|
||||||
},
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "姓名",
|
"name": "姓名",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"role": "角色",
|
"role": "角色",
|
||||||
"created": "创建时间"
|
"created": "创建时间",
|
||||||
|
"student": "学生",
|
||||||
|
"assignment": "作业",
|
||||||
|
"submitted": "提交时间",
|
||||||
|
"action": "操作",
|
||||||
|
"title": "标题",
|
||||||
|
"status": "状态",
|
||||||
|
"due": "截止时间",
|
||||||
|
"score": "分数",
|
||||||
|
"when": "时间"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noUsers": "暂无用户",
|
"noUsers": "暂无用户",
|
||||||
@@ -105,12 +128,31 @@
|
|||||||
"noChildrenDesc": "您的账号尚未关联任何学生账号,请联系学校管理员完成绑定。",
|
"noChildrenDesc": "您的账号尚未关联任何学生账号,请联系学校管理员完成绑定。",
|
||||||
"noStudent": "未找到学生用户",
|
"noStudent": "未找到学生用户",
|
||||||
"noStudentDesc": "请创建学生账号以查看仪表盘。",
|
"noStudentDesc": "请创建学生账号以查看仪表盘。",
|
||||||
"contactSupport": "联系客服"
|
"contactSupport": "联系客服",
|
||||||
|
"noClassesYet": "暂无班级",
|
||||||
|
"noClassesDesc": "创建班级以开始管理学生与课表。",
|
||||||
|
"noAssignments": "暂无作业",
|
||||||
|
"noAssignmentsDesc": "创建作业以开始布置任务。",
|
||||||
|
"noAssignmentsStudent": "暂无作业",
|
||||||
|
"noAssignmentsStudentDesc": "当前没有布置给你的作业。",
|
||||||
|
"noClassesToday": "今日无课",
|
||||||
|
"noClassesTodayDesc": "今日课表为空。",
|
||||||
|
"noNewSubmissions": "暂无新提交",
|
||||||
|
"noNewSubmissionsDesc": "全部处理完成!暂无新提交需要批改。",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"noDataDesc": "发布作业后即可查看班级表现趋势。",
|
||||||
|
"noGradedWork": "暂无已批改作业",
|
||||||
|
"noGradedWorkDesc": "完成并提交作业后即可查看成绩趋势。"
|
||||||
},
|
},
|
||||||
"badge": {
|
"badge": {
|
||||||
"activeSessions": "{count} 个活跃会话",
|
"activeSessions": "{count} 个活跃会话",
|
||||||
"users": "{count} 位用户",
|
"users": "{count} 位用户",
|
||||||
"childrenLinked": "已关联 {count} 个孩子"
|
"childrenLinked": "已关联 {count} 个孩子",
|
||||||
|
"live": "进行中",
|
||||||
|
"late": "已迟交",
|
||||||
|
"inProgress": "进行中",
|
||||||
|
"upNext": "下一节",
|
||||||
|
"unknown": "未知"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"loadFailed": "页面加载失败",
|
"loadFailed": "页面加载失败",
|
||||||
@@ -121,6 +163,26 @@
|
|||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"newUsers": "新增用户",
|
"newUsers": "新增用户",
|
||||||
"newSubmissions": "新增提交"
|
"newSubmissions": "新增提交",
|
||||||
|
"averageScorePercent": "平均分 (%)",
|
||||||
|
"scorePercent": "分数 (%)",
|
||||||
|
"classPerformanceDesc": "最近 {count} 份作业的平均分",
|
||||||
|
"submittedCount": "{submitted}/{total} 已提交",
|
||||||
|
"latest": "最新",
|
||||||
|
"points": "得分"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"noDueDate": "无截止日期",
|
||||||
|
"scrollForMore": "滚动查看更多",
|
||||||
|
"noMoreClasses": "今日无更多课程",
|
||||||
|
"homeroom": "班主任",
|
||||||
|
"room": "教室"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"review": "查看",
|
||||||
|
"view": "查看",
|
||||||
|
"continue": "继续",
|
||||||
|
"start": "开始",
|
||||||
|
"grade": "批改"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
"titleLabel": "课案标题",
|
"titleLabel": "课案标题",
|
||||||
"titlePlaceholder": "例如:《秋天》第一课时",
|
"titlePlaceholder": "例如:《秋天》第一课时",
|
||||||
"blockCount": "{count} 个环节",
|
"blockCount": "{count} 个环节",
|
||||||
"blankHint": "从空白开始"
|
"blankHint": "从空白开始",
|
||||||
|
"names": {
|
||||||
|
"tpl_regular": "常规课",
|
||||||
|
"tpl_review": "复习课",
|
||||||
|
"tpl_experiment": "实验课",
|
||||||
|
"tpl_inquiry": "探究课",
|
||||||
|
"tpl_blank": "空白模板"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"canvasEmpty": "画布为空",
|
"canvasEmpty": "画布为空",
|
||||||
@@ -87,7 +94,9 @@
|
|||||||
"auto": "自动",
|
"auto": "自动",
|
||||||
"manual": "手动保存",
|
"manual": "手动保存",
|
||||||
"revert": "回退到此版本",
|
"revert": "回退到此版本",
|
||||||
|
"revertTitle": "确认回退",
|
||||||
"revertConfirm": "确认回退到 v{versionNo}?将生成新版本。",
|
"revertConfirm": "确认回退到 v{versionNo}?将生成新版本。",
|
||||||
|
"revertSuccess": "已回退到 v{versionNo}",
|
||||||
"autoLabel": "自动版本"
|
"autoLabel": "自动版本"
|
||||||
},
|
},
|
||||||
"knowledgePoint": {
|
"knowledgePoint": {
|
||||||
@@ -193,6 +202,7 @@
|
|||||||
"retry": "重试"
|
"retry": "重试"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"archive": "确认归档此课案?"
|
"archive": "确认归档此课案?",
|
||||||
|
"archiveTitle": "确认归档"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
408
tests/integration/dashboard/dashboard-utils.test.ts
Normal file
408
tests/integration/dashboard/dashboard-utils.test.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
toWeekday,
|
||||||
|
countStudentAssignments,
|
||||||
|
sortUpcomingAssignments,
|
||||||
|
filterTodaySchedule,
|
||||||
|
computeTeacherMetrics,
|
||||||
|
getGreetingKey,
|
||||||
|
type Weekday,
|
||||||
|
} from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||||
|
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
|
||||||
|
import type {
|
||||||
|
HomeworkAssignmentListItem,
|
||||||
|
HomeworkSubmissionListItem,
|
||||||
|
TeacherGradeTrendItem,
|
||||||
|
} from "@/modules/homework/types"
|
||||||
|
|
||||||
|
describe("toWeekday", () => {
|
||||||
|
it("returns 1 for Monday", () => {
|
||||||
|
const monday = new Date("2026-06-22T00:00:00") // 2026-06-22 is Monday
|
||||||
|
expect(toWeekday(monday)).toBe(1 as Weekday)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 7 for Sunday", () => {
|
||||||
|
const sunday = new Date("2026-06-28T00:00:00") // 2026-06-28 is Sunday
|
||||||
|
expect(toWeekday(sunday)).toBe(7 as Weekday)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 6 for Saturday", () => {
|
||||||
|
const saturday = new Date("2026-06-27T00:00:00") // 2026-06-27 is Saturday
|
||||||
|
expect(toWeekday(saturday)).toBe(6 as Weekday)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns 2 for Tuesday", () => {
|
||||||
|
const tuesday = new Date("2026-06-23T00:00:00") // 2026-06-23 is Tuesday
|
||||||
|
expect(toWeekday(tuesday)).toBe(2 as Weekday)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getGreetingKey", () => {
|
||||||
|
it("returns morning before 12:00", () => {
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T08:00:00"))).toBe("morning")
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T11:59:59"))).toBe("morning")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns afternoon between 12:00 and 18:00", () => {
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T12:00:00"))).toBe("afternoon")
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T17:59:59"))).toBe("afternoon")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns evening at 18:00 or later", () => {
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T18:00:00"))).toBe("evening")
|
||||||
|
expect(getGreetingKey(new Date("2026-06-22T23:59:59"))).toBe("evening")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("countStudentAssignments", () => {
|
||||||
|
const now = new Date("2026-06-22T10:00:00")
|
||||||
|
|
||||||
|
it("returns zero counts for empty list", () => {
|
||||||
|
expect(countStudentAssignments([], now)).toEqual({
|
||||||
|
dueSoonCount: 0,
|
||||||
|
overdueCount: 0,
|
||||||
|
gradedCount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts graded assignments", () => {
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "graded", null),
|
||||||
|
makeAssignment("2", "graded", null),
|
||||||
|
]
|
||||||
|
expect(countStudentAssignments(assignments, now)).toEqual({
|
||||||
|
dueSoonCount: 0,
|
||||||
|
overdueCount: 0,
|
||||||
|
gradedCount: 2,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts due soon (within 7 days)", () => {
|
||||||
|
const in3Days = new Date(now)
|
||||||
|
in3Days.setDate(in3Days.getDate() + 3)
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", in3Days.toISOString()),
|
||||||
|
]
|
||||||
|
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts overdue", () => {
|
||||||
|
const past = new Date(now)
|
||||||
|
past.setDate(past.getDate() - 1)
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "submitted", past.toISOString()),
|
||||||
|
]
|
||||||
|
expect(countStudentAssignments(assignments, now).overdueCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores assignments without dueAt (non-graded)", () => {
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", null),
|
||||||
|
]
|
||||||
|
expect(countStudentAssignments(assignments, now)).toEqual({
|
||||||
|
dueSoonCount: 0,
|
||||||
|
overdueCount: 0,
|
||||||
|
gradedCount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("respects custom dueSoonWindowDays", () => {
|
||||||
|
const in15Days = new Date(now)
|
||||||
|
in15Days.setDate(in15Days.getDate() + 15)
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", in15Days.toISOString()),
|
||||||
|
]
|
||||||
|
// default 7-day window: not due soon
|
||||||
|
expect(countStudentAssignments(assignments, now).dueSoonCount).toBe(0)
|
||||||
|
// 30-day window: due soon
|
||||||
|
expect(countStudentAssignments(assignments, now, 30).dueSoonCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sortUpcomingAssignments", () => {
|
||||||
|
it("returns empty array for empty input", () => {
|
||||||
|
expect(sortUpcomingAssignments([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by dueAt ascending", () => {
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
|
||||||
|
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||||
|
makeAssignment("3", "in_progress", "2026-06-24T00:00:00"),
|
||||||
|
]
|
||||||
|
const sorted = sortUpcomingAssignments(assignments)
|
||||||
|
expect(sorted.map((a) => a.id)).toEqual(["2", "3", "1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("puts assignments without dueAt at the end", () => {
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", null),
|
||||||
|
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||||
|
]
|
||||||
|
const sorted = sortUpcomingAssignments(assignments)
|
||||||
|
expect(sorted.map((a) => a.id)).toEqual(["2", "1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("respects limit parameter", () => {
|
||||||
|
const assignments = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
makeAssignment(`${i}`, "in_progress", `2026-06-${23 + i}T00:00:00`),
|
||||||
|
)
|
||||||
|
expect(sortUpcomingAssignments(assignments, 3)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not mutate original array", () => {
|
||||||
|
const assignments = [
|
||||||
|
makeAssignment("1", "in_progress", "2026-06-25T00:00:00"),
|
||||||
|
makeAssignment("2", "in_progress", "2026-06-23T00:00:00"),
|
||||||
|
]
|
||||||
|
sortUpcomingAssignments(assignments)
|
||||||
|
expect(assignments.map((a) => a.id)).toEqual(["1", "2"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("filterTodaySchedule", () => {
|
||||||
|
const weekday = 1 as Weekday // Monday
|
||||||
|
|
||||||
|
it("returns empty array for empty schedule", () => {
|
||||||
|
expect(filterTodaySchedule([], weekday)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by weekday", () => {
|
||||||
|
const schedule: ClassScheduleItem[] = [
|
||||||
|
makeScheduleItem("1", 1, "08:00"),
|
||||||
|
makeScheduleItem("2", 2, "09:00"),
|
||||||
|
makeScheduleItem("3", 1, "07:00"),
|
||||||
|
]
|
||||||
|
const result = filterTodaySchedule(schedule, weekday)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect((result as Array<{ id: string }>).map((s) => s.id)).toEqual(["3", "1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by startTime ascending", () => {
|
||||||
|
const schedule: ClassScheduleItem[] = [
|
||||||
|
makeScheduleItem("1", 1, "10:00"),
|
||||||
|
makeScheduleItem("2", 1, "08:00"),
|
||||||
|
makeScheduleItem("3", 1, "09:00"),
|
||||||
|
]
|
||||||
|
const result = filterTodaySchedule(schedule, weekday) as Array<{ id: string }>
|
||||||
|
expect(result.map((s) => s.id)).toEqual(["2", "3", "1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses classNameById map when provided", () => {
|
||||||
|
const schedule: ClassScheduleItem[] = [
|
||||||
|
makeScheduleItem("1", 1, "08:00"),
|
||||||
|
]
|
||||||
|
const classNameById = new Map([["class-1", "Math 101"]])
|
||||||
|
const result = filterTodaySchedule(schedule, weekday, classNameById) as Array<{ className: string }>
|
||||||
|
expect(result[0].className).toBe("Math 101")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to 'Class' when classNameById missing", () => {
|
||||||
|
const schedule: ClassScheduleItem[] = [
|
||||||
|
makeScheduleItem("1", 1, "08:00"),
|
||||||
|
]
|
||||||
|
const result = filterTodaySchedule(schedule, weekday) as Array<{ className: string }>
|
||||||
|
expect(result[0].className).toBe("Class")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("computeTeacherMetrics", () => {
|
||||||
|
const now = new Date("2026-06-22T10:00:00") // Monday
|
||||||
|
|
||||||
|
it("returns zero metrics for empty inputs", () => {
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], [], [], now)
|
||||||
|
expect(metrics.toGradeCount).toBe(0)
|
||||||
|
expect(metrics.activeAssignmentsCount).toBe(0)
|
||||||
|
expect(metrics.averageScore).toBe(0)
|
||||||
|
expect(metrics.submissionRate).toBe(0)
|
||||||
|
expect(metrics.todayScheduleItems).toEqual([])
|
||||||
|
expect(metrics.submissionsToGrade).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts toGrade from submitted (non-graded) submissions", () => {
|
||||||
|
const submissions: HomeworkSubmissionListItem[] = [
|
||||||
|
makeSubmission("1", "submitted"),
|
||||||
|
makeSubmission("2", "submitted"),
|
||||||
|
makeSubmission("3", "graded"),
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
|
||||||
|
expect(metrics.toGradeCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts active assignments (published)", () => {
|
||||||
|
const assignments: HomeworkAssignmentListItem[] = [
|
||||||
|
makeAssignmentTeacher("1", "published"),
|
||||||
|
makeAssignmentTeacher("2", "published"),
|
||||||
|
makeAssignmentTeacher("3", "draft"),
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics([], [], assignments, [], [], now)
|
||||||
|
expect(metrics.activeAssignmentsCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes averageScore from gradeTrends", () => {
|
||||||
|
const trends: TeacherGradeTrendItem[] = [
|
||||||
|
makeTrend("1", 80),
|
||||||
|
makeTrend("2", 90),
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||||
|
expect(metrics.averageScore).toBe(85)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("computes submissionRate as percentage", () => {
|
||||||
|
const trends: TeacherGradeTrendItem[] = [
|
||||||
|
makeTrendWithSubmissions("1", 8, 10),
|
||||||
|
makeTrendWithSubmissions("2", 6, 10),
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||||
|
// (8 + 6) / (10 + 10) * 100 = 70
|
||||||
|
expect(metrics.submissionRate).toBe(70)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns submissionRate 0 when no students", () => {
|
||||||
|
const trends: TeacherGradeTrendItem[] = [
|
||||||
|
makeTrendWithSubmissions("1", 0, 0),
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], [], trends, now)
|
||||||
|
expect(metrics.submissionRate).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters today schedule by current weekday", () => {
|
||||||
|
const classes: TeacherClass[] = [
|
||||||
|
makeClass("c1", "Math 101"),
|
||||||
|
]
|
||||||
|
const schedule: ClassScheduleItem[] = [
|
||||||
|
makeScheduleItem("s1", 1, "08:00"), // Monday
|
||||||
|
makeScheduleItem("s2", 2, "09:00"), // Tuesday
|
||||||
|
]
|
||||||
|
const metrics = computeTeacherMetrics(classes, schedule, [], [], [], now)
|
||||||
|
expect(metrics.todayScheduleItems).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("limits submissionsToGrade to 6 items", () => {
|
||||||
|
const submissions: HomeworkSubmissionListItem[] = Array.from({ length: 10 }, (_, i) =>
|
||||||
|
makeSubmission(`${i}`, "submitted"),
|
||||||
|
)
|
||||||
|
const metrics = computeTeacherMetrics([], [], [], submissions, [], now)
|
||||||
|
expect(metrics.submissionsToGrade).toHaveLength(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ Helpers ============
|
||||||
|
|
||||||
|
function makeAssignment(
|
||||||
|
id: string,
|
||||||
|
progressStatus: string,
|
||||||
|
dueAt: string | null,
|
||||||
|
): StudentHomeworkAssignmentListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `Assignment ${id}`,
|
||||||
|
subjectName: null,
|
||||||
|
dueAt,
|
||||||
|
availableAt: null,
|
||||||
|
maxAttempts: 1,
|
||||||
|
attemptsUsed: 0,
|
||||||
|
progressStatus: progressStatus as StudentHomeworkAssignmentListItem["progressStatus"],
|
||||||
|
latestSubmissionId: null,
|
||||||
|
latestSubmittedAt: null,
|
||||||
|
latestScore: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScheduleItem(
|
||||||
|
id: string,
|
||||||
|
weekday: number,
|
||||||
|
startTime: string,
|
||||||
|
): ClassScheduleItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
classId: `class-${id}`,
|
||||||
|
weekday: weekday as ClassScheduleItem["weekday"],
|
||||||
|
startTime,
|
||||||
|
endTime: "09:00",
|
||||||
|
course: `Course ${id}`,
|
||||||
|
location: null,
|
||||||
|
} as ClassScheduleItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSubmission(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
): HomeworkSubmissionListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
assignmentId: `assignment-${id}`,
|
||||||
|
assignmentTitle: `Assignment ${id}`,
|
||||||
|
studentName: `Student ${id}`,
|
||||||
|
status: status as HomeworkSubmissionListItem["status"],
|
||||||
|
submittedAt: "2026-06-22T10:00:00",
|
||||||
|
isLate: false,
|
||||||
|
score: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAssignmentTeacher(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
): HomeworkAssignmentListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sourceExamId: null,
|
||||||
|
sourceExamTitle: null,
|
||||||
|
title: `Assignment ${id}`,
|
||||||
|
status: status as HomeworkAssignmentListItem["status"],
|
||||||
|
availableAt: null,
|
||||||
|
dueAt: null,
|
||||||
|
allowLate: false,
|
||||||
|
lateDueAt: null,
|
||||||
|
maxAttempts: 1,
|
||||||
|
createdAt: "2026-06-01T00:00:00",
|
||||||
|
updatedAt: "2026-06-01T00:00:00",
|
||||||
|
targetCount: 0,
|
||||||
|
submittedCount: 0,
|
||||||
|
gradedCount: 0,
|
||||||
|
averageScore: null,
|
||||||
|
overdueCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTrend(id: string, averageScore: number): TeacherGradeTrendItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `Trend ${id}`,
|
||||||
|
averageScore,
|
||||||
|
maxScore: 100,
|
||||||
|
submissionCount: 10,
|
||||||
|
totalStudents: 10,
|
||||||
|
createdAt: "2026-06-01T00:00:00",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTrendWithSubmissions(
|
||||||
|
id: string,
|
||||||
|
submissionCount: number,
|
||||||
|
totalStudents: number,
|
||||||
|
): TeacherGradeTrendItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `Trend ${id}`,
|
||||||
|
averageScore: 80,
|
||||||
|
maxScore: 100,
|
||||||
|
submissionCount,
|
||||||
|
totalStudents,
|
||||||
|
createdAt: "2026-06-01T00:00:00",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClass(id: string, name: string): TeacherClass {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
grade: "G1",
|
||||||
|
homeroom: null,
|
||||||
|
room: null,
|
||||||
|
studentCount: 0,
|
||||||
|
} as TeacherClass
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user