feat(dashboard): 新增分区 Error Boundary + Suspense 骨架屏(P2)
新增 components/dashboard-section.tsx,包含: - DashboardSectionErrorBoundary:分区级 Error Boundary,单区块崩溃仅替换该区块不波及整页 - DashboardSectionSkeleton:5 种骨架变体(stats/card/chart/table/list),匹配不同数据区块布局 - DashboardSection:组合 Error Boundary + Suspense + 骨架屏的包装器 将 admin/teacher/student 三个仪表盘视图的每个独立数据区块用 DashboardSection 包裹,i18n 补充 sectionLoadFailed/sectionLoadFailedDesc 翻译键,同步更新架构图 004/005 文档
This commit is contained in:
@@ -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<AttendanceStatus, number>` 类型断言
|
||||
- ⚠️ 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 | 学生选课视图(已选 + 可选) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||
</div>
|
||||
<DashboardSection variant="stats">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||
</div>
|
||||
</DashboardSection>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -111,108 +114,120 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
|
||||
|
||||
{/* 趋势图表 */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.userGrowth} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.homeworkTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardSection variant="chart">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.userGrowth} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="chart">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.homeworkTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.userRoles")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.userRoleCounts.length === 0 ? (
|
||||
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
|
||||
) : (
|
||||
data.userRoleCounts.map((r) => (
|
||||
<div key={r.role} className="flex items-center justify-between">
|
||||
<Badge variant="secondary">{r.role}</Badge>
|
||||
<div className="text-sm font-medium tabular-nums">{r.count}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardSection variant="card">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.userRoles")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{data.userRoleCounts.length === 0 ? (
|
||||
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
|
||||
) : (
|
||||
data.userRoleCounts.map((r) => (
|
||||
<div key={r.role} className="flex items-center justify-between">
|
||||
<Badge variant="secondary">{r.role}</Badge>
|
||||
<div className="text-sm font-medium tabular-nums">{r.count}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.content")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardSection variant="card">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.content")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DashboardSection variant="card">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
|
||||
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.recentUsers")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.recentUsers.length === 0 ? (
|
||||
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("table.name")}</TableHead>
|
||||
<TableHead>{t("table.email")}</TableHead>
|
||||
<TableHead>{t("table.role")}</TableHead>
|
||||
<TableHead>{t("table.created")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.recentUsers.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name || "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
|
||||
<DashboardSection variant="table">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sections.recentUsers")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.recentUsers.length === 0 ? (
|
||||
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("table.name")}</TableHead>
|
||||
<TableHead>{t("table.email")}</TableHead>
|
||||
<TableHead>{t("table.role")}</TableHead>
|
||||
<TableHead>{t("table.created")}</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/users">
|
||||
{t("sections.viewAllUsers")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.recentUsers.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell className="font-medium">{u.name || "-"}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role ?? "unknown"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/admin/users">
|
||||
{t("sections.viewAllUsers")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
170
src/modules/dashboard/components/dashboard-section.tsx
Normal file
170
src/modules/dashboard/components/dashboard-section.tsx
Normal file
@@ -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 <DashboardSectionErrorFallback onRetry={this.handleRetry} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DashboardSectionErrorFallback({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: () => void
|
||||
}): ReactNode {
|
||||
const t = useTranslations("dashboard.error")
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("sectionLoadFailed")}
|
||||
description={t("sectionLoadFailedDesc")}
|
||||
action={{ label: t("retry"), onClick: onRetry }}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分区骨架屏变体
|
||||
*
|
||||
* 不同数据区块使用不同骨架布局,提供更贴近真实内容的加载占位。
|
||||
*/
|
||||
type SkeletonVariant = "stats" | "card" | "chart" | "table" | "list"
|
||||
|
||||
export function DashboardSectionSkeleton({
|
||||
variant = "card",
|
||||
}: {
|
||||
variant?: SkeletonVariant
|
||||
}): ReactNode {
|
||||
switch (variant) {
|
||||
case "stats":
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case "chart":
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-[200px] w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "table":
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "list":
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
case "card":
|
||||
default:
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘分区包装器
|
||||
*
|
||||
* 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。
|
||||
* 单个区块出错或加载中时,仅影响该区块,不波及整页。
|
||||
*
|
||||
* @example
|
||||
* <DashboardSection variant="stats">
|
||||
* <TeacherStats ... />
|
||||
* </DashboardSection>
|
||||
*/
|
||||
export function DashboardSection({
|
||||
children,
|
||||
variant = "card",
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: SkeletonVariant
|
||||
}): ReactNode {
|
||||
return (
|
||||
<DashboardSectionErrorBoundary>
|
||||
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</DashboardSectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
<div className="space-y-6">
|
||||
<StudentDashboardHeader studentName={studentName} />
|
||||
|
||||
<StudentStatsGrid
|
||||
enrolledClassCount={enrolledClassCount}
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
ranking={grades.ranking}
|
||||
/>
|
||||
<DashboardSection variant="stats">
|
||||
<StudentStatsGrid
|
||||
enrolledClassCount={enrolledClassCount}
|
||||
dueSoonCount={dueSoonCount}
|
||||
overdueCount={overdueCount}
|
||||
gradedCount={gradedCount}
|
||||
ranking={grades.ranking}
|
||||
/>
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||
<StudentGradesCard grades={grades} />
|
||||
<DashboardSection variant="list">
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="card">
|
||||
<StudentGradesCard grades={grades} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
<DashboardSection variant="card">
|
||||
<StudentTodayScheduleCard items={todayScheduleItems} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
|
||||
<TeacherStats
|
||||
toGradeCount={metrics.toGradeCount}
|
||||
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||
averageScore={metrics.averageScore}
|
||||
submissionRate={metrics.submissionRate}
|
||||
/>
|
||||
<DashboardSection variant="stats">
|
||||
<TeacherStats
|
||||
toGradeCount={metrics.toGradeCount}
|
||||
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||
averageScore={metrics.averageScore}
|
||||
submissionRate={metrics.submissionRate}
|
||||
/>
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-8">
|
||||
<div className="lg:hidden">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
<RecentSubmissions
|
||||
submissions={metrics.submissionsToGrade}
|
||||
title={t("sections.pendingGrading")}
|
||||
emptyTitle={t("empty.allGraded")}
|
||||
emptyDescription={t("empty.allGradedDesc")}
|
||||
/>
|
||||
<DashboardSection variant="card">
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="chart">
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="list">
|
||||
<RecentSubmissions
|
||||
submissions={metrics.submissionsToGrade}
|
||||
title={t("sections.pendingGrading")}
|
||||
emptyTitle={t("empty.allGraded")}
|
||||
emptyDescription={t("empty.allGradedDesc")}
|
||||
/>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:col-span-4">
|
||||
<div className="hidden lg:block">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
<DashboardSection variant="card">
|
||||
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
<DashboardSection variant="list">
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="list">
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
"error": {
|
||||
"loadFailed": "页面加载失败",
|
||||
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
|
||||
"retry": "重试"
|
||||
"retry": "重试",
|
||||
"sectionLoadFailed": "区块加载失败",
|
||||
"sectionLoadFailedDesc": "该数据区块加载时出错,请重试。"
|
||||
},
|
||||
"chart": {
|
||||
"newUsers": "新增用户",
|
||||
|
||||
Reference in New Issue
Block a user