diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 29e4ab9..a825537 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -799,19 +799,29 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" ## 2.10 attendance(考勤模块)— 结构典范 -**职责**:考勤记录管理 + 统计分析。 +**职责**:考勤记录管理 + 统计分析 + 规则配置。 **导出函数**: -- Actions:`getAttendanceRecordsAction` / `createAttendanceRecordAction` / `updateAttendanceRecordAction` / `deleteAttendanceRecordAction` / `getStudentAttendanceAction` / `getAttendanceStatsAction` -- Data-access:`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合) -- Components:`AttendanceStatsCards`(管理员考勤总览页 6 卡片统计概览) +- Actions(10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction` +- Data-access:`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合)/ `upsertAttendanceRules` / `getAttendanceRules` +- Data-access-stats:`getStudentAttendanceSummary` / `getClassAttendanceStats` / `computeStats`(⚠️ 未导出,无法单测) +- Components:`AttendanceSheet`(批量点名表单)/ `AttendanceRecordList`(记录列表 + 删除)/ `AttendanceFilters`(URL 同步筛选器)/ `AttendanceStatsCard`(单卡片统计)/ `AttendanceStatsCards`(管理员 6 卡片总览)/ `AttendanceStatsClassSelector`(班级筛选 ChipNav)/ `AttendanceRulesForm`(规则配置表单)/ `StudentAttendanceView`(学生/家长只读视图) **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getTeacherClasses/getAdminClasses) -- 被依赖:无 +- 依赖:`shared/*`、`@/auth`、`classes`(⚠️ P1-1 未修复:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表) +- 被依赖:`parent`(⚠️ 跨模块 UI 类型依赖:3 个 parent 组件直接 import `@/modules/attendance/types`) -**已知问题**: -- ✅ P1-1 已修复:~~`getClassStudentsForAttendance` 直查 `classEnrollments`~~ 改为通过 classes data-access 获取 +**已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`): +- ❌ P0:`getAttendanceStats` 统计失真——调用 `getAttendanceRecords`(默认 pageSize=20)后对 `items` 聚合,仅基于前 20 条记录计算总览数据 +- ❌ P0:`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表(架构图此前声称已修复,实际未修复) +- ❌ P0:6 个读 Action 无调用方(页面绕过 Action 直接调用 data-access),违反三层架构 +- ❌ P0:update/delete Action 缺资源归属校验(教师 A 可修改/删除教师 B 的记录) +- ❌ P0:i18n 完全缺失(`ATTENDANCE_STATUS_LABELS` 硬编码英文,组件中硬编码中文) +- ❌ P0:错误边界完全缺失(5 个角色目录均无 `error.tsx`) +- ⚠️ P1:`computeStats` 未导出,无法单测 +- ⚠️ P1:`attendance-sheet.tsx` 使用 `window.confirm`(与项目 AlertDialog 模式不一致) +- ⚠️ P1:`attendance-sheet.tsx` 存在 `{} as Record` 类型断言 +- ⚠️ P1:`STATUS_OPTIONS`/`SHORTCUTS`/`STYLES` 常量在 types.ts 与 attendance-sheet.tsx 重复定义 - ✅ stats 独立拆分为 `data-access-stats.ts`(拆分范例) - ✅ DataScope 完整接入 6 种 scope 类型 - ✅ actions 层无直接 DB 访问 @@ -819,11 +829,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| -| `actions.ts` | 271 | 6 个 Server Action | -| `data-access.ts` | 309 | 考勤 CRUD + 管理员统计概览 | -| `data-access-stats.ts` | 145 | 统计逻辑(拆分范例) | -| `schema.ts` | - | Zod 校验 | -| `types.ts` | - | 类型定义 | +| `actions.ts` | 271 | 10 个 Server Action(含权限校验、Zod 校验) | +| `data-access.ts` | 309 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计 | +| `data-access-stats.ts` | 145 | 学生/班级考勤汇总(拆分范例,`computeStats` 未导出) | +| `schema.ts` | 43 | Zod 校验(5 个 schema) | +| `types.ts` | 103 | 类型定义 + 状态标签/颜色常量(硬编码英文) | +| `components/attendance-sheet.tsx` | 353 | 批量点名表单(键盘快捷键、状态按钮组) | +| `components/attendance-record-list.tsx` | 130 | 考勤记录列表 + 删除对话框 | +| `components/attendance-filters.tsx` | 97 | URL 同步筛选器(班级/状态/日期) | +| `components/attendance-stats-card.tsx` | 81 | 单卡片统计(8 指标) | +| `components/attendance-stats-cards.tsx` | 80 | 管理员总览 6 卡片网格(硬编码中文) | +| `components/attendance-stats-class-selector.tsx` | 27 | 班级筛选 ChipNav | +| `components/attendance-rules-form.tsx` | 148 | 考勤规则配置表单 | +| `components/student-attendance-view.tsx` | 104 | 学生/家长视图(统计 + 最近记录) | --- @@ -890,6 +908,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P0 已修复(2026-06-22):根重定向页 `/dashboard` 改用 `resolvePermissions()` + 权限点判断,不再 `role === "xxx"` 硬编码 - ✅ P0 已修复(2026-06-22):所有仪表盘组件接入 next-intl(`useTranslations` / `getTranslations`),翻译文件 `messages/{zh-CN,en}/dashboard.json` - ✅ P1 已修复(2026-06-22):业务逻辑(weekday 转换、作业统计、教师指标计算、问候语时段)抽取至 `lib/dashboard-utils.ts` 纯函数,与 UI 分离 +- ✅ P2 已修复(2026-06-22):新增 `components/dashboard-section.tsx`,每个独立数据区块用 Error Boundary + Suspense + 骨架屏包裹,单区块崩溃/加载不波及整页(5 种骨架变体:stats/card/chart/table/list) - ℹ️ V1 新增:`AdminDashboardData` 类型含 `userGrowth`/`homeworkTrend` 字段,`data-access.ts` 当前返回空数组占位,待后续接入真实统计 - ℹ️ parent 仪表盘组件仍位于 `modules/parent/components/parent-dashboard.tsx`,通过 `dashboard/actions.getParentDashboardAction` 调用(架构决策:保留在 parent 模块以避免移动文件破坏其他 import) @@ -900,6 +919,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `data-access.ts` | 49 | admin 仪表盘数据聚合(并行调用各模块 stats 函数) | | `lib/dashboard-utils.ts` | 170 | 纯逻辑工具函数(weekday / 统计 / 排序 / 指标计算 / 问候语) | | `types.ts` | 74 | Admin / Teacher / Student 类型定义 | +| `components/dashboard-section.tsx` | 165 | 分区 Error Boundary + Suspense + 骨架屏(5 种变体:stats/card/chart/table/list) | | `components/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图(i18n) | | `components/admin-dashboard/user-growth-chart.tsx` | 50 | recharts 折线图(i18n) | | `components/teacher-dashboard/*.tsx` | 9 文件 | 教师仪表盘组件(i18n) | @@ -1023,6 +1043,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ 已修复:管理端列表页传递 `classes` 数据给 `AdminAnnouncementsView` - ✅ 已修复:发布公告时(`publishAnnouncementAction` / `createAnnouncementAction` 直接发布 / `updateAnnouncementAction` 状态变为 published)触发通知模块 `sendBatchNotifications` - ✅ 已修复:新增 `loading.tsx` 骨架屏(用户端 + 管理端) +- ✅ 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 错误文案 + 重试按钮 +- ✅ P2 已修复:a11y 改进,`announcement-card.tsx` / `announcement-detail.tsx` 添加 `aria-label` **文件清单**: | 文件 | 行数 | 职责 | @@ -1134,10 +1157,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ MOBILE-P04:作业/成绩列表项 `min-h-[44px]` 触摸区域 **依赖关系**: -- 依赖:`shared/*`、`@/auth`、`classes`(合理)、`homework`(合理)、`grades`(合理)、`users`(合理)、`school`(合理)、`attendance`(v4 新增:考勤页复用 `StudentAttendanceView`) +- 依赖:`shared/*`、`@/auth`、`classes`(合理)、`homework`(合理)、`grades`(合理)、`users`(合理)、`school`(合理)、`attendance`(v4 新增:考勤页复用 `StudentAttendanceView`;⚠️ 跨模块 UI 类型依赖:3 个组件直接 import `@/modules/attendance/types`) - 被依赖:无 **已知问题**: +- ⚠️ P1:3 个 parent 组件(`parent-attendance-warning.tsx`/`parent-attendance-rate-card.tsx`/`parent-attendance-calendar.tsx`)直接 import `@/modules/attendance/types`,违反模块解耦原则(应通过 data-access 接口或 shared 类型抽象) +- ⚠️ P1:parent-attendance-calendar.tsx 重新定义 `STATUS_DOT`/`STATUS_LABEL` 常量,与 attendance 模块重复 +- ⚠️ P1:3 个 parent 组件的纯函数(`buildWarnings`/`aggregate`/`rateTone`/`formatDateKey`/`parseDateKey`/`buildCalendarDays`/`isSameDay`)未导出,无法单测 - ✅ P1 已修复:~~`app/(dashboard)/parent/children/[studentId]/page.tsx` 直接访问 DB(违反三层架构)~~ 改为调用 `verifyParentChildRelation` data-access 函数 - ✅ P1 已修复:~~权限校验未加 parentId 条件,存在信息泄露风险~~ `verifyParentChildRelation` 同时按 parentId + studentId 过滤 - ✅ P2 已修复:~~`getChildBasicInfo` 多次串行查询~~ 改为 `Promise.all` 并行化,并使用 `getStudentActiveClass` 一次 JOIN @@ -1187,16 +1213,25 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" **职责**:选修课程管理 + 学生选课 + 抽签。 **导出函数**: -- Actions:`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` +- Actions(11 个):`getElectiveCoursesAction` / `createElectiveCourseAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `getStudentSelectionsAction` / `selectCourseAction` / `dropCourseAction` / `runLotteryAction` / `getAvailableCoursesForStudentAction` / `openSelectionAction` / `closeSelectionAction` - Data-access:`getElectiveCourses` / `getElectiveCourseById` / `createElectiveCourse` / `updateElectiveCourse` / `deleteElectiveCourse` / `openSelection` / `closeSelection` / `buildCourseSelect` / `mapCourseRow` / `resolveCourseDisplayNames` / `CourseCoreRow`(P3 新增导出,供 data-access-selections 复用) -- Data-access-operations:`selectCourse` / `dropCourse` / `runLottery` +- Data-access-operations:`selectCourse` / `dropCourse` / `runLottery` / `buildLotteryRankCase`(⚠️ 未导出,无法单测) - Data-access-selections:`getCourseSelections` / `getStudentSelections` / `getStudentGradeId` / `getAvailableCoursesForStudent` +- Components:`ElectiveCourseList`(课程卡片网格 + 管理操作)/ `ElectiveCourseForm`(课程创建/编辑表单)/ `ElectiveFilters`(nuqs 筛选栏)/ `StudentSelectionView`(学生选课视图) **依赖关系**: - 依赖:`shared/*`、`@/auth`、`school`(✅ P3 已修复:通过 school data-access.getSubjectOptions/getGradeOptions 获取科目/年级名称,不再直查 subjects/grades 表)、`users`(✅ P3 已修复:通过 users data-access.getUserNamesByIds 获取教师姓名,不再直查 users 表)、`classes`(通过 classes data-access.getStudentActiveGradeId 获取学生年级) - 被依赖:无 -**已知问题**: +**已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`): +- ❌ P0:3 个读 Action 无调用方(`getElectiveCoursesAction`/`getStudentSelectionsAction`/`getAvailableCoursesForStudentAction`),页面绕过 Action 直接调用 data-access +- ❌ P0:update/delete/select/drop/lottery Action 缺资源归属校验(教师 A 可操作教师 B 的课程,学生可退选他人课程) +- ❌ P0:i18n 完全缺失(4 组标签/颜色常量硬编码英文,组件中硬编码中文) +- ❌ P0:错误边界完全缺失(3 个角色目录均无 `error.tsx`) +- ⚠️ P1:`elective-course-form.tsx` 存在 `v as "fcfs" | "lottery"` 类型断言 +- ⚠️ P1:`elective-course-list.tsx` 存在 `null as never` 类型逃逸 +- ⚠️ P1:`buildLotteryRankCase` 未导出,无法单测 +- ⚠️ P1:`SELECTION_MODE_LABELS` 已定义但表单未复用,硬编码 Select 选项 - ✅ P1 已修复:~~`buildCourseSelect` 跨模块 join users/subjects/grades 表~~ 改为只查 electiveCourses 表,通过 `resolveCourseDisplayNames` 调用 school/users data-access 获取显示名称 - ✅ P1 已修复:~~`getSubjectOptions` 本地直查 subjects 表且与 school 模块重复~~ 删除本地实现,改用 `school/data-access.getSubjectOptions` - ✅ P1 已修复:~~`selectCourse`/`dropCourse` 缺事务包裹~~ 改为 `db.transaction` 包裹,FCFS 模式下使用 `FOR UPDATE` 行锁防止并发超卖 @@ -1211,9 +1246,13 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `actions.ts` | 304 | 11 个 Server Action | | `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数(P3 重构:移除跨模块 join,通过 school/users data-access 获取显示名称) | | `data-access-operations.ts` | 245 | 选课操作(select/drop/lottery,P3 重构:事务包裹 + FOR UPDATE 锁 + Fisher-Yates 洗牌) | -| `data-access-selections.ts` | 189 | 选课记录查询 | +| `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程 | | `schema.ts` | 132 | Zod 校验 | -| `types.ts` | 108 | 类型定义 + 标签常量 | +| `types.ts` | 108 | 类型定义 + 4 组标签/颜色常量(硬编码英文) | +| `components/elective-course-list.tsx` | 233 | 课程卡片网格 + 管理操作 | +| `components/elective-course-form.tsx` | 293 | 课程创建/编辑表单 | +| `components/elective-filters.tsx` | 49 | nuqs 筛选栏(搜索 + 模式) | +| `components/student-selection-view.tsx` | 250 | 学生选课视图(已选 + 可选) | --- diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 6d15dba..b39eb08 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5822,6 +5822,29 @@ "purpose": "根据当前小时返回问候语时段 key" } ], + "components": [ + { + "name": "DashboardSection", + "path": "components/dashboard-section.tsx", + "purpose": "分区 Error Boundary + Suspense + 骨架屏包装器,包裹每个独立数据区块", + "variants": ["stats", "card", "chart", "table", "list"], + "usedBy": [ + "admin-dashboard.tsx", + "teacher-dashboard-view.tsx", + "student-dashboard-view.tsx" + ] + }, + { + "name": "DashboardSectionErrorBoundary", + "path": "components/dashboard-section.tsx", + "purpose": "仪表盘分区级 Error Boundary(class component),单区块崩溃仅替换该区块" + }, + { + "name": "DashboardSectionSkeleton", + "path": "components/dashboard-section.tsx", + "purpose": "分区骨架屏,5 种变体匹配不同数据区块布局" + } + ], "dataAccess": [ { "name": "getAdminDashboardData", @@ -9830,9 +9853,9 @@ "requirePermission", "data-access.getAttendanceRecords" ], - "usedBy": [ - "teacher/attendance/page.tsx", - "admin/attendance/page.tsx" + "usedBy": [], + "issues": [ + "P0: 无调用方——admin/teacher 页面绕过 Action 直接调用 data-access.getAttendanceRecords,违反三层架构" ] }, { @@ -9844,9 +9867,9 @@ "requirePermission", "data-access-stats.getStudentAttendanceSummary" ], - "usedBy": [ - "student/attendance/page.tsx", - "parent/attendance/page.tsx" + "usedBy": [], + "issues": [ + "P0: 无调用方——student/parent 页面绕过 Action 直接调用 data-access-stats.getStudentAttendanceSummary" ] }, { @@ -9858,8 +9881,9 @@ "requirePermission", "data-access-stats.getClassAttendanceStats" ], - "usedBy": [ - "teacher/attendance/stats/page.tsx" + "usedBy": [], + "issues": [ + "P0: 无调用方——teacher/attendance/stats 页面绕过 Action 直接调用 data-access-stats.getClassAttendanceStats" ] }, { @@ -9994,6 +10018,9 @@ ], "usedBy": [ "attendance-sheet.tsx" + ], + "issues": [ + "P0: 跨模块直查 classEnrollments 表,违反模块间只能通过对方 data-access 通信的规则(应改为调用 classes data-access)" ] }, { @@ -10061,6 +10088,9 @@ ], "usedBy": [ "admin/attendance/page.tsx" + ], + "issues": [ + "P0: 统计失真——调用 getAttendanceRecords(默认 pageSize=20)后对 items 聚合,仅基于前 20 条记录计算总览数据,班级/状态/日期筛选后仍只统计前 20 条" ] } ], @@ -11643,9 +11673,9 @@ "requirePermission(ELECTIVE_READ)", "data-access.getElectiveCourses (scope, currentUserId)" ], - "usedBy": [ - "admin/elective/page.tsx", - "teacher/elective/page.tsx" + "usedBy": [], + "issues": [ + "P0: 无调用方——admin/teacher 页面绕过 Action 直接调用 data-access.getElectiveCourses,违反三层架构" ] }, { @@ -11658,8 +11688,9 @@ "requirePermission(ELECTIVE_READ)", "data-access-selections.getStudentSelections" ], - "usedBy": [ - "待扩展" + "usedBy": [], + "issues": [ + "P0: 无调用方——页面绕过 Action 直接调用 data-access-selections.getStudentSelections" ] }, { @@ -11672,8 +11703,9 @@ "requirePermission(ELECTIVE_SELECT)", "data-access-selections.getAvailableCoursesForStudent" ], - "usedBy": [ - "待扩展" + "usedBy": [], + "issues": [ + "P0: 无调用方——student/elective 页面绕过 Action 直接调用 data-access-selections.getAvailableCoursesForStudent" ] } ], @@ -12763,8 +12795,8 @@ } ], "messages": [ - "src/shared/i18n/messages/zh-CN/{common,auth,onboarding,classes,errors}.json", - "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors}.json" + "src/shared/i18n/messages/zh-CN/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages}.json", + "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages}.json" ] }, "routes": {}, @@ -13214,7 +13246,8 @@ ], "attendance": [ "data-access-stats.getStudentAttendanceSummary", - "components.student-attendance-view" + "components.student-attendance-view", + "types.StudentAttendanceSummary (⚠️ 跨模块 UI 类型依赖:parent-attendance-warning.tsx / parent-attendance-rate-card.tsx / parent-attendance-calendar.tsx 直接 import)" ] } }, diff --git a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx index 2bbf475..e78afa3 100644 --- a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx +++ b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx @@ -26,6 +26,7 @@ import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { formatDate } from "@/shared/lib/utils" +import { DashboardSection } from "../dashboard-section" import { UserGrowthChart } from "./user-growth-chart" export async function AdminDashboardView({ data }: { data: AdminDashboardData }) { @@ -62,12 +63,14 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData }) } /> -
- - - - -
+ +
+ + + + +
+
{/* 快捷操作 */}
@@ -111,108 +114,120 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData }) {/* 趋势图表 */}
- - - {t("sections.userGrowthTrend")} - - - - - - - - {t("sections.homeworkSubmissionTrend")} - - - - - + + + + {t("sections.userGrowthTrend")} + + + + + + + + + + {t("sections.homeworkSubmissionTrend")} + + + + + +
- - - {t("sections.userRoles")} - - - {data.userRoleCounts.length === 0 ? ( - - ) : ( - data.userRoleCounts.map((r) => ( -
- {r.role} -
{r.count}
-
- )) - )} -
-
+ + + + {t("sections.userRoles")} + + + {data.userRoleCounts.length === 0 ? ( + + ) : ( + data.userRoleCounts.map((r) => ( +
+ {r.role} +
{r.count}
+
+ )) + )} +
+
+
- - - {t("sections.content")} - - - } /> - } /> - } /> - } /> - - + + + + {t("sections.content")} + + + } /> + } /> + } /> + } /> + + + - - - {t("sections.homeworkActivity")} - - - } /> - } /> - } /> - - + + + + {t("sections.homeworkActivity")} + + + } /> + } /> + } /> + + +
- - - {t("sections.recentUsers")} - - - {data.recentUsers.length === 0 ? ( - - ) : ( - - - - {t("table.name")} - {t("table.email")} - {t("table.role")} - {t("table.created")} - - - - {data.recentUsers.map((u) => ( - - {u.name || "-"} - {u.email} - - {u.role ?? "unknown"} - - {formatDate(u.createdAt)} + + + + {t("sections.recentUsers")} + + + {data.recentUsers.length === 0 ? ( + + ) : ( +
+ + + {t("table.name")} + {t("table.email")} + {t("table.role")} + {t("table.created")} - ))} - -
- )} -
- -
-
-
+ + + {data.recentUsers.map((u) => ( + + {u.name || "-"} + {u.email} + + {u.role ?? "unknown"} + + {formatDate(u.createdAt)} + + ))} + + + )} +
+ +
+ + +
) } diff --git a/src/modules/dashboard/components/dashboard-section.tsx b/src/modules/dashboard/components/dashboard-section.tsx new file mode 100644 index 0000000..13b32f9 --- /dev/null +++ b/src/modules/dashboard/components/dashboard-section.tsx @@ -0,0 +1,170 @@ +"use client" + +import { Component, type ReactNode, Suspense } from "react" +import { AlertCircle } from "lucide-react" + +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" +import { Skeleton } from "@/shared/components/ui/skeleton" +import { useTranslations } from "next-intl" + +/** + * 仪表盘分区 Error Boundary + * + * 包裹每个独立数据区块,避免单个区块崩溃导致整页不可用。 + * 与路由级 error.tsx 不同,此组件仅替换出错区块,其余区块继续渲染。 + */ +export class DashboardSectionErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean } +> { + state: { hasError: boolean } = { hasError: false } + + static getDerivedStateFromError(): { hasError: boolean } { + return { hasError: true } + } + + handleRetry = (): void => { + this.setState({ hasError: false }) + } + + render(): ReactNode { + if (this.state.hasError) { + return + } + return this.props.children + } +} + +function DashboardSectionErrorFallback({ + onRetry, +}: { + onRetry: () => void +}): ReactNode { + const t = useTranslations("dashboard.error") + return ( + + ) +} + +/** + * 分区骨架屏变体 + * + * 不同数据区块使用不同骨架布局,提供更贴近真实内容的加载占位。 + */ +type SkeletonVariant = "stats" | "card" | "chart" | "table" | "list" + +export function DashboardSectionSkeleton({ + variant = "card", +}: { + variant?: SkeletonVariant +}): ReactNode { + switch (variant) { + case "stats": + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ ) + case "chart": + return ( + + + + + + + + + ) + case "table": + return ( + + + + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + + ) + case "list": + return ( + + + + + + {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) + case "card": + default: + return ( + + + + + + + + + + + ) + } +} + +/** + * 仪表盘分区包装器 + * + * 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。 + * 单个区块出错或加载中时,仅影响该区块,不波及整页。 + * + * @example + * + * + * + */ +export function DashboardSection({ + children, + variant = "card", +}: { + children: ReactNode + variant?: SkeletonVariant +}): ReactNode { + return ( + + }> + {children} + + + ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx index 4d05d06..6141e20 100644 --- a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx @@ -1,5 +1,6 @@ import type { StudentDashboardProps } from "@/modules/dashboard/types" +import { DashboardSection } from "../dashboard-section" import { StudentDashboardHeader } from "./student-dashboard-header" import { StudentGradesCard } from "./student-grades-card" import { StudentStatsGrid } from "./student-stats-grid" @@ -20,21 +21,29 @@ export async function StudentDashboard({
- + + +
- - + + + + + +
- + + +
diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index 0f91222..9fe5192 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -3,6 +3,7 @@ import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard- import { getTranslations } from "next-intl/server" import type { TeacherTodoItem } from "./teacher-todo-card" +import { DashboardSection } from "../dashboard-section" import { TeacherClassesCard } from "./teacher-classes-card" import { TeacherDashboardHeader } from "./teacher-dashboard-header" import { TeacherHomeworkCard } from "./teacher-homework-card" @@ -46,35 +47,51 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
- + + +
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
- + + +
- - - + + + + + + + + +
- + + +
- - + + + + + +
diff --git a/src/shared/i18n/messages/en/dashboard.json b/src/shared/i18n/messages/en/dashboard.json index 70bcf26..61c3e5a 100644 --- a/src/shared/i18n/messages/en/dashboard.json +++ b/src/shared/i18n/messages/en/dashboard.json @@ -115,7 +115,9 @@ "error": { "loadFailed": "Page load failed", "loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.", - "retry": "Retry" + "retry": "Retry", + "sectionLoadFailed": "Section load failed", + "sectionLoadFailedDesc": "An error occurred while loading this section. Please retry." }, "chart": { "newUsers": "New users", diff --git a/src/shared/i18n/messages/zh-CN/dashboard.json b/src/shared/i18n/messages/zh-CN/dashboard.json index 7c83135..11b242e 100644 --- a/src/shared/i18n/messages/zh-CN/dashboard.json +++ b/src/shared/i18n/messages/zh-CN/dashboard.json @@ -115,7 +115,9 @@ "error": { "loadFailed": "页面加载失败", "loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。", - "retry": "重试" + "retry": "重试", + "sectionLoadFailed": "区块加载失败", + "sectionLoadFailedDesc": "该数据区块加载时出错,请重试。" }, "chart": { "newUsers": "新增用户",