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:
SpecialX
2026-06-22 15:58:49 +08:00
parent 868ac5f9cf
commit 21c1e7a286
8 changed files with 454 additions and 167 deletions

View File

@@ -799,19 +799,29 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
## 2.10 attendance考勤模块— 结构典范 ## 2.10 attendance考勤模块— 结构典范
**职责**:考勤记录管理 + 统计分析。 **职责**:考勤记录管理 + 统计分析 + 规则配置
**导出函数** **导出函数**
- Actions`getAttendanceRecordsAction` / `createAttendanceRecordAction` / `updateAttendanceRecordAction` / `deleteAttendanceRecordAction` / `getStudentAttendanceAction` / `getAttendanceStatsAction` - Actions10 个):`recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction`
- Data-access`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合) - Data-access`getAttendanceRecords` / `createAttendanceRecord` / `updateAttendanceRecord` / `deleteAttendanceRecord` / `getClassStudentsForAttendance` / `getAttendanceStats`(管理员考勤总览页统计概览,基于 `getAttendanceRecords` 聚合)/ `upsertAttendanceRules` / `getAttendanceRules`
- Components`AttendanceStatsCards`(管理员考勤总览页 6 卡片统计概览 - 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`
**已知问题** **已知问题**(详见 `docs/architecture/audit/attendance-elective-audit-report.md`
- P1-1 已修复:~~`getClassStudentsForAttendance` 直查 `classEnrollments`~~ 改为通过 classes data-access 获取 - P0`getAttendanceStats` 统计失真——调用 `getAttendanceRecords`(默认 pageSize=20后对 `items` 聚合,仅基于前 20 条记录计算总览数据
- ❌ P0`getClassStudentsForAttendance` 仍直查 `classEnrollments` 表(架构图此前声称已修复,实际未修复)
- ❌ P06 个读 Action 无调用方(页面绕过 Action 直接调用 data-access违反三层架构
- ❌ P0update/delete Action 缺资源归属校验(教师 A 可修改/删除教师 B 的记录)
- ❌ P0i18n 完全缺失(`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`(拆分范例) - ✅ stats 独立拆分为 `data-access-stats.ts`(拆分范例)
- ✅ DataScope 完整接入 6 种 scope 类型 - ✅ DataScope 完整接入 6 种 scope 类型
- ✅ actions 层无直接 DB 访问 - ✅ actions 层无直接 DB 访问
@@ -819,11 +829,19 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
**文件清单** **文件清单**
| 文件 | 行数 | 职责 | | 文件 | 行数 | 职责 |
|------|------|------| |------|------|------|
| `actions.ts` | 271 | 6 个 Server Action | | `actions.ts` | 271 | 10 个 Server Action含权限校验、Zod 校验) |
| `data-access.ts` | 309 | 考勤 CRUD + 管理员统计概览 | | `data-access.ts` | 309 | 考勤 CRUD + 班级学生查询 + 规则 upsert + 总览统计 |
| `data-access-stats.ts` | 145 | 统计逻辑(拆分范例 | | `data-access-stats.ts` | 145 | 学生/班级考勤汇总(拆分范例,`computeStats` 未导出 |
| `schema.ts` | - | Zod 校验 | | `schema.ts` | 43 | Zod 校验5 个 schema |
| `types.ts` | - | 类型定义 | | `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根重定向页 `/dashboard` 改用 `resolvePermissions()` + 权限点判断,不再 `role === "xxx"` 硬编码
- ✅ P0 已修复2026-06-22所有仪表盘组件接入 next-intl`useTranslations` / `getTranslations`),翻译文件 `messages/{zh-CN,en}/dashboard.json` - ✅ P0 已修复2026-06-22所有仪表盘组件接入 next-intl`useTranslations` / `getTranslations`),翻译文件 `messages/{zh-CN,en}/dashboard.json`
- ✅ P1 已修复2026-06-22业务逻辑weekday 转换、作业统计、教师指标计算、问候语时段)抽取至 `lib/dashboard-utils.ts` 纯函数,与 UI 分离 - ✅ 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` 当前返回空数组占位,待后续接入真实统计 - 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
@@ -900,6 +919,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| `data-access.ts` | 49 | admin 仪表盘数据聚合(并行调用各模块 stats 函数) | | `data-access.ts` | 49 | admin 仪表盘数据聚合(并行调用各模块 stats 函数) |
| `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/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图i18n | | `components/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图i18n |
| `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 |
@@ -1023,6 +1043,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
- ✅ 已修复:管理端列表页传递 `classes` 数据给 `AdminAnnouncementsView` - ✅ 已修复:管理端列表页传递 `classes` 数据给 `AdminAnnouncementsView`
- ✅ 已修复:发布公告时(`publishAnnouncementAction` / `createAnnouncementAction` 直接发布 / `updateAnnouncementAction` 状态变为 published触发通知模块 `sendBatchNotifications` - ✅ 已修复:发布公告时(`publishAnnouncementAction` / `createAnnouncementAction` 直接发布 / `updateAnnouncementAction` 状态变为 published触发通知模块 `sendBatchNotifications`
- ✅ 已修复:新增 `loading.tsx` 骨架屏(用户端 + 管理端) - ✅ 已修复:新增 `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]` 触摸区域 - ✅ 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`
- 被依赖:无 - 被依赖:无
**已知问题** **已知问题**
- ⚠️ P13 个 parent 组件(`parent-attendance-warning.tsx`/`parent-attendance-rate-card.tsx`/`parent-attendance-calendar.tsx`)直接 import `@/modules/attendance/types`,违反模块解耦原则(应通过 data-access 接口或 shared 类型抽象)
- ⚠️ P1parent-attendance-calendar.tsx 重新定义 `STATUS_DOT`/`STATUS_LABEL` 常量,与 attendance 模块重复
- ⚠️ P13 个 parent 组件的纯函数(`buildWarnings`/`aggregate`/`rateTone`/`formatDateKey`/`parseDateKey`/`buildCalendarDays`/`isSameDay`)未导出,无法单测
- ✅ P1 已修复:~~`app/(dashboard)/parent/children/[studentId]/page.tsx` 直接访问 DB违反三层架构~~ 改为调用 `verifyParentChildRelation` data-access 函数 - ✅ P1 已修复:~~`app/(dashboard)/parent/children/[studentId]/page.tsx` 直接访问 DB违反三层架构~~ 改为调用 `verifyParentChildRelation` data-access 函数
- ✅ P1 已修复:~~权限校验未加 parentId 条件,存在信息泄露风险~~ `verifyParentChildRelation` 同时按 parentId + studentId 过滤 - ✅ P1 已修复:~~权限校验未加 parentId 条件,存在信息泄露风险~~ `verifyParentChildRelation` 同时按 parentId + studentId 过滤
- ✅ P2 已修复:~~`getChildBasicInfo` 多次串行查询~~ 改为 `Promise.all` 并行化,并使用 `getStudentActiveClass` 一次 JOIN - ✅ 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` - Actions11 个)`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`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` - 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 获取学生年级) - 依赖:`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`
- ❌ P03 个读 Action 无调用方(`getElectiveCoursesAction`/`getStudentSelectionsAction`/`getAvailableCoursesForStudentAction`),页面绕过 Action 直接调用 data-access
- ❌ P0update/delete/select/drop/lottery Action 缺资源归属校验(教师 A 可操作教师 B 的课程,学生可退选他人课程)
- ❌ P0i18n 完全缺失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 已修复:~~`buildCourseSelect` 跨模块 join users/subjects/grades 表~~ 改为只查 electiveCourses 表,通过 `resolveCourseDisplayNames` 调用 school/users data-access 获取显示名称
- ✅ P1 已修复:~~`getSubjectOptions` 本地直查 subjects 表且与 school 模块重复~~ 删除本地实现,改用 `school/data-access.getSubjectOptions` - ✅ P1 已修复:~~`getSubjectOptions` 本地直查 subjects 表且与 school 模块重复~~ 删除本地实现,改用 `school/data-access.getSubjectOptions`
- ✅ P1 已修复:~~`selectCourse`/`dropCourse` 缺事务包裹~~ 改为 `db.transaction` 包裹FCFS 模式下使用 `FOR UPDATE` 行锁防止并发超卖 - ✅ 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 | | `actions.ts` | 304 | 11 个 Server Action |
| `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数P3 重构:移除跨模块 join通过 school/users data-access 获取显示名称) | | `data-access.ts` | 250 | 课程 CRUD + scope 过滤 + 共享映射函数P3 重构:移除跨模块 join通过 school/users data-access 获取显示名称) |
| `data-access-operations.ts` | 245 | 选课操作select/drop/lotteryP3 重构:事务包裹 + FOR UPDATE 锁 + Fisher-Yates 洗牌) | | `data-access-operations.ts` | 245 | 选课操作select/drop/lotteryP3 重构:事务包裹 + FOR UPDATE 锁 + Fisher-Yates 洗牌) |
| `data-access-selections.ts` | 189 | 选课记录查询 | | `data-access-selections.ts` | 149 | 选课记录查询 + 学生可选课程 |
| `schema.ts` | 132 | Zod 校验 | | `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 | 学生选课视图(已选 + 可选) |
--- ---

View File

@@ -5822,6 +5822,29 @@
"purpose": "根据当前小时返回问候语时段 key" "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 Boundaryclass component单区块崩溃仅替换该区块"
},
{
"name": "DashboardSectionSkeleton",
"path": "components/dashboard-section.tsx",
"purpose": "分区骨架屏5 种变体匹配不同数据区块布局"
}
],
"dataAccess": [ "dataAccess": [
{ {
"name": "getAdminDashboardData", "name": "getAdminDashboardData",
@@ -9830,9 +9853,9 @@
"requirePermission", "requirePermission",
"data-access.getAttendanceRecords" "data-access.getAttendanceRecords"
], ],
"usedBy": [ "usedBy": [],
"teacher/attendance/page.tsx", "issues": [
"admin/attendance/page.tsx" "P0: 无调用方——admin/teacher 页面绕过 Action 直接调用 data-access.getAttendanceRecords违反三层架构"
] ]
}, },
{ {
@@ -9844,9 +9867,9 @@
"requirePermission", "requirePermission",
"data-access-stats.getStudentAttendanceSummary" "data-access-stats.getStudentAttendanceSummary"
], ],
"usedBy": [ "usedBy": [],
"student/attendance/page.tsx", "issues": [
"parent/attendance/page.tsx" "P0: 无调用方——student/parent 页面绕过 Action 直接调用 data-access-stats.getStudentAttendanceSummary"
] ]
}, },
{ {
@@ -9858,8 +9881,9 @@
"requirePermission", "requirePermission",
"data-access-stats.getClassAttendanceStats" "data-access-stats.getClassAttendanceStats"
], ],
"usedBy": [ "usedBy": [],
"teacher/attendance/stats/page.tsx" "issues": [
"P0: 无调用方——teacher/attendance/stats 页面绕过 Action 直接调用 data-access-stats.getClassAttendanceStats"
] ]
}, },
{ {
@@ -9994,6 +10018,9 @@
], ],
"usedBy": [ "usedBy": [
"attendance-sheet.tsx" "attendance-sheet.tsx"
],
"issues": [
"P0: 跨模块直查 classEnrollments 表,违反模块间只能通过对方 data-access 通信的规则(应改为调用 classes data-access"
] ]
}, },
{ {
@@ -10061,6 +10088,9 @@
], ],
"usedBy": [ "usedBy": [
"admin/attendance/page.tsx" "admin/attendance/page.tsx"
],
"issues": [
"P0: 统计失真——调用 getAttendanceRecords默认 pageSize=20后对 items 聚合,仅基于前 20 条记录计算总览数据,班级/状态/日期筛选后仍只统计前 20 条"
] ]
} }
], ],
@@ -11643,9 +11673,9 @@
"requirePermission(ELECTIVE_READ)", "requirePermission(ELECTIVE_READ)",
"data-access.getElectiveCourses (scope, currentUserId)" "data-access.getElectiveCourses (scope, currentUserId)"
], ],
"usedBy": [ "usedBy": [],
"admin/elective/page.tsx", "issues": [
"teacher/elective/page.tsx" "P0: 无调用方——admin/teacher 页面绕过 Action 直接调用 data-access.getElectiveCourses违反三层架构"
] ]
}, },
{ {
@@ -11658,8 +11688,9 @@
"requirePermission(ELECTIVE_READ)", "requirePermission(ELECTIVE_READ)",
"data-access-selections.getStudentSelections" "data-access-selections.getStudentSelections"
], ],
"usedBy": [ "usedBy": [],
"待扩展" "issues": [
"P0: 无调用方——页面绕过 Action 直接调用 data-access-selections.getStudentSelections"
] ]
}, },
{ {
@@ -11672,8 +11703,9 @@
"requirePermission(ELECTIVE_SELECT)", "requirePermission(ELECTIVE_SELECT)",
"data-access-selections.getAvailableCoursesForStudent" "data-access-selections.getAvailableCoursesForStudent"
], ],
"usedBy": [ "usedBy": [],
"待扩展" "issues": [
"P0: 无调用方——student/elective 页面绕过 Action 直接调用 data-access-selections.getAvailableCoursesForStudent"
] ]
} }
], ],
@@ -12763,8 +12795,8 @@
} }
], ],
"messages": [ "messages": [
"src/shared/i18n/messages/zh-CN/{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}.json" "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages}.json"
] ]
}, },
"routes": {}, "routes": {},
@@ -13214,7 +13246,8 @@
], ],
"attendance": [ "attendance": [
"data-access-stats.getStudentAttendanceSummary", "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)"
] ]
} }
}, },

View File

@@ -26,6 +26,7 @@ import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { DashboardSection } from "../dashboard-section"
import { UserGrowthChart } from "./user-growth-chart" import { UserGrowthChart } from "./user-growth-chart"
export async function AdminDashboardView({ data }: { data: AdminDashboardData }) { 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"> <DashboardSection variant="stats">
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" /> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" /> <StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" /> <StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" /> <StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
</div> <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"> <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"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <DashboardSection variant="chart">
<CardHeader> <Card>
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
<CardContent> </CardHeader>
<UserGrowthChart data={data.userGrowth} /> <CardContent>
</CardContent> <UserGrowthChart data={data.userGrowth} />
</Card> </CardContent>
<Card> </Card>
<CardHeader> </DashboardSection>
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle> <DashboardSection variant="chart">
</CardHeader> <Card>
<CardContent> <CardHeader>
<UserGrowthChart data={data.homeworkTrend} /> <CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
</CardContent> </CardHeader>
</Card> <CardContent>
<UserGrowthChart data={data.homeworkTrend} />
</CardContent>
</Card>
</DashboardSection>
</div> </div>
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1"> <DashboardSection variant="card">
<CardHeader> <Card className="lg:col-span-1">
<CardTitle>{t("sections.userRoles")}</CardTitle> <CardHeader>
</CardHeader> <CardTitle>{t("sections.userRoles")}</CardTitle>
<CardContent className="space-y-3"> </CardHeader>
{data.userRoleCounts.length === 0 ? ( <CardContent className="space-y-3">
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} /> {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"> data.userRoleCounts.map((r) => (
<Badge variant="secondary">{r.role}</Badge> <div key={r.role} className="flex items-center justify-between">
<div className="text-sm font-medium tabular-nums">{r.count}</div> <Badge variant="secondary">{r.role}</Badge>
</div> <div className="text-sm font-medium tabular-nums">{r.count}</div>
)) </div>
)} ))
</CardContent> )}
</Card> </CardContent>
</Card>
</DashboardSection>
<Card className="lg:col-span-1"> <DashboardSection variant="card">
<CardHeader> <Card className="lg:col-span-1">
<CardTitle>{t("sections.content")}</CardTitle> <CardHeader>
</CardHeader> <CardTitle>{t("sections.content")}</CardTitle>
<CardContent className="grid gap-3"> </CardHeader>
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} /> <CardContent className="grid gap-3">
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library 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.classes")} value={data.chapterCount} icon={<BookOpen 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" />} /> <ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
</CardContent> <ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</Card> </CardContent>
</Card>
</DashboardSection>
<Card className="lg:col-span-1"> <DashboardSection variant="card">
<CardHeader> <Card className="lg:col-span-1">
<CardTitle>{t("sections.homeworkActivity")}</CardTitle> <CardHeader>
</CardHeader> <CardTitle>{t("sections.homeworkActivity")}</CardTitle>
<CardContent className="grid gap-3"> </CardHeader>
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} /> <CardContent className="grid gap-3">
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} /> <ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList 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" />} /> <ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
</CardContent> <ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</Card> </CardContent>
</Card>
</DashboardSection>
</div> </div>
<Card> <DashboardSection variant="table">
<CardHeader> <Card>
<CardTitle>{t("sections.recentUsers")}</CardTitle> <CardHeader>
</CardHeader> <CardTitle>{t("sections.recentUsers")}</CardTitle>
<CardContent> </CardHeader>
{data.recentUsers.length === 0 ? ( <CardContent>
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} /> {data.recentUsers.length === 0 ? (
) : ( <EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
<Table> ) : (
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead>{t("table.name")}</TableHead> <TableRow>
<TableHead>{t("table.email")}</TableHead> <TableHead>{t("table.name")}</TableHead>
<TableHead>{t("table.role")}</TableHead> <TableHead>{t("table.email")}</TableHead>
<TableHead>{t("table.created")}</TableHead> <TableHead>{t("table.role")}</TableHead>
</TableRow> <TableHead>{t("table.created")}</TableHead>
</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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {data.recentUsers.map((u) => (
)} <TableRow key={u.id}>
<div className="flex justify-end pt-4"> <TableCell className="font-medium">{u.name || "-"}</TableCell>
<Button asChild variant="ghost" size="sm"> <TableCell className="text-muted-foreground">{u.email}</TableCell>
<Link href="/admin/users"> <TableCell>
{t("sections.viewAllUsers")} <Badge variant="secondary">{u.role ?? "unknown"}</Badge>
<ChevronRight className="ml-1 h-4 w-4" /> </TableCell>
</Link> <TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
</Button> </TableRow>
</div> ))}
</CardContent> </TableBody>
</Card> </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> </div>
) )
} }

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

View File

@@ -1,5 +1,6 @@
import type { StudentDashboardProps } from "@/modules/dashboard/types" import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { DashboardSection } from "../dashboard-section"
import { StudentDashboardHeader } from "./student-dashboard-header" import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card" import { StudentGradesCard } from "./student-grades-card"
import { StudentStatsGrid } from "./student-stats-grid" import { StudentStatsGrid } from "./student-stats-grid"
@@ -20,21 +21,29 @@ export async function StudentDashboard({
<div className="space-y-6"> <div className="space-y-6">
<StudentDashboardHeader studentName={studentName} /> <StudentDashboardHeader studentName={studentName} />
<StudentStatsGrid <DashboardSection variant="stats">
enrolledClassCount={enrolledClassCount} <StudentStatsGrid
dueSoonCount={dueSoonCount} enrolledClassCount={enrolledClassCount}
overdueCount={overdueCount} dueSoonCount={dueSoonCount}
gradedCount={gradedCount} overdueCount={overdueCount}
ranking={grades.ranking} gradedCount={gradedCount}
/> ranking={grades.ranking}
/>
</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"> <div className="lg:col-span-2 space-y-6">
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} /> <DashboardSection variant="list">
<StudentGradesCard grades={grades} /> <StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</DashboardSection>
<DashboardSection variant="card">
<StudentGradesCard grades={grades} />
</DashboardSection>
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<StudentTodayScheduleCard items={todayScheduleItems} /> <DashboardSection variant="card">
<StudentTodayScheduleCard items={todayScheduleItems} />
</DashboardSection>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import type { TeacherTodoItem } from "./teacher-todo-card" import type { TeacherTodoItem } from "./teacher-todo-card"
import { DashboardSection } from "../dashboard-section"
import { TeacherClassesCard } from "./teacher-classes-card" import { TeacherClassesCard } from "./teacher-classes-card"
import { TeacherDashboardHeader } from "./teacher-dashboard-header" import { TeacherDashboardHeader } from "./teacher-dashboard-header"
import { TeacherHomeworkCard } from "./teacher-homework-card" 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"> <div className="flex h-full flex-col space-y-6 p-8">
<TeacherDashboardHeader teacherName={data.teacherName} /> <TeacherDashboardHeader teacherName={data.teacherName} />
<TeacherStats <DashboardSection variant="stats">
toGradeCount={metrics.toGradeCount} <TeacherStats
activeAssignmentsCount={metrics.activeAssignmentsCount} toGradeCount={metrics.toGradeCount}
averageScore={metrics.averageScore} activeAssignmentsCount={metrics.activeAssignmentsCount}
submissionRate={metrics.submissionRate} averageScore={metrics.averageScore}
/> submissionRate={metrics.submissionRate}
/>
</DashboardSection>
<div className="grid gap-6 lg:grid-cols-12"> <div className="grid gap-6 lg:grid-cols-12">
{/* 移动端优先展示:今日课表 → 待办 → 待批改 */} {/* 移动端优先展示:今日课表 → 待办 → 待批改 */}
<div 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">
<TeacherSchedule items={metrics.todayScheduleItems} /> <DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div> </div>
<TeacherTodoCard items={todoItems} /> <DashboardSection variant="card">
<TeacherGradeTrends trends={data.gradeTrends} /> <TeacherTodoCard items={todoItems} />
<RecentSubmissions </DashboardSection>
submissions={metrics.submissionsToGrade} <DashboardSection variant="chart">
title={t("sections.pendingGrading")} <TeacherGradeTrends trends={data.gradeTrends} />
emptyTitle={t("empty.allGraded")} </DashboardSection>
emptyDescription={t("empty.allGradedDesc")} <DashboardSection variant="list">
/> <RecentSubmissions
submissions={metrics.submissionsToGrade}
title={t("sections.pendingGrading")}
emptyTitle={t("empty.allGraded")}
emptyDescription={t("empty.allGradedDesc")}
/>
</DashboardSection>
</div> </div>
<div className="flex flex-col gap-6 lg:col-span-4"> <div className="flex flex-col gap-6 lg:col-span-4">
<div className="hidden lg:block"> <div className="hidden lg:block">
<TeacherSchedule items={metrics.todayScheduleItems} /> <DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div> </div>
<TeacherHomeworkCard assignments={data.assignments} /> <DashboardSection variant="list">
<TeacherClassesCard classes={data.classes} /> <TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection>
<DashboardSection variant="list">
<TeacherClassesCard classes={data.classes} />
</DashboardSection>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -115,7 +115,9 @@
"error": { "error": {
"loadFailed": "Page load failed", "loadFailed": "Page load failed",
"loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.", "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": { "chart": {
"newUsers": "New users", "newUsers": "New users",

View File

@@ -115,7 +115,9 @@
"error": { "error": {
"loadFailed": "页面加载失败", "loadFailed": "页面加载失败",
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。", "loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
"retry": "重试" "retry": "重试",
"sectionLoadFailed": "区块加载失败",
"sectionLoadFailedDesc": "该数据区块加载时出错,请重试。"
}, },
"chart": { "chart": {
"newUsers": "新增用户", "newUsers": "新增用户",