feat(dashboard): 仪表盘模块审计重构 — 权限校验 + i18n + 逻辑抽离
基于 dashboard-audit-report.md 审计结论,对仪表盘模块进行 P0/P1 级修复:
- 新增 4 个 dashboard 权限点(DASHBOARD_ADMIN/TEACHER/STUDENT/PARENT_READ),补充到 permissions.ts 和角色-权限映射
- 新建 actions.ts:4 个 Server Action 均调用 requirePermission() 校验权限,消除 admin 页面零鉴权、teacher/student/parent 仅 requireAuth 的安全隐患
- 根重定向页 /dashboard 改用 resolvePermissions() + 权限点判断,不再 role === xxx 硬编码
- 新建 lib/dashboard-utils.ts:抽取 toWeekday / countStudentAssignments / sortUpcomingAssignments / filterTodaySchedule / computeTeacherMetrics / getGreetingKey 纯函数,与 UI 分离,便于单测
- 新建 messages/{zh-CN,en}/dashboard.json 翻译文件,i18n request.ts 加载 dashboard 命名空间;所有视图组件接入 useTranslations / getTranslations,消除中英混杂硬编码
- 重构 4 个角色 page.tsx:通过 actions 获取数据,generateMetadata 使用 i18n
- 同步更新架构图 004 / 005 文档(dashboard exports / permissions / 文件清单)
This commit is contained in:
@@ -866,30 +866,44 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
## 2.12 dashboard(仪表盘模块)
|
## 2.12 dashboard(仪表盘模块)
|
||||||
|
|
||||||
**职责**:管理员/教师/学生仪表盘数据聚合 + 管理员趋势图表。
|
**职责**:管理员/教师/学生/家长仪表盘数据聚合 + 权限校验 + i18n + 纯逻辑工具函数。
|
||||||
|
|
||||||
**导出函数**:
|
**导出函数**:
|
||||||
- Data-access:`getAdminDashboardData` / `getTeacherDashboardData` / `getStudentDashboardData`
|
- Actions:`getAdminDashboardAction` / `getTeacherDashboardAction` / `getStudentDashboardAction` / `getParentDashboardAction`(均调用 `requirePermission()` 校验对应 `DASHBOARD_*_READ` 权限)
|
||||||
- Components:`AdminDashboardView` / `UserGrowthChart`(recharts 折线图,复用于用户增长趋势与作业提交趋势)
|
- Data-access:`getAdminDashboardData`(并行调用 6 个模块 stats 函数)
|
||||||
|
- Lib 纯函数:`toWeekday` / `countStudentAssignments` / `sortUpcomingAssignments` / `filterTodaySchedule` / `computeTeacherMetrics` / `getGreetingKey`
|
||||||
|
- Components:`AdminDashboardView` / `TeacherDashboardView` / `StudentDashboard` / `UserGrowthChart`(均接入 next-intl i18n)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(通过 data-access,合理)、`homework`(通过 data-access,合理)、`grades`(合理)、`users`/`textbooks`/`questions`/`exams`(通过各模块 dashboard stats 函数,P0-4 已修复)、`recharts`(UserGrowthChart)
|
- 依赖:`shared/*`、`@/auth`、`classes`(通过 data-access)、`homework`(通过 data-access)、`users`(通过 data-access)、`parent`(通过 data-access.getParentDashboardData)、`textbooks`/`questions`/`exams`(通过各模块 dashboard stats 函数)、`recharts`、`next-intl`
|
||||||
- 被依赖:无
|
- 被依赖:无
|
||||||
|
|
||||||
|
**权限点**:
|
||||||
|
- `DASHBOARD_ADMIN_READ`(admin)
|
||||||
|
- `DASHBOARD_TEACHER_READ`(teacher)
|
||||||
|
- `DASHBOARD_STUDENT_READ`(student)
|
||||||
|
- `DASHBOARD_PARENT_READ`(parent)
|
||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ P0-4 已修复:`getAdminDashboardData` 改为并行调用各模块 dashboard stats 函数(`getUsersDashboardStats`/`getClassesDashboardStats`/`getTextbooksDashboardStats`/`getQuestionsDashboardStats`/`getExamsDashboardStats`/`getHomeworkDashboardStats`),不再直查跨模块表
|
- ✅ P0-4 已修复:`getAdminDashboardData` 改为并行调用各模块 dashboard stats 函数,不再直查跨模块表
|
||||||
- ✅ P1-1 已修复:~~教师仪表盘直查 `users` 表获取教师姓名~~ 改为通过 users data-access 获取
|
- ✅ P0 已修复(2026-06-22):所有仪表盘页面通过 `actions.ts` 调用 `requirePermission()` 进行权限校验,不再裸调 data-access
|
||||||
- ✅ 学生/教师仪表盘正确通过各模块 data-access 获取数据
|
- ✅ P0 已修复(2026-06-22):根重定向页 `/dashboard` 改用 `resolvePermissions()` + 权限点判断,不再 `role === "xxx"` 硬编码
|
||||||
- ℹ️ V1 新增:`AdminDashboardData` 类型新增 `userGrowth`/`homeworkTrend` 字段(`Array<{ date: string; count: number }>`),`data-access.ts` 当前返回空数组占位,待后续接入真实统计
|
- ✅ P0 已修复(2026-06-22):所有仪表盘组件接入 next-intl(`useTranslations` / `getTranslations`),翻译文件 `messages/{zh-CN,en}/dashboard.json`
|
||||||
|
- ✅ P1 已修复(2026-06-22):业务逻辑(weekday 转换、作业统计、教师指标计算、问候语时段)抽取至 `lib/dashboard-utils.ts` 纯函数,与 UI 分离
|
||||||
|
- ℹ️ V1 新增:`AdminDashboardData` 类型含 `userGrowth`/`homeworkTrend` 字段,`data-access.ts` 当前返回空数组占位,待后续接入真实统计
|
||||||
|
- ℹ️ parent 仪表盘组件仍位于 `modules/parent/components/parent-dashboard.tsx`,通过 `dashboard/actions.getParentDashboardAction` 调用(架构决策:保留在 parent 模块以避免移动文件破坏其他 import)
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `data-access.ts` | - | 仪表盘数据聚合(P0-4 已修复,通过各模块 data-access 获取数据;V1 新增 userGrowth/homeworkTrend 占位字段) |
|
| `actions.ts` | 120 | 4 个 Server Action(编排层,含 `requirePermission()` 权限校验) |
|
||||||
| `types.ts` | - | 类型定义(V1 新增 userGrowth/homeworkTrend 字段) |
|
| `data-access.ts` | 49 | admin 仪表盘数据聚合(并行调用各模块 stats 函数) |
|
||||||
| `components/admin-dashboard/admin-dashboard.tsx` | - | 管理员仪表盘视图(V1 新增趋势图表区域:用户增长趋势 + 作业提交趋势) |
|
| `lib/dashboard-utils.ts` | 170 | 纯逻辑工具函数(weekday / 统计 / 排序 / 指标计算 / 问候语) |
|
||||||
| `components/admin-dashboard/user-growth-chart.tsx` | - | recharts 折线图组件(V1 新增,复用于两个趋势卡片) |
|
| `types.ts` | 74 | Admin / Teacher / Student 类型定义 |
|
||||||
| `components/*` | 14 文件 | 三种角色仪表盘组件 |
|
| `components/admin-dashboard/admin-dashboard.tsx` | 267 | 管理员仪表盘视图(i18n) |
|
||||||
|
| `components/admin-dashboard/user-growth-chart.tsx` | 50 | recharts 折线图(i18n) |
|
||||||
|
| `components/teacher-dashboard/*.tsx` | 9 文件 | 教师仪表盘组件(i18n) |
|
||||||
|
| `components/student-dashboard/*.tsx` | 6 文件 | 学生仪表盘组件(i18n) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,11 @@
|
|||||||
"LESSON_PLAN_READ": "lesson_plan:read",
|
"LESSON_PLAN_READ": "lesson_plan:read",
|
||||||
"LESSON_PLAN_UPDATE": "lesson_plan:update",
|
"LESSON_PLAN_UPDATE": "lesson_plan:update",
|
||||||
"LESSON_PLAN_DELETE": "lesson_plan:delete",
|
"LESSON_PLAN_DELETE": "lesson_plan:delete",
|
||||||
"LESSON_PLAN_PUBLISH": "lesson_plan:publish"
|
"LESSON_PLAN_PUBLISH": "lesson_plan:publish",
|
||||||
|
"DASHBOARD_ADMIN_READ": "dashboard:admin_read",
|
||||||
|
"DASHBOARD_TEACHER_READ": "dashboard:teacher_read",
|
||||||
|
"DASHBOARD_STUDENT_READ": "dashboard:student_read",
|
||||||
|
"DASHBOARD_PARENT_READ": "dashboard:parent_read"
|
||||||
},
|
},
|
||||||
"rolePermissions": {
|
"rolePermissions": {
|
||||||
"admin": [
|
"admin": [
|
||||||
@@ -176,7 +180,8 @@
|
|||||||
"ELECTIVE_MANAGE",
|
"ELECTIVE_MANAGE",
|
||||||
"ELECTIVE_READ",
|
"ELECTIVE_READ",
|
||||||
"EXAM_PROCTOR",
|
"EXAM_PROCTOR",
|
||||||
"EXAM_PROCTOR_READ"
|
"EXAM_PROCTOR_READ",
|
||||||
|
"DASHBOARD_ADMIN_READ"
|
||||||
],
|
],
|
||||||
"teacher": [
|
"teacher": [
|
||||||
"EXAM_CREATE",
|
"EXAM_CREATE",
|
||||||
@@ -216,7 +221,8 @@
|
|||||||
"ELECTIVE_MANAGE",
|
"ELECTIVE_MANAGE",
|
||||||
"ELECTIVE_READ",
|
"ELECTIVE_READ",
|
||||||
"EXAM_PROCTOR",
|
"EXAM_PROCTOR",
|
||||||
"EXAM_PROCTOR_READ"
|
"EXAM_PROCTOR_READ",
|
||||||
|
"DASHBOARD_TEACHER_READ"
|
||||||
],
|
],
|
||||||
"student": [
|
"student": [
|
||||||
"EXAM_READ",
|
"EXAM_READ",
|
||||||
@@ -236,7 +242,8 @@
|
|||||||
"MESSAGE_DELETE",
|
"MESSAGE_DELETE",
|
||||||
"DIAGNOSTIC_READ",
|
"DIAGNOSTIC_READ",
|
||||||
"ELECTIVE_SELECT",
|
"ELECTIVE_SELECT",
|
||||||
"ELECTIVE_READ"
|
"ELECTIVE_READ",
|
||||||
|
"DASHBOARD_STUDENT_READ"
|
||||||
],
|
],
|
||||||
"parent": [
|
"parent": [
|
||||||
"EXAM_READ",
|
"EXAM_READ",
|
||||||
@@ -248,7 +255,8 @@
|
|||||||
"ATTENDANCE_READ",
|
"ATTENDANCE_READ",
|
||||||
"MESSAGE_SEND",
|
"MESSAGE_SEND",
|
||||||
"MESSAGE_READ",
|
"MESSAGE_READ",
|
||||||
"MESSAGE_DELETE"
|
"MESSAGE_DELETE",
|
||||||
|
"DASHBOARD_PARENT_READ"
|
||||||
],
|
],
|
||||||
"grade_head": [
|
"grade_head": [
|
||||||
"EXAM_CREATE",
|
"EXAM_CREATE",
|
||||||
@@ -5724,8 +5732,96 @@
|
|||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"path": "src/modules/dashboard",
|
"path": "src/modules/dashboard",
|
||||||
"description": "各角色仪表盘数据聚合与展示",
|
"description": "各角色仪表盘数据聚合与展示(含权限校验 + i18n + 纯逻辑工具函数)",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"name": "getAdminDashboardAction",
|
||||||
|
"signature": "() => Promise<AdminDashboardData>",
|
||||||
|
"deps": [
|
||||||
|
"shared/lib/auth-guard.requirePermission",
|
||||||
|
"dashboard/data-access.getAdminDashboardData",
|
||||||
|
"Permissions.DASHBOARD_ADMIN_READ"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"admin/dashboard/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getTeacherDashboardAction",
|
||||||
|
"signature": "() => Promise<TeacherDashboardData & { metrics: TeacherDashboardMetrics }>",
|
||||||
|
"deps": [
|
||||||
|
"shared/lib/auth-guard.requirePermission",
|
||||||
|
"classes/data-access.getTeacherClasses/getClassSchedule/getTeacherIdForMutations",
|
||||||
|
"homework/data-access.getHomeworkAssignments/getHomeworkSubmissions/getTeacherGradeTrends",
|
||||||
|
"users/data-access.getUserBasicInfo",
|
||||||
|
"dashboard/lib/dashboard-utils.computeTeacherMetrics",
|
||||||
|
"Permissions.DASHBOARD_TEACHER_READ"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"teacher/dashboard/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getStudentDashboardAction",
|
||||||
|
"signature": "() => Promise<{ student, dashboardProps }>",
|
||||||
|
"deps": [
|
||||||
|
"shared/lib/auth-guard.requirePermission",
|
||||||
|
"users/data-access.getCurrentStudentUser",
|
||||||
|
"classes/data-access.getStudentClasses/getStudentSchedule",
|
||||||
|
"homework/data-access.getStudentHomeworkAssignments/getStudentDashboardGrades",
|
||||||
|
"dashboard/lib/dashboard-utils.countStudentAssignments/sortUpcomingAssignments/toWeekday/filterTodaySchedule",
|
||||||
|
"Permissions.DASHBOARD_STUDENT_READ"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"student/dashboard/page.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getParentDashboardAction",
|
||||||
|
"signature": "() => Promise<{ data, hasChildren }>",
|
||||||
|
"deps": [
|
||||||
|
"shared/lib/auth-guard.requirePermission",
|
||||||
|
"parent/data-access.getParentDashboardData",
|
||||||
|
"Permissions.DASHBOARD_PARENT_READ"
|
||||||
|
],
|
||||||
|
"usedBy": [
|
||||||
|
"parent/dashboard/page.tsx"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lib": [
|
||||||
|
{
|
||||||
|
"name": "toWeekday",
|
||||||
|
"signature": "(d: Date) => Weekday",
|
||||||
|
"purpose": "Date 转 1-7 周几(周一=1,周日=7)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "countStudentAssignments",
|
||||||
|
"signature": "(assignments, now, dueSoonWindowDays?) => StudentAssignmentStats",
|
||||||
|
"purpose": "单次遍历统计学生作业:即将到期/已逾期/已批改"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sortUpcomingAssignments",
|
||||||
|
"signature": "(assignments, limit?) => StudentHomeworkAssignmentListItem[]",
|
||||||
|
"purpose": "按截止日期升序排序取前 N 条"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "filterTodaySchedule",
|
||||||
|
"signature": "(schedule, weekday, classNameById?) => StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]",
|
||||||
|
"purpose": "筛选指定周几课表并按开始时间排序"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "computeTeacherMetrics",
|
||||||
|
"signature": "(classes, schedule, assignments, submissions, gradeTrends, now) => TeacherDashboardMetrics",
|
||||||
|
"purpose": "计算教师仪表盘派生指标:待批改数/进行中作业/平均分/提交率/今日课表/待批改列表"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "getGreetingKey",
|
||||||
|
"signature": "(now: Date) => 'morning' | 'afternoon' | 'evening'",
|
||||||
|
"purpose": "根据当前小时返回问候语时段 key"
|
||||||
|
}
|
||||||
|
],
|
||||||
"dataAccess": [
|
"dataAccess": [
|
||||||
{
|
{
|
||||||
"name": "getAdminDashboardData",
|
"name": "getAdminDashboardData",
|
||||||
@@ -5740,7 +5836,7 @@
|
|||||||
"DataScope"
|
"DataScope"
|
||||||
],
|
],
|
||||||
"usedBy": [
|
"usedBy": [
|
||||||
"admin/dashboard/page.tsx"
|
"dashboard/actions.getAdminDashboardAction"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
320
docs/architecture/audit/dashboard-audit-report.md
Normal file
320
docs/architecture/audit/dashboard-audit-report.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# 仪表盘模块审计报告
|
||||||
|
|
||||||
|
> 审查日期:2026-06-22
|
||||||
|
> 审查范围:`src/modules/dashboard/**`、`src/app/(dashboard)/*/dashboard/**`、`src/modules/parent/components/parent-dashboard.tsx`(家长端仪表盘)
|
||||||
|
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §1.4.3、`docs/architecture/005_architecture_data.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有实现概要
|
||||||
|
|
||||||
|
### 1.1 文件分布
|
||||||
|
|
||||||
|
| 层 | 路径 | 文件数 | 说明 |
|
||||||
|
|----|------|--------|------|
|
||||||
|
| 路由层 | `src/app/(dashboard)/{admin,teacher,student,parent}/dashboard/` | 4 个 `page.tsx` + 3 个 `error.tsx` + 3 个 `loading.tsx` | 各角色独立路由,另有根 `/dashboard/page.tsx` 做角色重定向 |
|
||||||
|
| 模块层 - admin | `src/modules/dashboard/components/admin-dashboard/` | 2 个(`admin-dashboard.tsx` 263 行、`user-growth-chart.tsx` 46 行) | |
|
||||||
|
| 模块层 - teacher | `src/modules/dashboard/components/teacher-dashboard/` | 9 个组件 | `teacher-dashboard-view.tsx` 为容器,含业务计算逻辑 |
|
||||||
|
| 模块层 - student | `src/modules/dashboard/components/student-dashboard/` | 6 个组件 | `student-dashboard-view.tsx` 为容器 |
|
||||||
|
| 模块层 - parent | `src/modules/parent/components/parent-dashboard.tsx` | 1 个(108 行) | **不在 dashboard 模块内**,位于 parent 模块 |
|
||||||
|
| 数据层 | `src/modules/dashboard/data-access.ts` | 1 个(49 行) | 仅 `getAdminDashboardData`,并行调用 6 个模块的 stats 函数 |
|
||||||
|
| 类型层 | `src/modules/dashboard/types.ts` | 1 个(74 行) | Admin / Teacher / Student 类型定义 |
|
||||||
|
| Actions 层 | **缺失** | 0 | 无 `actions.ts`,页面直接调用 data-access |
|
||||||
|
|
||||||
|
### 1.2 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
[Route] /admin/dashboard/page.tsx
|
||||||
|
└─▶ dashboard/data-access.getAdminDashboardData()
|
||||||
|
└─▶ Promise.all(users/classes/textbooks/questions/exams/homework stats)
|
||||||
|
|
||||||
|
[Route] /teacher/dashboard/page.tsx
|
||||||
|
├─▶ classes/data-access.getTeacherClasses / getClassSchedule
|
||||||
|
├─▶ homework/data-access.getHomeworkAssignments / getHomeworkSubmissions / getTeacherGradeTrends
|
||||||
|
└─▶ users/data-access.getUserBasicInfo
|
||||||
|
(页面层直接编排 3 个模块的 data-access)
|
||||||
|
|
||||||
|
[Route] /student/dashboard/page.tsx
|
||||||
|
├─▶ users/data-access.getCurrentStudentUser
|
||||||
|
├─▶ classes/data-access.getStudentClasses / getStudentSchedule
|
||||||
|
└─▶ homework/data-access.getStudentHomeworkAssignments / getStudentDashboardGrades
|
||||||
|
(页面层直接编排 3 个模块的 data-access + 业务计算)
|
||||||
|
|
||||||
|
[Route] /parent/dashboard/page.tsx
|
||||||
|
└─▶ parent/data-access.getParentDashboardData
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 架构图记录情况
|
||||||
|
|
||||||
|
`004_architecture_impact_map.md` §1.4.3 记录了 admin 仪表盘聚合链路(P0-4 已修复跨模块直查),但存在遗漏:
|
||||||
|
- **未记录 teacher / student / parent 仪表盘的调用链路**
|
||||||
|
- **未记录 dashboard 模块的 exports 清单**(005 JSON 中 dashboard 节点缺失 `exports` 字段)
|
||||||
|
- **未记录 parent 仪表盘组件位于 parent 模块这一结构异常**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现存问题与原因分析
|
||||||
|
|
||||||
|
### 2.1 安全性:权限校验完全缺失(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [admin/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/dashboard/page.tsx) | 直接调用 `getAdminDashboardData()`,**无任何 auth/permission 校验** | "所有 Server Action 必须调用 `requirePermission()` 进行权限校验" |
|
||||||
|
| [teacher/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx) | 仅调用 `getAuthContext()`,未校验任何权限点 | 同上 |
|
||||||
|
| [student/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx) | **无任何 auth 调用**,完全依赖 layout 守卫 | 同上 |
|
||||||
|
| [parent/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/dashboard/page.tsx) | 调用 `requireAuth()`,未校验具体权限 | 同上 |
|
||||||
|
| [permissions.ts](file:///e:/Desktop/CICD/src/shared/types/permissions.ts) | **无 dashboard 相关权限点定义** | 权限体系不完整 |
|
||||||
|
|
||||||
|
**后果**:admin 仪表盘数据(含全校用户数、活跃会话数、最近注册用户列表)可被任意已登录用户访问,属于严重越权。即使 layout 层有路由组守卫,data-access 层仍缺乏二次校验,不符合"Server Action 二次校验"要求。
|
||||||
|
|
||||||
|
### 2.2 架构分层:页面层越权编排 + 模块归属错位(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [teacher/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/dashboard/page.tsx) L16-23 | 页面层直接 `Promise.all` 调用 classes/homework/users 三个模块的 data-access | "app/ 只能调用 modules/ 的 Server Actions 和 data-access" — 虽然语法允许,但编排逻辑应在 dashboard 模块的 actions/data-access 层完成 |
|
||||||
|
| [student/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx) L36-86 | 页面层包含 weekday 转换、作业状态统计、排序切片等 **80 行业务逻辑** | "Server Actions / Data Access 模块"应承担编排职责;纯逻辑应抽为 hooks/纯函数 |
|
||||||
|
| [parent-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-dashboard.tsx) | 家长仪表盘组件位于 `modules/parent` 而非 `modules/dashboard` | 仪表盘模块不完整,四角色仪表盘分散在两个模块 |
|
||||||
|
| dashboard 模块无 `actions.ts` | 缺失编排层 | "模块标准结构"要求 `actions.ts`(编排层) |
|
||||||
|
|
||||||
|
**后果**:页面层臃肿、逻辑不可复用、不可测试;新增角色需复制粘贴整页编排逻辑。
|
||||||
|
|
||||||
|
### 2.3 角色硬编码(P0)
|
||||||
|
|
||||||
|
| 位置 | 代码 | 违反规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| [dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/dashboard/page.tsx) L12-15 | `roles.includes("admin")` / `roles.includes("student")` / `roles.includes("parent")` | "前端权限判断统一使用 `usePermission().hasPermission()`,严禁出现 `role === 'xxx'` 硬编码" |
|
||||||
|
| [auth-guard.ts](file:///e:/Desktop/CICD/src/shared/lib/auth-guard.ts) L69/L74/L86/L118/L131 | `roleNames.includes("admin"/"teacher"/"student"/"parent")` | 同上(dataScope 解析也基于角色硬编码) |
|
||||||
|
|
||||||
|
**后果**:新增角色(如 grade_head 已存在但未处理仪表盘重定向)无法正确路由;权限策略变更需改多处代码。
|
||||||
|
|
||||||
|
### 2.4 国际化:零覆盖 + 中英混杂(P0)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| [admin-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx) | L34 `"Dashboard"`、L63 `"Users"` 为英文;L74 `"批量导入用户"`、L113 `"用户增长趋势(近30天)"` 为中文 — **同一文件中英混杂** |
|
||||||
|
| [teacher-dashboard-header.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx) L13-16 | `greeting = "早上好"/"下午好"/"晚上好"` 硬编码 |
|
||||||
|
| [teacher-dashboard-view.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx) L53-55 | `"待批改作业"` / `"今日待考勤"` / `"进行中作业"` 硬编码 |
|
||||||
|
| [student-stats-grid.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/student-dashboard/student-stats-grid.tsx) | `"Enrolled Classes"` / `"Average Score"` 等全英文硬编码 |
|
||||||
|
| [parent-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-dashboard.tsx) L28-31 | `"Good morning"` / `"Good afternoon"` 硬编码 |
|
||||||
|
| [user-growth-chart.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx) L42 | `name="新增用户"` 硬编码 |
|
||||||
|
| `messages/` 目录 | **无 `dashboard.json`**,仅 onboarding/classes/auth/errors/common 有翻译文件 |
|
||||||
|
|
||||||
|
**违反规则**:"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。
|
||||||
|
|
||||||
|
**后果**:无法切换语言;维护时需逐文件改字符串;中英混杂给用户造成混乱。
|
||||||
|
|
||||||
|
### 2.5 错误与边界处理:仅路由级(P1)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| `error.tsx` / `loading.tsx` | 仅存在于路由级(`app/(dashboard)/*/dashboard/`),**无按数据区块的 Error Boundary** |
|
||||||
|
| [admin-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx) | 6 张 Card + 1 张表格,任一数据源异常导致整页崩溃 |
|
||||||
|
| [teacher-dashboard-view.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx) | 7 个子区块,无独立 Suspense 包裹 |
|
||||||
|
| error.tsx 文案 | `"页面加载失败"` 硬编码中文,未 i18n |
|
||||||
|
|
||||||
|
**违反规则**:"每个独立的数据区块必须用 React Error Boundary 包裹"、"异步数据使用 React Suspense + 骨架屏"。
|
||||||
|
|
||||||
|
**后果**:单个 Widget 故障导致整页不可用;无法流式渲染,首屏白屏时间长。
|
||||||
|
|
||||||
|
### 2.6 可测试性:业务逻辑与 UI 耦合(P1)
|
||||||
|
|
||||||
|
| 位置 | 耦合的逻辑 |
|
||||||
|
|------|-----------|
|
||||||
|
| [teacher-dashboard-view.tsx](file:///e:/Desktop/CICD/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx) L18-56 | `toWeekday`、`todayScheduleItems` 过滤排序、`toGradeCount`/`submissionRate` 计算、`todoItems` 聚合 — 全部内联在组件中 |
|
||||||
|
| [student/dashboard/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/dashboard/page.tsx) L13-86 | `toWeekday`、`dueSoonCount`/`overdueCount`/`gradedCount` 单次遍历统计、`upcomingAssignments` 排序切片 — 80 行纯逻辑在 Server Component 中 |
|
||||||
|
| [parent-dashboard.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-dashboard.tsx) L27-31 | greeting 时段判断内联在组件中 |
|
||||||
|
|
||||||
|
**违反规则**:"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离"。
|
||||||
|
|
||||||
|
**后果**:无法对统计逻辑做单元测试;逻辑变更需改组件代码;复用需复制粘贴。
|
||||||
|
|
||||||
|
### 2.7 可复用性:四角色零共享抽象(P1)
|
||||||
|
|
||||||
|
| 维度 | 现状 |
|
||||||
|
|------|------|
|
||||||
|
| 布局容器 | admin/teacher/student/parent 各写一套 `<div className="space-y-*">`,无统一 `DashboardLayout` |
|
||||||
|
| 统计卡片 | 已复用 `shared/components/ui/stat-card.tsx`(✅ 良好) |
|
||||||
|
| 快捷操作 | admin 的 `QuickActionCard`(内联)、parent 的 `QUICK_ENTRIES`(内联)、teacher 的 `TeacherQuickActions` — 三套独立实现,无统一 `QuickActions` 组件 |
|
||||||
|
| 待办/任务 | 仅 teacher 有 `TeacherTodoCard`,student/admin/parent 无类似组件 |
|
||||||
|
| 问候语 | teacher/parent 各写一套 `hour < 12 ? "早上好" : ...`,无统一 `useGreeting` hook |
|
||||||
|
| Widget 配置 | 无配置驱动设计,新增角色需新建整套组件 |
|
||||||
|
|
||||||
|
**违反规则**:"最大化复用:识别四个角色共用的 UI 块和业务逻辑块,抽象为泛型组件和 hooks"、"采用配置驱动设计"。
|
||||||
|
|
||||||
|
### 2.8 性能:全量 force-dynamic 无流式渲染(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| 所有 `page.tsx` | `export const dynamic = "force-dynamic"`,`Promise.all` 等全部数据就绪后才渲染 |
|
||||||
|
| 无 `<Suspense>` 包裹 | 无法流式渲染,首屏 TTFB 到 FCP 全部阻塞 |
|
||||||
|
|
||||||
|
**违反规则**:"优先使用 React Server Components 获取初始数据;客户端组件仅负责交互;支持流式渲染"。
|
||||||
|
|
||||||
|
### 2.9 可访问性(P2)
|
||||||
|
|
||||||
|
| 位置 | 问题 |
|
||||||
|
|------|------|
|
||||||
|
| admin-dashboard.tsx | 表格无 `caption`,快捷操作 Card 作为链接无 `aria-label` |
|
||||||
|
| teacher-dashboard-view.tsx | 布局 div 无语义化标签(`<section>` / `<aside>`) |
|
||||||
|
| student-dashboard-view.tsx | 同上 |
|
||||||
|
|
||||||
|
**违反规则**:"语义化标签、ARIA 属性、键盘导航"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业差距对比
|
||||||
|
|
||||||
|
### 3.1 K12 仪表盘主流设计模式
|
||||||
|
|
||||||
|
| 模式 | 行业实践 | 本项目现状 | 差距影响 |
|
||||||
|
|------|----------|------------|----------|
|
||||||
|
| **Widget 网格系统** | 可拖拽、可配置的 Widget 卡片(如 PowerSchool、Veracross) | 四角色各自硬编码布局 | 无法个性化,新增角色需重写 |
|
||||||
|
| **跨角色数据联动** | 家长端预览孩子仪表盘、教师端查看学生上下文 | 四角色完全隔离 | 家长需跳转多个页面才能了解孩子情况 |
|
||||||
|
| **可操作洞察** | "3 名学生成绩下滑"、"2 份作业待批改超 3 天" 等智能提醒 | 仅展示静态数字 | 管理者/教师需手动分析,效率低 |
|
||||||
|
| **通知中心集成** | 仪表盘首屏显示未读通知摘要 | 无通知集成 | 用户需进入消息模块查看 |
|
||||||
|
| **统一日历** | 跨模块日历视图(作业/考试/考勤/请假) | 无 | 师生需在多个模块间切换查看日程 |
|
||||||
|
| **学习进度可视化** | 学生学习路径、知识点掌握雷达图 | student 仅有成绩卡片 | 学生无法直观了解学习状态 |
|
||||||
|
| **空状态引导** | 无数据时提供 CTA("创建第一个作业") | admin 有部分 EmptyState,其他角色缺失 | 新用户不知下一步操作 |
|
||||||
|
| **实时更新** | 活跃会话数、待批改数 WebSocket 推送 | 全静态 | 数据滞后,需手动刷新 |
|
||||||
|
| **响应式适配** | 移动端优先布局 | parent 有移动端横向滑动,其他角色仅 `md:` 断点 | 移动端体验差 |
|
||||||
|
|
||||||
|
### 3.2 各角色差距详述
|
||||||
|
|
||||||
|
**Admin**:
|
||||||
|
- 缺少学校运营关键指标(出勤率、作业完成率趋势)
|
||||||
|
- 用户增长趋势图为空(`userGrowth: []` 硬编码在 data-access L46)
|
||||||
|
- 无系统健康监控(DB 连接数、API 延迟等)
|
||||||
|
|
||||||
|
**Teacher**:
|
||||||
|
- 缺少班级对比视图(哪个班表现最好/最差)
|
||||||
|
- 缺少学生预警列表(成绩下滑/未提交作业的学生)
|
||||||
|
- 课表仅显示今日,无本周概览
|
||||||
|
|
||||||
|
**Student**:
|
||||||
|
- 缺少学习目标/进度跟踪
|
||||||
|
- 缺少同学协作入口(小组作业、学习伙伴)
|
||||||
|
- 成绩仅显示排名,无知识点维度分析
|
||||||
|
|
||||||
|
**Parent**:
|
||||||
|
- 缺少多孩子对比视图
|
||||||
|
- 缺少与教师沟通快捷入口
|
||||||
|
- 缺少孩子出勤/成绩异常告警
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进优先级建议
|
||||||
|
|
||||||
|
### P0(紧急 — 安全与合规)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P0-1 | 权限校验完全缺失 | 新增 `DASHBOARD_ADMIN_READ` / `DASHBOARD_TEACHER_READ` / `DASHBOARD_STUDENT_READ` / `DASHBOARD_PARENT_READ` 权限点;创建 `actions.ts`,每个 Action 调用 `requirePermission()` |
|
||||||
|
| P0-2 | 根重定向页角色硬编码 | 改用 `hasPermission(DASHBOARD_*_READ)` 决定重定向目标 |
|
||||||
|
| P0-3 | i18n 零覆盖 | 创建 `messages/{zh-CN,en}/dashboard.json`;所有组件接入 `useTranslations` / `getTranslations` |
|
||||||
|
| P0-4 | 页面层越权编排 | 将 teacher/student/parent 的数据编排下沉到 `dashboard/actions.ts` 或 `data-access.ts` |
|
||||||
|
|
||||||
|
### P1(较严重 — 架构与质量)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P1-1 | 业务逻辑耦合 UI | 抽取 `hooks/use-teacher-dashboard-metrics.ts`、`hooks/use-student-dashboard-metrics.ts`、`lib/weekday.ts`(纯函数) |
|
||||||
|
| P1-2 | 四角色零共享 | 抽象 `DashboardLayout`、`QuickActions`、`GreetingHeader`、`WidgetBoundary`(Error Boundary + Suspense 组合) |
|
||||||
|
| P1-3 | 仅路由级错误边界 | 每个数据区块用 `<WidgetBoundary>` 包裹,支持独立 fallback |
|
||||||
|
| P1-4 | parent 仪表盘归属错位 | 将 `parent-dashboard.tsx` 迁移至 `modules/dashboard/components/parent-dashboard/`,或保留在 parent 模块但在架构图中明确标注 |
|
||||||
|
| P1-5 | 无流式渲染 | 用 `<Suspense>` 包裹各 Widget,数据获取改为独立 async 组件 |
|
||||||
|
|
||||||
|
### P2(优化 — 体验与扩展)
|
||||||
|
|
||||||
|
| # | 问题 | 改进方向 |
|
||||||
|
|---|------|----------|
|
||||||
|
| P2-1 | 无 Widget 配置系统 | 设计 `DashboardWidgetConfig` 类型,按角色配置渲染哪些 Widget |
|
||||||
|
| P2-2 | a11y 不足 | 补充语义化标签、ARIA 属性、表格 caption |
|
||||||
|
| P2-3 | 无单测 | 为抽取的纯函数/hooks 添加单测 |
|
||||||
|
| P2-4 | 行业功能差距 | 逐步补齐通知集成、统一日历、学生预警等(按角色优先级迭代) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构图同步说明
|
||||||
|
|
||||||
|
本次审计发现架构图存在以下遗漏,需在实现后同步更新:
|
||||||
|
|
||||||
|
### 5.1 `004_architecture_impact_map.md` 需补充
|
||||||
|
|
||||||
|
1. **§1.4 调用链路**:新增 teacher / student / parent 仪表盘调用链路(当前仅记录 admin)
|
||||||
|
2. **dashboard 模块章节**:补充 `actions.ts`(新增)、`hooks/`(新增)、`lib/`(新增)描述
|
||||||
|
3. **parent 模块章节**:标注 parent-dashboard 组件的归属决策
|
||||||
|
|
||||||
|
### 5.2 `005_architecture_data.json` 需修改
|
||||||
|
|
||||||
|
1. `modules.dashboard` 节点:
|
||||||
|
- 新增 `exports`:`getAdminDashboardData`、`getTeacherDashboardData`(新增)、`getStudentDashboardData`(新增)、`getParentDashboardData`(迁移或代理)
|
||||||
|
- 新增 `actions`:`getAdminDashboardAction` 等
|
||||||
|
- 新增 `hooks`:`useTeacherDashboardMetrics`、`useStudentDashboardMetrics`
|
||||||
|
2. `permissions` 节点:新增 `DASHBOARD_*_READ` 四个权限点
|
||||||
|
3. `routes` 节点:补充 teacher/student/parent dashboard 调用链
|
||||||
|
4. `dependencyMatrix`:更新 dashboard → classes/homework/users 的依赖关系(通过 actions 层而非页面层)
|
||||||
|
|
||||||
|
### 5.3 翻译文件结构示例
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/i18n/messages/
|
||||||
|
├─ zh-CN/
|
||||||
|
│ └─ dashboard.json # 新增
|
||||||
|
└─ en/
|
||||||
|
└─ dashboard.json # 新增
|
||||||
|
```
|
||||||
|
|
||||||
|
`dashboard.json` 结构示例(zh-CN):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"admin": "管理控制台",
|
||||||
|
"teacher": "教师工作台",
|
||||||
|
"student": "学生中心",
|
||||||
|
"parent": "家长中心"
|
||||||
|
},
|
||||||
|
"greeting": {
|
||||||
|
"morning": "早上好",
|
||||||
|
"afternoon": "下午好",
|
||||||
|
"evening": "晚上好",
|
||||||
|
"welcome": "欢迎回来"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"users": "用户总数",
|
||||||
|
"classes": "班级数",
|
||||||
|
"activeSessions": "活跃会话",
|
||||||
|
"toGrade": "待批改",
|
||||||
|
"enrolledClasses": "已选课程",
|
||||||
|
"averageScore": "平均分",
|
||||||
|
"classRank": "班级排名",
|
||||||
|
"graded": "已批改",
|
||||||
|
"dueSoon": "即将到期",
|
||||||
|
"overdue": "已逾期"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"importUsers": "批量导入用户",
|
||||||
|
"newAnnouncement": "发布公告",
|
||||||
|
"approveSchedule": "审批课表变更",
|
||||||
|
"autoSchedule": "自动排课",
|
||||||
|
"fileManagement": "文件管理",
|
||||||
|
"attendanceOverview": "考勤总览"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"title": "今日待办",
|
||||||
|
"toGrade": "待批改作业",
|
||||||
|
"todayAttendance": "今日待考勤",
|
||||||
|
"activeAssignments": "进行中作业",
|
||||||
|
"empty": "今日无待办事项"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noUsers": "暂无用户",
|
||||||
|
"noChildren": "未绑定孩子",
|
||||||
|
"allGraded": "全部批改完成!"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "页面加载失败",
|
||||||
|
"retry": "重试"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
|
import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard"
|
||||||
import { getAdminDashboardData } from "@/modules/dashboard/data-access"
|
import { getAdminDashboardAction } from "@/modules/dashboard/actions"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "管理控制台 - Next_Edu",
|
|
||||||
description: "系统管理总览",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
return {
|
||||||
|
title: t("title.admin"),
|
||||||
|
description: t("description.admin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
||||||
const data = await getAdminDashboardData()
|
const data = await getAdminDashboardAction()
|
||||||
return <AdminDashboardView data={data} />
|
return <AdminDashboardView data={data} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("error.loadFailed")}
|
||||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
description={t("error.loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("error.retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
import { auth } from "@/auth"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { resolvePermissions } from "@/shared/lib/permissions"
|
||||||
|
import { isRole } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage(): Promise<void> {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
if (!session?.user) redirect("/login")
|
if (!session?.user) redirect("/login")
|
||||||
|
|
||||||
const roles = session.user.roles ?? []
|
const roles = (session.user.roles ?? []).filter(isRole)
|
||||||
|
const permissions = resolvePermissions(roles)
|
||||||
|
|
||||||
if (roles.includes("admin")) redirect("/admin/dashboard")
|
// 按优先级匹配仪表盘权限(admin > student > parent > teacher)
|
||||||
if (roles.includes("student")) redirect("/student/dashboard")
|
if (permissions.includes(Permissions.DASHBOARD_ADMIN_READ)) {
|
||||||
if (roles.includes("parent")) redirect("/parent/dashboard")
|
redirect("/admin/dashboard")
|
||||||
|
}
|
||||||
|
if (permissions.includes(Permissions.DASHBOARD_STUDENT_READ)) {
|
||||||
|
redirect("/student/dashboard")
|
||||||
|
}
|
||||||
|
if (permissions.includes(Permissions.DASHBOARD_PARENT_READ)) {
|
||||||
|
redirect("/parent/dashboard")
|
||||||
|
}
|
||||||
redirect("/teacher/dashboard")
|
redirect("/teacher/dashboard")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("error.loadFailed")}
|
||||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
description={t("error.loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("error.retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import type { Metadata } from "next"
|
||||||
import { getParentDashboardData } from "@/modules/parent/data-access"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
|
import { getParentDashboardAction } from "@/modules/dashboard/actions"
|
||||||
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
||||||
import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page"
|
import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page"
|
||||||
import { Users } from "lucide-react"
|
import { Users } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export const metadata = { title: "Dashboard - Next_Edu" }
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
return {
|
||||||
|
title: t("title.parent"),
|
||||||
|
description: t("description.parent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ParentDashboardPage() {
|
export default async function ParentDashboardPage(): Promise<JSX.Element> {
|
||||||
const ctx = await requireAuth()
|
const t = await getTranslations("dashboard")
|
||||||
|
const { data, hasChildren } = await getParentDashboardAction()
|
||||||
|
|
||||||
// 非 admin 且 dataScope 非 children 类型时,显示空状态
|
if (!data || !hasChildren) {
|
||||||
if (
|
|
||||||
ctx.dataScope.type !== "all" &&
|
|
||||||
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8">
|
<div className="p-6 md:p-8">
|
||||||
<ParentNoChildrenPage
|
<ParentNoChildrenPage
|
||||||
title="Parent Dashboard"
|
title={t("title.parent")}
|
||||||
description="Here's an overview of your children."
|
description={t("description.parent")}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
emptyTitle="No children linked"
|
emptyTitle={t("empty.noChildren")}
|
||||||
emptyDescription="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
emptyDescription={t("empty.noChildrenDesc")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getParentDashboardData(ctx.userId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8">
|
<div className="p-6 md:p-8">
|
||||||
<ParentDashboard data={data} />
|
<ParentDashboard data={data} />
|
||||||
|
|||||||
@@ -1,101 +1,42 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
|
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
|
||||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
import { getStudentDashboardAction } from "@/modules/dashboard/actions"
|
||||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { UserX } from "lucide-react"
|
import { UserX } from "lucide-react"
|
||||||
import type { StudentHomeworkProgressStatus } from "@/modules/homework/types"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export const metadata = { title: "Dashboard - Next_Edu" }
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
return {
|
||||||
// getDay() 返回 0(周日)-6(周六),转换为 1-7(周一为 1)
|
title: t("title.student"),
|
||||||
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
|
description: t("description.student"),
|
||||||
const day = d.getDay()
|
|
||||||
if (day < 0 || day > 6) {
|
|
||||||
throw new Error(`Invalid day from getDay(): ${day}`)
|
|
||||||
}
|
}
|
||||||
return WEEKDAY_MAP[day]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function StudentDashboardPage() {
|
export default async function StudentDashboardPage(): Promise<JSX.Element> {
|
||||||
const student = await getCurrentStudentUser()
|
const t = await getTranslations("dashboard")
|
||||||
if (!student) {
|
const { student, dashboardProps } = await getStudentDashboardAction()
|
||||||
|
|
||||||
|
if (!student || !dashboardProps) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No user found"
|
title={t("empty.noStudent")}
|
||||||
description="Create a student user to see dashboard."
|
description={t("empty.noStudentDesc")}
|
||||||
icon={UserX}
|
icon={UserX}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [classes, schedule, assignments, grades] = await Promise.all([
|
|
||||||
getStudentClasses(student.id),
|
|
||||||
getStudentSchedule(student.id),
|
|
||||||
getStudentHomeworkAssignments(student.id),
|
|
||||||
getStudentDashboardGrades(student.id),
|
|
||||||
])
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const in7Days = new Date(now)
|
|
||||||
in7Days.setDate(in7Days.getDate() + 7)
|
|
||||||
|
|
||||||
// 单次遍历统计,避免重复 filter(PERF-04)
|
|
||||||
let dueSoonCount = 0
|
|
||||||
let overdueCount = 0
|
|
||||||
let gradedCount = 0
|
|
||||||
for (const a of assignments) {
|
|
||||||
const status: StudentHomeworkProgressStatus = a.progressStatus
|
|
||||||
if (status === "graded") {
|
|
||||||
gradedCount++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!a.dueAt) continue
|
|
||||||
const due = new Date(a.dueAt)
|
|
||||||
if (due >= now && due <= in7Days) {
|
|
||||||
dueSoonCount++
|
|
||||||
} else if (due < now) {
|
|
||||||
overdueCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const todayWeekday = toWeekday(now)
|
|
||||||
const todayScheduleItems = schedule
|
|
||||||
.filter((s) => s.weekday === todayWeekday)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
classId: s.classId,
|
|
||||||
className: s.className,
|
|
||||||
course: s.course,
|
|
||||||
startTime: s.startTime,
|
|
||||||
endTime: s.endTime,
|
|
||||||
location: s.location ?? null,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
|
||||||
|
|
||||||
const upcomingAssignments = [...assignments]
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
||||||
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
|
||||||
return aDue - bDue
|
|
||||||
})
|
|
||||||
.slice(0, 6)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<StudentDashboard
|
<StudentDashboard
|
||||||
studentName={student.name}
|
studentName={student.name}
|
||||||
enrolledClassCount={classes.length}
|
{...dashboardProps}
|
||||||
dueSoonCount={dueSoonCount}
|
|
||||||
overdueCount={overdueCount}
|
|
||||||
gradedCount={gradedCount}
|
|
||||||
todayScheduleItems={todayScheduleItems}
|
|
||||||
upcomingAssignments={upcomingAssignments}
|
|
||||||
grades={grades}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function TeacherDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function TeacherDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("error.loadFailed")}
|
||||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
description={t("error.loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("error.retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
@@ -1,37 +1,21 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
|
import { TeacherDashboardView } from "@/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view"
|
||||||
import { getClassSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
import { getTeacherDashboardAction } from "@/modules/dashboard/actions"
|
||||||
import { getHomeworkAssignments, getHomeworkSubmissions, getTeacherGradeTrends } from "@/modules/homework/data-access"
|
|
||||||
import { getUserBasicInfo } from "@/modules/users/data-access"
|
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export const metadata = { title: "Dashboard - Next_Edu" }
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
return {
|
||||||
|
title: t("title.teacher"),
|
||||||
|
description: t("description.teacher"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function TeacherDashboardPage(): Promise<JSX.Element> {
|
export default async function TeacherDashboardPage(): Promise<JSX.Element> {
|
||||||
await getAuthContext()
|
const data = await getTeacherDashboardAction()
|
||||||
const teacherId = await getTeacherIdForMutations()
|
return <TeacherDashboardView data={data} />
|
||||||
|
|
||||||
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
|
||||||
getTeacherClasses({ teacherId }),
|
|
||||||
getClassSchedule({ teacherId }),
|
|
||||||
getHomeworkAssignments({ creatorId: teacherId }),
|
|
||||||
getHomeworkSubmissions({ creatorId: teacherId }),
|
|
||||||
getUserBasicInfo(teacherId),
|
|
||||||
getTeacherGradeTrends(teacherId),
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TeacherDashboardView
|
|
||||||
data={{
|
|
||||||
classes,
|
|
||||||
schedule,
|
|
||||||
assignments,
|
|
||||||
submissions,
|
|
||||||
teacherName: teacherProfile?.name ?? "Teacher",
|
|
||||||
gradeTrends,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,38 @@ export default getRequestConfig(async () => {
|
|||||||
const locale = isLocale(cookieValue) ? cookieValue : DEFAULT_LOCALE;
|
const locale = isLocale(cookieValue) ? cookieValue : DEFAULT_LOCALE;
|
||||||
|
|
||||||
// 按命名空间拆分加载,避免单文件过大
|
// 按命名空间拆分加载,避免单文件过大
|
||||||
const [common, auth, onboarding, classes, errors] = await Promise.all([
|
const [
|
||||||
|
common,
|
||||||
|
auth,
|
||||||
|
onboarding,
|
||||||
|
classes,
|
||||||
|
errors,
|
||||||
|
dashboard,
|
||||||
|
examHomework,
|
||||||
|
announcements,
|
||||||
|
messages,
|
||||||
|
settings,
|
||||||
|
textbooks,
|
||||||
|
grade,
|
||||||
|
lessonPreparation,
|
||||||
|
grades,
|
||||||
|
diagnostic,
|
||||||
|
] = await Promise.all([
|
||||||
import(`@/shared/i18n/messages/${locale}/common.json`),
|
import(`@/shared/i18n/messages/${locale}/common.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/auth.json`),
|
import(`@/shared/i18n/messages/${locale}/auth.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/onboarding.json`),
|
import(`@/shared/i18n/messages/${locale}/onboarding.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/classes.json`),
|
import(`@/shared/i18n/messages/${locale}/classes.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/errors.json`),
|
import(`@/shared/i18n/messages/${locale}/errors.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/dashboard.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/exam-homework.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/announcements.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/messages.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/settings.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/textbooks.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/grade.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/lesson-preparation.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/grades.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/diagnostic.json`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -35,6 +61,16 @@ export default getRequestConfig(async () => {
|
|||||||
onboarding: onboarding.default,
|
onboarding: onboarding.default,
|
||||||
classes: classes.default,
|
classes: classes.default,
|
||||||
errors: errors.default,
|
errors: errors.default,
|
||||||
|
dashboard: dashboard.default,
|
||||||
|
examHomework: examHomework.default,
|
||||||
|
announcements: announcements.default,
|
||||||
|
messages: messages.default,
|
||||||
|
settings: settings.default,
|
||||||
|
textbooks: textbooks.default,
|
||||||
|
grade: grade.default,
|
||||||
|
lessonPreparation: lessonPreparation.default,
|
||||||
|
grades: grades.default,
|
||||||
|
diagnostic: diagnostic.default,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
146
src/modules/dashboard/actions.ts
Normal file
146
src/modules/dashboard/actions.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { cache } from "react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||||
|
import {
|
||||||
|
getHomeworkAssignments,
|
||||||
|
getHomeworkSubmissions,
|
||||||
|
getStudentDashboardGrades,
|
||||||
|
getStudentHomeworkAssignments,
|
||||||
|
getTeacherGradeTrends,
|
||||||
|
} from "@/modules/homework/data-access"
|
||||||
|
import { getCurrentStudentUser, getUserBasicInfo } from "@/modules/users/data-access"
|
||||||
|
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||||
|
|
||||||
|
import { getAdminDashboardData } from "./data-access"
|
||||||
|
import type {
|
||||||
|
AdminDashboardData,
|
||||||
|
StudentDashboardProps,
|
||||||
|
TeacherDashboardData,
|
||||||
|
} from "./types"
|
||||||
|
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||||
|
import {
|
||||||
|
computeTeacherMetrics,
|
||||||
|
countStudentAssignments,
|
||||||
|
sortUpcomingAssignments,
|
||||||
|
toWeekday,
|
||||||
|
filterTodaySchedule,
|
||||||
|
type TeacherDashboardMetrics,
|
||||||
|
} from "./lib/dashboard-utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取管理员仪表盘数据。
|
||||||
|
* 权限:DASHBOARD_ADMIN_READ
|
||||||
|
*/
|
||||||
|
export async function getAdminDashboardAction(): Promise<AdminDashboardData> {
|
||||||
|
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||||
|
return getAdminDashboardData(ctx.dataScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取教师仪表盘数据(含派生指标)。
|
||||||
|
* 权限:DASHBOARD_TEACHER_READ
|
||||||
|
*/
|
||||||
|
export async function getTeacherDashboardAction(): Promise<TeacherDashboardData & {
|
||||||
|
metrics: TeacherDashboardMetrics
|
||||||
|
}> {
|
||||||
|
await requirePermission(Permissions.DASHBOARD_TEACHER_READ)
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
|
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
||||||
|
getTeacherClasses({ teacherId }),
|
||||||
|
getClassSchedule({ teacherId }),
|
||||||
|
getHomeworkAssignments({ creatorId: teacherId }),
|
||||||
|
getHomeworkSubmissions({ creatorId: teacherId }),
|
||||||
|
getUserBasicInfo(teacherId),
|
||||||
|
getTeacherGradeTrends(teacherId),
|
||||||
|
])
|
||||||
|
|
||||||
|
const metrics = computeTeacherMetrics(
|
||||||
|
classes,
|
||||||
|
schedule,
|
||||||
|
assignments,
|
||||||
|
submissions,
|
||||||
|
gradeTrends,
|
||||||
|
new Date(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
classes,
|
||||||
|
schedule,
|
||||||
|
assignments,
|
||||||
|
submissions,
|
||||||
|
teacherName: teacherProfile?.name ?? "Teacher",
|
||||||
|
gradeTrends,
|
||||||
|
metrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学生仪表盘数据(含派生指标)。
|
||||||
|
* 权限:DASHBOARD_STUDENT_READ
|
||||||
|
*/
|
||||||
|
export async function getStudentDashboardAction(): Promise<{
|
||||||
|
student: { id: string; name: string } | null
|
||||||
|
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
|
||||||
|
}> {
|
||||||
|
await requirePermission(Permissions.DASHBOARD_STUDENT_READ)
|
||||||
|
const student = await getCurrentStudentUser()
|
||||||
|
if (!student) {
|
||||||
|
return { student: null, dashboardProps: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [classes, schedule, assignments, grades] = await Promise.all([
|
||||||
|
getStudentClasses(student.id),
|
||||||
|
getStudentSchedule(student.id),
|
||||||
|
getStudentHomeworkAssignments(student.id),
|
||||||
|
getStudentDashboardGrades(student.id),
|
||||||
|
])
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const stats = countStudentAssignments(assignments, now)
|
||||||
|
const todayWeekday = toWeekday(now)
|
||||||
|
const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday)
|
||||||
|
const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
|
||||||
|
|
||||||
|
return {
|
||||||
|
student: { id: student.id, name: student.name },
|
||||||
|
dashboardProps: {
|
||||||
|
enrolledClassCount: classes.length,
|
||||||
|
dueSoonCount: stats.dueSoonCount,
|
||||||
|
overdueCount: stats.overdueCount,
|
||||||
|
gradedCount: stats.gradedCount,
|
||||||
|
todayScheduleItems,
|
||||||
|
upcomingAssignments,
|
||||||
|
grades,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家长仪表盘数据。
|
||||||
|
* 权限:DASHBOARD_PARENT_READ
|
||||||
|
*/
|
||||||
|
export async function getParentDashboardAction(): Promise<{
|
||||||
|
data: ParentDashboardData | null
|
||||||
|
hasChildren: boolean
|
||||||
|
}> {
|
||||||
|
const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ)
|
||||||
|
|
||||||
|
// 非 admin 且 dataScope 非 children 类型时,无孩子数据
|
||||||
|
if (
|
||||||
|
ctx.dataScope.type !== "all" &&
|
||||||
|
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
||||||
|
) {
|
||||||
|
return { data: null, hasChildren: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getParentDashboardData(ctx.userId)
|
||||||
|
return { data, hasChildren: data.children.length > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缓存版本(用于 RSC 直接调用,不走 Server Action 协议) */
|
||||||
|
export const getCachedAdminDashboard = cache(getAdminDashboardAction)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -27,43 +28,45 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { UserGrowthChart } from "./user-growth-chart"
|
import { UserGrowthChart } from "./user-growth-chart"
|
||||||
|
|
||||||
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
export async function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title={t("title.admin")}
|
||||||
description="System overview across users, learning content, and activity."
|
description={t("description.admin")}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||||
<Link href="/admin/users/import">
|
<Link href="/admin/users/import">
|
||||||
<Upload className="h-4 w-4" />
|
<Upload className="h-4 w-4" />
|
||||||
Import Users
|
{t("quickActions.importUsers")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" className="gap-2">
|
<Button asChild size="sm" className="gap-2">
|
||||||
<Link href="/admin/announcements">
|
<Link href="/admin/announcements">
|
||||||
<Megaphone className="h-4 w-4" />
|
<Megaphone className="h-4 w-4" />
|
||||||
New Announcement
|
{t("quickActions.newAnnouncement")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Badge variant="outline" className="gap-2">
|
<Badge variant="outline" className="gap-2">
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
{data.activeSessionsCount} active sessions
|
{t("badge.activeSessions", { count: data.activeSessionsCount })}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="gap-2">
|
<Badge variant="outline" className="gap-2">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
{data.userCount} users
|
{t("badge.users", { count: data.userCount })}
|
||||||
</Badge>
|
</Badge>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard title="Users" value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||||
<StatCard title="Classes" value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||||
<StatCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||||
<StatCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
{/* 快捷操作 */}
|
||||||
@@ -71,38 +74,38 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/users/import"
|
href="/admin/users/import"
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="批量导入用户"
|
title={t("quickActions.importUsers")}
|
||||||
description="通过 Excel 批量创建用户账号"
|
description={t("quickActions.importUsersDesc")}
|
||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/announcements"
|
href="/admin/announcements"
|
||||||
icon={Megaphone}
|
icon={Megaphone}
|
||||||
title="发布公告"
|
title={t("quickActions.newAnnouncement")}
|
||||||
description="向全校或指定年级/班级发布通知"
|
description={t("quickActions.newAnnouncementDesc")}
|
||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/scheduling/changes"
|
href="/admin/scheduling/changes"
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
title="审批课表变更"
|
title={t("quickActions.approveSchedule")}
|
||||||
description="审核教师提交的课表变更与代课申请"
|
description={t("quickActions.approveScheduleDesc")}
|
||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/scheduling/auto"
|
href="/admin/scheduling/auto"
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
title="自动排课"
|
title={t("quickActions.autoSchedule")}
|
||||||
description="基于规则自动生成周课表"
|
description={t("quickActions.autoScheduleDesc")}
|
||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/files"
|
href="/admin/files"
|
||||||
icon={FolderOpen}
|
icon={FolderOpen}
|
||||||
title="文件管理"
|
title={t("quickActions.fileManagement")}
|
||||||
description="查看与管理系统中所有上传文件"
|
description={t("quickActions.fileManagementDesc")}
|
||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
href="/admin/attendance"
|
href="/admin/attendance"
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
title="考勤总览"
|
title={t("quickActions.attendanceOverview")}
|
||||||
description="查看全校所有班级的考勤记录"
|
description={t("quickActions.attendanceOverviewDesc")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">用户增长趋势(近30天)</CardTitle>
|
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<UserGrowthChart data={data.userGrowth} />
|
<UserGrowthChart data={data.userGrowth} />
|
||||||
@@ -118,7 +121,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">作业提交趋势(近7天)</CardTitle>
|
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<UserGrowthChart data={data.homeworkTrend} />
|
<UserGrowthChart data={data.homeworkTrend} />
|
||||||
@@ -129,11 +132,11 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
<Card className="lg:col-span-1">
|
<Card className="lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>User Roles</CardTitle>
|
<CardTitle>{t("sections.userRoles")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{data.userRoleCounts.length === 0 ? (
|
{data.userRoleCounts.length === 0 ? (
|
||||||
<EmptyState title="No users" description="No user records found." />
|
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
|
||||||
) : (
|
) : (
|
||||||
data.userRoleCounts.map((r) => (
|
data.userRoleCounts.map((r) => (
|
||||||
<div key={r.role} className="flex items-center justify-between">
|
<div key={r.role} className="flex items-center justify-between">
|
||||||
@@ -147,43 +150,43 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
|
|
||||||
<Card className="lg:col-span-1">
|
<Card className="lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Content</CardTitle>
|
<CardTitle>{t("sections.content")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid gap-3">
|
||||||
<ContentRow label="Textbooks" value={data.textbookCount} icon={<Library 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="Chapters" value={data.chapterCount} icon={<BookOpen 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="Questions" value={data.questionCount} icon={<FileText 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="Exams" value={data.examCount} icon={<ClipboardList 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="lg:col-span-1">
|
<Card className="lg:col-span-1">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Homework Activity</CardTitle>
|
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid gap-3">
|
||||||
<ContentRow label="Assignments" value={data.homeworkAssignmentCount} icon={<ClipboardList 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="Submissions" value={data.homeworkSubmissionCount} icon={<FileText 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="To grade" value={data.homeworkSubmissionToGradeCount} icon={<Activity 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Recent Users</CardTitle>
|
<CardTitle>{t("sections.recentUsers")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{data.recentUsers.length === 0 ? (
|
{data.recentUsers.length === 0 ? (
|
||||||
<EmptyState title="No users yet" description="Seed the database to see users here." />
|
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("table.name")}</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>{t("table.email")}</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>{t("table.role")}</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>{t("table.created")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -203,7 +206,7 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
|||||||
<div className="flex justify-end pt-4">
|
<div className="flex justify-end pt-4">
|
||||||
<Button asChild variant="ghost" size="sm">
|
<Button asChild variant="ghost" size="sm">
|
||||||
<Link href="/admin/users">
|
<Link href="/admin/users">
|
||||||
查看全部用户
|
{t("sections.viewAllUsers")}
|
||||||
<ChevronRight className="ml-1 h-4 w-4" />
|
<ChevronRight className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -15,6 +16,8 @@ interface UserGrowthChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
@@ -38,7 +41,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
|||||||
stroke="hsl(var(--primary))"
|
stroke="hsl(var(--primary))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
||||||
name="新增用户"
|
name={t("chart.newUsers")}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -1,44 +1,25 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import { useTranslations } from "next-intl"
|
||||||
import { CalendarDays, BookOpen, PenTool } from "lucide-react"
|
import { formatLongDate } from "@/shared/lib/utils"
|
||||||
|
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
interface StudentDashboardHeaderProps {
|
||||||
|
studentName: string
|
||||||
|
}
|
||||||
|
|
||||||
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
|
export function StudentDashboardHeader({ studentName }: StudentDashboardHeaderProps) {
|
||||||
const hour = new Date().getHours()
|
const t = useTranslations("dashboard")
|
||||||
let greeting = "Welcome back"
|
const today = formatLongDate(new Date())
|
||||||
if (hour < 12) greeting = "Good morning"
|
const greetingKey = getGreetingKey(new Date())
|
||||||
else if (hour < 18) greeting = "Good afternoon"
|
|
||||||
else greeting = "Good evening"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
<div className="text-sm text-muted-foreground">
|
{t(`greeting.${greetingKey}`)},{studentName}
|
||||||
{greeting}, {studentName}. Here's what's happening today.
|
</h2>
|
||||||
</div>
|
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
||||||
<Link href="/student/schedule">
|
|
||||||
<CalendarDays className="h-4 w-4" />
|
|
||||||
Schedule
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
||||||
<Link href="/student/learning/textbooks">
|
|
||||||
<BookOpen className="h-4 w-4" />
|
|
||||||
Textbooks
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild size="sm" className="gap-2">
|
|
||||||
<Link href="/student/learning/assignments">
|
|
||||||
<PenTool className="h-4 w-4" />
|
|
||||||
Assignments
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { StudentStatsGrid } from "./student-stats-grid"
|
|||||||
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
|
||||||
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
|
||||||
|
|
||||||
export function StudentDashboard({
|
export async function StudentDashboard({
|
||||||
studentName,
|
studentName,
|
||||||
enrolledClassCount,
|
enrolledClassCount,
|
||||||
dueSoonCount,
|
dueSoonCount,
|
||||||
|
|||||||
@@ -1,72 +1,77 @@
|
|||||||
import { BookOpen, CheckCircle, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
import { BookOpen, CheckCircle, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
import type { StudentRanking } from "@/modules/homework/types"
|
import type { StudentRanking } from "@/modules/homework/types"
|
||||||
|
|
||||||
export function StudentStatsGrid({
|
interface StudentStatsGridProps {
|
||||||
enrolledClassCount,
|
|
||||||
dueSoonCount,
|
|
||||||
overdueCount,
|
|
||||||
gradedCount,
|
|
||||||
ranking,
|
|
||||||
}: {
|
|
||||||
enrolledClassCount: number
|
enrolledClassCount: number
|
||||||
dueSoonCount: number
|
dueSoonCount: number
|
||||||
overdueCount: number
|
overdueCount: number
|
||||||
gradedCount: number
|
gradedCount: number
|
||||||
ranking: StudentRanking | null
|
ranking: StudentRanking | null
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export async function StudentStatsGrid({
|
||||||
|
enrolledClassCount,
|
||||||
|
dueSoonCount,
|
||||||
|
overdueCount,
|
||||||
|
gradedCount,
|
||||||
|
ranking,
|
||||||
|
}: StudentStatsGridProps) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Enrolled Classes"
|
title={t("stats.enrolledClasses")}
|
||||||
value={String(enrolledClassCount)}
|
value={String(enrolledClassCount)}
|
||||||
description="Active enrollments"
|
description={t("stats.activeEnrollments")}
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
href="/student/learning/courses"
|
href="/student/learning/courses"
|
||||||
color="text-emerald-500"
|
color="text-emerald-500"
|
||||||
valueClassName="text-emerald-500 tabular-nums"
|
valueClassName="text-emerald-500 tabular-nums"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Average Score"
|
title={t("stats.averageScore")}
|
||||||
value={ranking ? `${Math.round(ranking.percentage)}%` : "-"}
|
value={ranking ? `${Math.round(ranking.percentage)}%` : "-"}
|
||||||
description={ranking ? "Overall performance" : "No grades yet"}
|
description={ranking ? t("stats.overallPerformance") : t("stats.noGradesYet")}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
href="/student/grades"
|
href="/student/grades"
|
||||||
color="text-blue-500"
|
color="text-blue-500"
|
||||||
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
|
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Class Rank"
|
title={t("stats.classRank")}
|
||||||
value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
||||||
description={ranking ? "Current position" : "No ranking yet"}
|
description={ranking ? t("stats.currentPosition") : t("stats.noRankingYet")}
|
||||||
icon={Trophy}
|
icon={Trophy}
|
||||||
href="/student/grades"
|
href="/student/grades"
|
||||||
color="text-purple-500"
|
color="text-purple-500"
|
||||||
valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"}
|
valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Graded"
|
title={t("stats.graded")}
|
||||||
value={String(gradedCount)}
|
value={String(gradedCount)}
|
||||||
description="Completed assignments"
|
description={t("stats.completedAssignments")}
|
||||||
icon={CheckCircle}
|
icon={CheckCircle}
|
||||||
href="/student/learning/assignments"
|
href="/student/learning/assignments"
|
||||||
color="text-green-500"
|
color="text-green-500"
|
||||||
valueClassName="text-green-500 tabular-nums"
|
valueClassName="text-green-500 tabular-nums"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Due Soon"
|
title={t("stats.dueSoon")}
|
||||||
value={String(dueSoonCount)}
|
value={String(dueSoonCount)}
|
||||||
description="Next 7 days"
|
description={t("stats.next7Days")}
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
href="/student/learning/assignments"
|
href="/student/learning/assignments"
|
||||||
color={dueSoonCount > 0 ? "text-orange-500" : undefined}
|
color={dueSoonCount > 0 ? "text-orange-500" : undefined}
|
||||||
valueClassName={dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"}
|
valueClassName={dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Overdue"
|
title={t("stats.overdue")}
|
||||||
value={String(overdueCount)}
|
value={String(overdueCount)}
|
||||||
description="Needs attention"
|
description={t("stats.needsAttention")}
|
||||||
icon={TriangleAlert}
|
icon={TriangleAlert}
|
||||||
href="/student/learning/assignments"
|
href="/student/learning/assignments"
|
||||||
color={overdueCount > 0 ? "text-red-500" : undefined}
|
color={overdueCount > 0 ? "text-red-500" : undefined}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { formatLongDate } from "@/shared/lib/utils"
|
import { formatLongDate } from "@/shared/lib/utils"
|
||||||
|
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||||
|
|
||||||
interface TeacherDashboardHeaderProps {
|
interface TeacherDashboardHeaderProps {
|
||||||
@@ -8,18 +10,17 @@ interface TeacherDashboardHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
export function TeacherDashboardHeader({ teacherName }: TeacherDashboardHeaderProps) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
const today = formatLongDate(new Date())
|
const today = formatLongDate(new Date())
|
||||||
const hour = new Date().getHours()
|
const greetingKey = getGreetingKey(new Date())
|
||||||
let greeting = "欢迎回来"
|
|
||||||
if (hour < 12) greeting = "早上好"
|
|
||||||
else if (hour < 18) greeting = "下午好"
|
|
||||||
else greeting = "晚上好"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{greeting},{teacherName}</h2>
|
<h2 className="text-2xl font-bold tracking-tight">
|
||||||
<p className="text-muted-foreground">今天是 {today},以下是今日概览。</p>
|
{t(`greeting.${greetingKey}`)},{teacherName}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||||
</div>
|
</div>
|
||||||
<TeacherQuickActions />
|
<TeacherQuickActions />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { TeacherDashboardData, TeacherTodayScheduleItem } from "@/modules/dashboard/types"
|
import type { TeacherDashboardData } from "@/modules/dashboard/types"
|
||||||
|
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import type { TeacherTodoItem } from "./teacher-todo-card"
|
||||||
|
|
||||||
import { TeacherClassesCard } from "./teacher-classes-card"
|
import { TeacherClassesCard } from "./teacher-classes-card"
|
||||||
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
|
import { TeacherDashboardHeader } from "./teacher-dashboard-header"
|
||||||
@@ -7,52 +10,36 @@ import { RecentSubmissions } from "./recent-submissions"
|
|||||||
import { TeacherSchedule } from "./teacher-schedule"
|
import { TeacherSchedule } from "./teacher-schedule"
|
||||||
import { TeacherStats } from "./teacher-stats"
|
import { TeacherStats } from "./teacher-stats"
|
||||||
import { TeacherGradeTrends } from "./teacher-grade-trends"
|
import { TeacherGradeTrends } from "./teacher-grade-trends"
|
||||||
import { TeacherTodoCard, type TeacherTodoItem } from "./teacher-todo-card"
|
import { TeacherTodoCard } from "./teacher-todo-card"
|
||||||
|
|
||||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
interface TeacherDashboardViewProps {
|
||||||
const day = d.getDay()
|
data: TeacherDashboardData & { metrics: TeacherDashboardMetrics }
|
||||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
export async function TeacherDashboardView({ data }: TeacherDashboardViewProps) {
|
||||||
const todayWeekday = toWeekday(new Date())
|
const t = await getTranslations("dashboard")
|
||||||
|
const { metrics } = data
|
||||||
|
|
||||||
const classNameById = new Map(data.classes.map((c) => [c.id, c.name] as const))
|
// 待办聚合(使用预计算指标)
|
||||||
const todayScheduleItems: TeacherTodayScheduleItem[] = data.schedule
|
|
||||||
.filter((s) => s.weekday === todayWeekday)
|
|
||||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
|
||||||
.map((s): TeacherTodayScheduleItem => ({
|
|
||||||
id: s.id,
|
|
||||||
classId: s.classId,
|
|
||||||
className: classNameById.get(s.classId) ?? "Class",
|
|
||||||
course: s.course,
|
|
||||||
startTime: s.startTime,
|
|
||||||
endTime: s.endTime,
|
|
||||||
location: s.location ?? null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const submittedSubmissions = data.submissions.filter((s) => Boolean(s.submittedAt))
|
|
||||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
|
||||||
|
|
||||||
const submissionsToGrade = submittedSubmissions
|
|
||||||
.filter(s => s.status === "submitted")
|
|
||||||
.sort((a, b) => (a.submittedAt ? new Date(a.submittedAt).getTime() : 0) - (b.submittedAt ? new Date(b.submittedAt).getTime() : 0))
|
|
||||||
.slice(0, 6);
|
|
||||||
|
|
||||||
const activeAssignmentsCount = data.assignments.filter(a => a.status === "published").length
|
|
||||||
|
|
||||||
const totalTrendScore = data.gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
|
||||||
const averageScore = data.gradeTrends.length > 0 ? totalTrendScore / data.gradeTrends.length : 0
|
|
||||||
|
|
||||||
const totalSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
|
||||||
const totalPotentialSubmissions = data.gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
|
|
||||||
const submissionRate = totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
|
|
||||||
|
|
||||||
// 待办聚合
|
|
||||||
const todoItems: TeacherTodoItem[] = [
|
const todoItems: TeacherTodoItem[] = [
|
||||||
{ label: "待批改作业", count: toGradeCount, href: "/teacher/homework/submissions", variant: toGradeCount > 0 ? "urgent" : "normal" },
|
{
|
||||||
{ label: "今日待考勤", count: todayScheduleItems.length, href: "/teacher/attendance/sheet", variant: "info" },
|
label: t("todo.toGrade"),
|
||||||
{ label: "进行中作业", count: activeAssignmentsCount, href: "/teacher/homework/assignments", variant: "normal" },
|
count: metrics.toGradeCount,
|
||||||
|
href: "/teacher/homework/submissions",
|
||||||
|
variant: metrics.toGradeCount > 0 ? "urgent" : "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("todo.todayAttendance"),
|
||||||
|
count: metrics.todayScheduleItems.length,
|
||||||
|
href: "/teacher/attendance/sheet",
|
||||||
|
variant: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("todo.activeAssignments"),
|
||||||
|
count: metrics.activeAssignmentsCount,
|
||||||
|
href: "/teacher/homework/assignments",
|
||||||
|
variant: "normal",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,31 +47,31 @@ export function TeacherDashboardView({ data }: { data: TeacherDashboardData }) {
|
|||||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||||
|
|
||||||
<TeacherStats
|
<TeacherStats
|
||||||
toGradeCount={toGradeCount}
|
toGradeCount={metrics.toGradeCount}
|
||||||
activeAssignmentsCount={activeAssignmentsCount}
|
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||||
averageScore={averageScore}
|
averageScore={metrics.averageScore}
|
||||||
submissionRate={submissionRate}
|
submissionRate={metrics.submissionRate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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={todayScheduleItems} />
|
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||||
</div>
|
</div>
|
||||||
<TeacherTodoCard items={todoItems} />
|
<TeacherTodoCard items={todoItems} />
|
||||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||||
<RecentSubmissions
|
<RecentSubmissions
|
||||||
submissions={submissionsToGrade}
|
submissions={metrics.submissionsToGrade}
|
||||||
title="待批改"
|
title={t("sections.pendingGrading")}
|
||||||
emptyTitle="全部批改完成!"
|
emptyTitle={t("empty.allGraded")}
|
||||||
emptyDescription="暂无待批改的提交。"
|
emptyDescription={t("empty.allGradedDesc")}
|
||||||
/>
|
/>
|
||||||
</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={todayScheduleItems} />
|
<TeacherSchedule items={metrics.todayScheduleItems} />
|
||||||
</div>
|
</div>
|
||||||
<TeacherHomeworkCard assignments={data.assignments} />
|
<TeacherHomeworkCard assignments={data.assignments} />
|
||||||
<TeacherClassesCard classes={data.classes} />
|
<TeacherClassesCard classes={data.classes} />
|
||||||
|
|||||||
@@ -1,27 +1,30 @@
|
|||||||
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
|
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react"
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
|
|
||||||
interface TeacherStatsProps {
|
interface TeacherStatsProps {
|
||||||
toGradeCount: number;
|
toGradeCount: number
|
||||||
activeAssignmentsCount: number;
|
activeAssignmentsCount: number
|
||||||
averageScore: number;
|
averageScore: number
|
||||||
submissionRate: number;
|
submissionRate: number
|
||||||
isLoading?: boolean;
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherStats({
|
export async function TeacherStats({
|
||||||
toGradeCount,
|
toGradeCount,
|
||||||
activeAssignmentsCount,
|
activeAssignmentsCount,
|
||||||
averageScore,
|
averageScore,
|
||||||
submissionRate,
|
submissionRate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: TeacherStatsProps) {
|
}: TeacherStatsProps) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Needs Grading"
|
title={t("stats.needsGrading")}
|
||||||
value={String(toGradeCount)}
|
value={String(toGradeCount)}
|
||||||
description="Submissions pending review"
|
description={t("stats.submissionsPendingReview")}
|
||||||
icon={FileCheck}
|
icon={FileCheck}
|
||||||
href="/teacher/homework/submissions?status=submitted"
|
href="/teacher/homework/submissions?status=submitted"
|
||||||
highlight={toGradeCount > 0}
|
highlight={toGradeCount > 0}
|
||||||
@@ -29,32 +32,32 @@ export function TeacherStats({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Active Assignments"
|
title={t("stats.activeAssignments")}
|
||||||
value={String(activeAssignmentsCount)}
|
value={String(activeAssignmentsCount)}
|
||||||
description="Published and ongoing"
|
description={t("stats.publishedAndOngoing")}
|
||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
href="/teacher/homework/assignments?status=published"
|
href="/teacher/homework/assignments?status=published"
|
||||||
color="text-blue-500"
|
color="text-blue-500"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Average Score"
|
title={t("stats.averageScore")}
|
||||||
value={`${Math.round(averageScore)}%`}
|
value={`${Math.round(averageScore)}%`}
|
||||||
description="Across recent assignments"
|
description={t("stats.acrossRecentAssignments")}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
href="#grade-trends"
|
href="#grade-trends"
|
||||||
color="text-emerald-500"
|
color="text-emerald-500"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Submission Rate"
|
title={t("stats.submissionRate")}
|
||||||
value={`${Math.round(submissionRate)}%`}
|
value={`${Math.round(submissionRate)}%`}
|
||||||
description="Overall completion rate"
|
description={t("stats.overallCompletionRate")}
|
||||||
icon={BarChart}
|
icon={BarChart}
|
||||||
href="#grade-trends"
|
href="#grade-trends"
|
||||||
color="text-purple-500"
|
color="text-purple-500"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { ClipboardCheck, CalendarCheck, FileEdit, AlertCircle, ChevronRight } from "lucide-react"
|
||||||
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
export interface TeacherTodoItem {
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
href: string
|
||||||
|
variant: "urgent" | "normal" | "info"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeacherTodoCardProps {
|
||||||
|
items: TeacherTodoItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<TeacherTodoItem["variant"], { icon: typeof AlertCircle; iconColor: string; badge: string }> = {
|
||||||
|
urgent: { icon: AlertCircle, iconColor: "text-destructive", badge: "bg-destructive text-destructive-foreground" },
|
||||||
|
normal: { icon: ClipboardCheck, iconColor: "text-amber-500", badge: "bg-amber-500 text-white" },
|
||||||
|
info: { icon: CalendarCheck, iconColor: "text-blue-500", badge: "bg-blue-500 text-white" },
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
|
const hasItems = items.some((item) => item.count > 0)
|
||||||
|
const totalPending = items.reduce((acc, item) => acc + (item.count > 0 ? 1 : 0), 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<FileEdit className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||||
|
{t("todo.title")}
|
||||||
|
{totalPending > 0 && (
|
||||||
|
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground tabular-nums">
|
||||||
|
{totalPending}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!hasItems ? (
|
||||||
|
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<CalendarCheck className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("todo.empty")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items
|
||||||
|
.filter((item) => item.count > 0)
|
||||||
|
.sort((a, b) => (a.variant === "urgent" ? -1 : 1) - (b.variant === "urgent" ? -1 : 1))
|
||||||
|
.map((item, idx) => {
|
||||||
|
const style = VARIANT_STYLES[item.variant]
|
||||||
|
const Icon = style.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={idx}
|
||||||
|
href={item.href}
|
||||||
|
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Icon className={cn("h-4 w-4 shrink-0", style.iconColor)} aria-hidden="true" />
|
||||||
|
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className={cn("inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-medium tabular-nums", style.badge)}>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
src/modules/dashboard/lib/dashboard-utils.ts
Normal file
190
src/modules/dashboard/lib/dashboard-utils.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* 仪表盘纯逻辑工具函数(与 UI 分离,便于单测)。
|
||||||
|
*
|
||||||
|
* 所有函数均为纯函数:相同输入 → 相同输出,无副作用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HomeworkAssignmentListItem,
|
||||||
|
HomeworkSubmissionListItem,
|
||||||
|
StudentHomeworkAssignmentListItem,
|
||||||
|
StudentHomeworkProgressStatus,
|
||||||
|
TeacherGradeTrendItem,
|
||||||
|
} from "@/modules/homework/types"
|
||||||
|
import type { ClassScheduleItem, TeacherClass } from "@/modules/classes/types"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StudentTodayScheduleItem,
|
||||||
|
TeacherTodayScheduleItem,
|
||||||
|
TeacherDashboardData,
|
||||||
|
} from "@/modules/dashboard/types"
|
||||||
|
|
||||||
|
/** 周一=1 ... 周日=7 */
|
||||||
|
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Date 转换为 1-7 周几表示(周一=1,周日=7)。
|
||||||
|
* getDay() 返回 0(周日)-6(周六),需映射为 1-7。
|
||||||
|
*/
|
||||||
|
export function toWeekday(d: Date): Weekday {
|
||||||
|
const day = d.getDay()
|
||||||
|
if (day < 0 || day > 6) {
|
||||||
|
throw new Error(`Invalid day from getDay(): ${day}`)
|
||||||
|
}
|
||||||
|
const WEEKDAY_MAP: readonly Weekday[] = [7, 1, 2, 3, 4, 5, 6]
|
||||||
|
return WEEKDAY_MAP[day]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 学生作业统计结果 */
|
||||||
|
export interface StudentAssignmentStats {
|
||||||
|
dueSoonCount: number
|
||||||
|
overdueCount: number
|
||||||
|
gradedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次遍历统计学生作业状态:即将到期 / 已逾期 / 已批改。
|
||||||
|
*/
|
||||||
|
export function countStudentAssignments(
|
||||||
|
assignments: readonly StudentHomeworkAssignmentListItem[],
|
||||||
|
now: Date,
|
||||||
|
dueSoonWindowDays = 7,
|
||||||
|
): StudentAssignmentStats {
|
||||||
|
const in7Days = new Date(now)
|
||||||
|
in7Days.setDate(in7Days.getDate() + dueSoonWindowDays)
|
||||||
|
|
||||||
|
let dueSoonCount = 0
|
||||||
|
let overdueCount = 0
|
||||||
|
let gradedCount = 0
|
||||||
|
|
||||||
|
for (const a of assignments) {
|
||||||
|
const status: StudentHomeworkProgressStatus = a.progressStatus
|
||||||
|
if (status === "graded") {
|
||||||
|
gradedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!a.dueAt) continue
|
||||||
|
const due = new Date(a.dueAt)
|
||||||
|
if (due >= now && due <= in7Days) {
|
||||||
|
dueSoonCount++
|
||||||
|
} else if (due < now) {
|
||||||
|
overdueCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dueSoonCount, overdueCount, gradedCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按截止日期升序排序作业,取前 N 条作为「即将到期」列表。
|
||||||
|
* 无截止日期的作业排到最后。
|
||||||
|
*/
|
||||||
|
export function sortUpcomingAssignments(
|
||||||
|
assignments: readonly StudentHomeworkAssignmentListItem[],
|
||||||
|
limit = 6,
|
||||||
|
): StudentHomeworkAssignmentListItem[] {
|
||||||
|
return [...assignments]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
|
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
|
return aDue - bDue
|
||||||
|
})
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
||||||
|
*/
|
||||||
|
export function filterTodaySchedule(
|
||||||
|
schedule: readonly ClassScheduleItem[],
|
||||||
|
weekday: Weekday,
|
||||||
|
classNameById?: ReadonlyMap<string, string>,
|
||||||
|
): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] {
|
||||||
|
return schedule
|
||||||
|
.filter((s) => s.weekday === weekday)
|
||||||
|
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
classId: s.classId,
|
||||||
|
className: classNameById?.get(s.classId) ?? "Class",
|
||||||
|
course: s.course,
|
||||||
|
startTime: s.startTime,
|
||||||
|
endTime: s.endTime,
|
||||||
|
location: s.location ?? null,
|
||||||
|
})) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 教师仪表盘派生指标 */
|
||||||
|
export interface TeacherDashboardMetrics {
|
||||||
|
todayScheduleItems: TeacherTodayScheduleItem[]
|
||||||
|
toGradeCount: number
|
||||||
|
submissionsToGrade: HomeworkSubmissionListItem[]
|
||||||
|
activeAssignmentsCount: number
|
||||||
|
averageScore: number
|
||||||
|
submissionRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算教师仪表盘派生指标:待批改数、进行中作业数、平均分、提交率等。
|
||||||
|
*/
|
||||||
|
export function computeTeacherMetrics(
|
||||||
|
classes: readonly TeacherClass[],
|
||||||
|
schedule: readonly ClassScheduleItem[],
|
||||||
|
assignments: readonly HomeworkAssignmentListItem[],
|
||||||
|
submissions: readonly HomeworkSubmissionListItem[],
|
||||||
|
gradeTrends: readonly TeacherGradeTrendItem[],
|
||||||
|
now: Date,
|
||||||
|
): TeacherDashboardMetrics {
|
||||||
|
const todayWeekday = toWeekday(now)
|
||||||
|
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
||||||
|
|
||||||
|
const todayScheduleItems = filterTodaySchedule(
|
||||||
|
schedule,
|
||||||
|
todayWeekday,
|
||||||
|
classNameById,
|
||||||
|
) as TeacherTodayScheduleItem[]
|
||||||
|
|
||||||
|
const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt))
|
||||||
|
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||||
|
|
||||||
|
const submissionsToGrade = submittedSubmissions
|
||||||
|
.filter((s) => s.status === "submitted")
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.submittedAt ? new Date(a.submittedAt).getTime() : 0) -
|
||||||
|
(b.submittedAt ? new Date(b.submittedAt).getTime() : 0),
|
||||||
|
)
|
||||||
|
.slice(0, 6)
|
||||||
|
|
||||||
|
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
|
||||||
|
|
||||||
|
const totalTrendScore = gradeTrends.reduce((acc, curr) => acc + curr.averageScore, 0)
|
||||||
|
const averageScore = gradeTrends.length > 0 ? totalTrendScore / gradeTrends.length : 0
|
||||||
|
|
||||||
|
const totalSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.submissionCount, 0)
|
||||||
|
const totalPotentialSubmissions = gradeTrends.reduce((acc, curr) => acc + curr.totalStudents, 0)
|
||||||
|
const submissionRate =
|
||||||
|
totalPotentialSubmissions > 0 ? (totalSubmissions / totalPotentialSubmissions) * 100 : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayScheduleItems,
|
||||||
|
toGradeCount,
|
||||||
|
submissionsToGrade,
|
||||||
|
activeAssignmentsCount,
|
||||||
|
averageScore,
|
||||||
|
submissionRate,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前小时返回问候语时段 key(morning / afternoon / evening)。
|
||||||
|
*/
|
||||||
|
export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
|
||||||
|
const hour = now.getHours()
|
||||||
|
if (hour < 12) return "morning"
|
||||||
|
if (hour < 18) return "afternoon"
|
||||||
|
return "evening"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重导出 TeacherDashboardData 便于 actions 使用 */
|
||||||
|
export type { TeacherDashboardData }
|
||||||
@@ -1,74 +1,106 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { CalendarCheck, CalendarDays, GraduationCap, Users } from "lucide-react"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import {
|
||||||
|
CalendarCheck,
|
||||||
|
CalendarDays,
|
||||||
|
GraduationCap,
|
||||||
|
Megaphone,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { ChildCard } from "./child-card"
|
|
||||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||||
|
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
import { ChildCard } from "./child-card"
|
||||||
|
import { ParentAttentionBanner } from "./parent-attention-banner"
|
||||||
|
|
||||||
export function ParentDashboard({ data }: { data: ParentDashboardData }) {
|
export async function ParentDashboard({ data }: { data: ParentDashboardData }) {
|
||||||
|
const t = await getTranslations("dashboard")
|
||||||
const { parentName, children } = data
|
const { parentName, children } = data
|
||||||
const hasChildren = children.length > 0
|
const hasChildren = children.length > 0
|
||||||
|
|
||||||
const hour = new Date().getHours()
|
const greetingKey = getGreetingKey(new Date())
|
||||||
let greeting = "Welcome"
|
|
||||||
if (hour < 12) greeting = "Good morning"
|
const QUICK_ENTRIES = [
|
||||||
else if (hour < 18) greeting = "Good afternoon"
|
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
|
||||||
else greeting = "Good evening"
|
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
|
||||||
|
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
|
||||||
|
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
|
||||||
|
] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Parent Dashboard</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{greeting}
|
{t(`greeting.${greetingKey}`)}
|
||||||
{parentName ? `, ${parentName}` : ""}. Here's an overview of your children.
|
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
||||||
<Link href="/parent/grades">
|
|
||||||
<GraduationCap className="h-4 w-4" />
|
|
||||||
Grades
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
||||||
<Link href="/parent/attendance">
|
|
||||||
<CalendarCheck className="h-4 w-4" />
|
|
||||||
Attendance
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
|
||||||
<Link href="/announcements">
|
|
||||||
<CalendarDays className="h-4 w-4" />
|
|
||||||
Announcements
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasChildren ? (
|
{hasChildren ? (
|
||||||
<EmptyState
|
|
||||||
icon={Users}
|
|
||||||
title="No children linked"
|
|
||||||
description="Your account is not linked to any student accounts yet. Please contact the school administrator."
|
|
||||||
className="border-none shadow-none"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
|
<ParentAttentionBanner data={data} />
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label={t("quickActions.announcements")}
|
||||||
|
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||||
|
>
|
||||||
|
{QUICK_ENTRIES.map((entry) => (
|
||||||
|
<Link
|
||||||
|
key={entry.href}
|
||||||
|
href={entry.href}
|
||||||
|
className="group"
|
||||||
|
aria-label={entry.label}
|
||||||
|
>
|
||||||
|
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
|
||||||
|
<entry.icon
|
||||||
|
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{entry.label}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" aria-hidden />
|
||||||
<span>
|
<span>
|
||||||
{children.length} {children.length === 1 ? "child" : "children"} linked
|
{t("badge.childrenLinked", { count: children.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
{/* 移动端水平滑动卡片,桌面端网格布局 */}
|
||||||
|
<div
|
||||||
|
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
|
||||||
|
aria-label={t("title.parent")}
|
||||||
|
>
|
||||||
|
{children.map((child) => (
|
||||||
|
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
|
||||||
|
<ChildCard child={child} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<ChildCard key={child.basicInfo.id} child={child} />
|
<ChildCard key={child.basicInfo.id} child={child} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title={t("empty.noChildren")}
|
||||||
|
description={t("empty.noChildrenDesc")}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
action={{
|
||||||
|
label: t("empty.contactSupport"),
|
||||||
|
href: "/messages",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
124
src/shared/i18n/messages/en/dashboard.json
Normal file
124
src/shared/i18n/messages/en/dashboard.json
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"admin": "Admin Console",
|
||||||
|
"teacher": "Teacher Workspace",
|
||||||
|
"student": "Student Center",
|
||||||
|
"parent": "Parent Center",
|
||||||
|
"root": "Dashboard"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"admin": "System overview across users, learning content, and activity.",
|
||||||
|
"teacher": "Today's teaching overview.",
|
||||||
|
"student": "Today's learning overview.",
|
||||||
|
"parent": "An overview of your children."
|
||||||
|
},
|
||||||
|
"greeting": {
|
||||||
|
"morning": "Good morning",
|
||||||
|
"afternoon": "Good afternoon",
|
||||||
|
"evening": "Good evening",
|
||||||
|
"welcome": "Welcome back",
|
||||||
|
"todayIs": "Today is {date}. Here's your overview.",
|
||||||
|
"overview": "Here's today's overview."
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"users": "Users",
|
||||||
|
"classes": "Classes",
|
||||||
|
"activeSessions": "Active sessions",
|
||||||
|
"homeworkPublished": "Homework (published)",
|
||||||
|
"toGrade": "To grade",
|
||||||
|
"enrolledClasses": "Enrolled Classes",
|
||||||
|
"activeEnrollments": "Active enrollments",
|
||||||
|
"averageScore": "Average Score",
|
||||||
|
"overallPerformance": "Overall performance",
|
||||||
|
"noGradesYet": "No grades yet",
|
||||||
|
"classRank": "Class Rank",
|
||||||
|
"currentPosition": "Current position",
|
||||||
|
"noRankingYet": "No ranking yet",
|
||||||
|
"graded": "Graded",
|
||||||
|
"completedAssignments": "Completed assignments",
|
||||||
|
"dueSoon": "Due Soon",
|
||||||
|
"next7Days": "Next 7 days",
|
||||||
|
"overdue": "Overdue",
|
||||||
|
"needsAttention": "Needs attention",
|
||||||
|
"needsGrading": "Needs Grading",
|
||||||
|
"submissionsPendingReview": "Submissions pending review",
|
||||||
|
"activeAssignments": "Active Assignments",
|
||||||
|
"publishedAndOngoing": "Published and ongoing",
|
||||||
|
"submissionRate": "Submission Rate",
|
||||||
|
"overallCompletionRate": "Overall completion rate",
|
||||||
|
"acrossRecentAssignments": "Across recent assignments"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"importUsers": "Import Users",
|
||||||
|
"importUsersDesc": "Batch create user accounts via Excel",
|
||||||
|
"newAnnouncement": "New Announcement",
|
||||||
|
"newAnnouncementDesc": "Publish notices to the whole school or specific grades/classes",
|
||||||
|
"approveSchedule": "Approve Schedule Changes",
|
||||||
|
"approveScheduleDesc": "Review schedule changes and substitute teacher requests",
|
||||||
|
"autoSchedule": "Auto Scheduling",
|
||||||
|
"autoScheduleDesc": "Auto-generate weekly schedules based on rules",
|
||||||
|
"fileManagement": "File Management",
|
||||||
|
"fileManagementDesc": "View and manage all uploaded files",
|
||||||
|
"attendanceOverview": "Attendance Overview",
|
||||||
|
"attendanceOverviewDesc": "View attendance records for all classes",
|
||||||
|
"grades": "Grades",
|
||||||
|
"attendance": "Attendance",
|
||||||
|
"announcements": "Announcements",
|
||||||
|
"leaveRequest": "Leave Request"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"title": "Today's To-Do",
|
||||||
|
"toGrade": "Homework to grade",
|
||||||
|
"todayAttendance": "Attendance to take",
|
||||||
|
"activeAssignments": "Active assignments",
|
||||||
|
"empty": "No to-do items today"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"userGrowthTrend": "User Growth Trend (Last 30 Days)",
|
||||||
|
"homeworkSubmissionTrend": "Homework Submission Trend (Last 7 Days)",
|
||||||
|
"userRoles": "User Roles",
|
||||||
|
"content": "Content",
|
||||||
|
"homeworkActivity": "Homework Activity",
|
||||||
|
"recentUsers": "Recent Users",
|
||||||
|
"viewAllUsers": "View all users",
|
||||||
|
"pendingGrading": "Pending Grading",
|
||||||
|
"todaySchedule": "Today's Schedule",
|
||||||
|
"upcomingAssignments": "Upcoming Assignments",
|
||||||
|
"grades": "Grades",
|
||||||
|
"myClasses": "My Classes",
|
||||||
|
"gradeTrends": "Grade Trends"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"created": "Created"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noUsers": "No users",
|
||||||
|
"noUsersDesc": "No user records found.",
|
||||||
|
"noUsersYet": "No users yet",
|
||||||
|
"seedHint": "Seed the database to see users here.",
|
||||||
|
"allGraded": "All graded!",
|
||||||
|
"allGradedDesc": "No submissions pending review.",
|
||||||
|
"noChildren": "No children linked",
|
||||||
|
"noChildrenDesc": "Your account is not linked to any student accounts yet. Please contact the school administrator to link your child.",
|
||||||
|
"noStudent": "No student found",
|
||||||
|
"noStudentDesc": "Create a student user to see dashboard.",
|
||||||
|
"contactSupport": "Contact support"
|
||||||
|
},
|
||||||
|
"badge": {
|
||||||
|
"activeSessions": "{count} active sessions",
|
||||||
|
"users": "{count} users",
|
||||||
|
"childrenLinked": "{count} children linked"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "Page load failed",
|
||||||
|
"loadFailedDesc": "Sorry, an unexpected error occurred while loading the page. Please try again later.",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"newUsers": "New users",
|
||||||
|
"newSubmissions": "New submissions"
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/shared/i18n/messages/zh-CN/dashboard.json
Normal file
124
src/shared/i18n/messages/zh-CN/dashboard.json
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
{
|
||||||
|
"title": {
|
||||||
|
"admin": "管理控制台",
|
||||||
|
"teacher": "教师工作台",
|
||||||
|
"student": "学生中心",
|
||||||
|
"parent": "家长中心",
|
||||||
|
"root": "仪表盘"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"admin": "用户、学习内容与活动全貌",
|
||||||
|
"teacher": "今日教学概览",
|
||||||
|
"student": "今日学习概览",
|
||||||
|
"parent": "查看孩子的学习概况"
|
||||||
|
},
|
||||||
|
"greeting": {
|
||||||
|
"morning": "早上好",
|
||||||
|
"afternoon": "下午好",
|
||||||
|
"evening": "晚上好",
|
||||||
|
"welcome": "欢迎回来",
|
||||||
|
"todayIs": "今天是 {date},以下是今日概览。",
|
||||||
|
"overview": "以下是今日概览。"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"users": "用户总数",
|
||||||
|
"classes": "班级数",
|
||||||
|
"activeSessions": "活跃会话",
|
||||||
|
"homeworkPublished": "已发布作业",
|
||||||
|
"toGrade": "待批改",
|
||||||
|
"enrolledClasses": "已选课程",
|
||||||
|
"activeEnrollments": "有效选课",
|
||||||
|
"averageScore": "平均分",
|
||||||
|
"overallPerformance": "综合表现",
|
||||||
|
"noGradesYet": "暂无成绩",
|
||||||
|
"classRank": "班级排名",
|
||||||
|
"currentPosition": "当前名次",
|
||||||
|
"noRankingYet": "暂无排名",
|
||||||
|
"graded": "已批改",
|
||||||
|
"completedAssignments": "已完成作业",
|
||||||
|
"dueSoon": "即将到期",
|
||||||
|
"next7Days": "未来 7 天",
|
||||||
|
"overdue": "已逾期",
|
||||||
|
"needsAttention": "需要关注",
|
||||||
|
"needsGrading": "待批改",
|
||||||
|
"submissionsPendingReview": "待审核提交",
|
||||||
|
"activeAssignments": "进行中作业",
|
||||||
|
"publishedAndOngoing": "已发布且进行中",
|
||||||
|
"submissionRate": "提交率",
|
||||||
|
"overallCompletionRate": "总体完成率",
|
||||||
|
"acrossRecentAssignments": "近期作业平均"
|
||||||
|
},
|
||||||
|
"quickActions": {
|
||||||
|
"importUsers": "批量导入用户",
|
||||||
|
"importUsersDesc": "通过 Excel 批量创建用户账号",
|
||||||
|
"newAnnouncement": "发布公告",
|
||||||
|
"newAnnouncementDesc": "向全校或指定年级/班级发布通知",
|
||||||
|
"approveSchedule": "审批课表变更",
|
||||||
|
"approveScheduleDesc": "审核教师提交的课表变更与代课申请",
|
||||||
|
"autoSchedule": "自动排课",
|
||||||
|
"autoScheduleDesc": "基于规则自动生成周课表",
|
||||||
|
"fileManagement": "文件管理",
|
||||||
|
"fileManagementDesc": "查看与管理系统中所有上传文件",
|
||||||
|
"attendanceOverview": "考勤总览",
|
||||||
|
"attendanceOverviewDesc": "查看全校所有班级的考勤记录",
|
||||||
|
"grades": "成绩",
|
||||||
|
"attendance": "考勤",
|
||||||
|
"announcements": "通知",
|
||||||
|
"leaveRequest": "请假申请"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"title": "今日待办",
|
||||||
|
"toGrade": "待批改作业",
|
||||||
|
"todayAttendance": "今日待考勤",
|
||||||
|
"activeAssignments": "进行中作业",
|
||||||
|
"empty": "今日无待办事项"
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"userGrowthTrend": "用户增长趋势(近30天)",
|
||||||
|
"homeworkSubmissionTrend": "作业提交趋势(近7天)",
|
||||||
|
"userRoles": "用户角色分布",
|
||||||
|
"content": "内容统计",
|
||||||
|
"homeworkActivity": "作业活动",
|
||||||
|
"recentUsers": "最近注册用户",
|
||||||
|
"viewAllUsers": "查看全部用户",
|
||||||
|
"pendingGrading": "待批改",
|
||||||
|
"todaySchedule": "今日课表",
|
||||||
|
"upcomingAssignments": "即将到期的作业",
|
||||||
|
"grades": "成绩",
|
||||||
|
"myClasses": "我的班级",
|
||||||
|
"gradeTrends": "成绩趋势"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"name": "姓名",
|
||||||
|
"email": "邮箱",
|
||||||
|
"role": "角色",
|
||||||
|
"created": "创建时间"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"noUsers": "暂无用户",
|
||||||
|
"noUsersDesc": "数据库中暂无用户记录",
|
||||||
|
"noUsersYet": "暂无用户",
|
||||||
|
"seedHint": "初始化数据库以查看用户数据",
|
||||||
|
"allGraded": "全部批改完成!",
|
||||||
|
"allGradedDesc": "暂无待批改的提交。",
|
||||||
|
"noChildren": "未绑定孩子",
|
||||||
|
"noChildrenDesc": "您的账号尚未关联任何学生账号,请联系学校管理员完成绑定。",
|
||||||
|
"noStudent": "未找到学生用户",
|
||||||
|
"noStudentDesc": "请创建学生账号以查看仪表盘。",
|
||||||
|
"contactSupport": "联系客服"
|
||||||
|
},
|
||||||
|
"badge": {
|
||||||
|
"activeSessions": "{count} 个活跃会话",
|
||||||
|
"users": "{count} 位用户",
|
||||||
|
"childrenLinked": "已关联 {count} 个孩子"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"loadFailed": "页面加载失败",
|
||||||
|
"loadFailedDesc": "抱歉,页面加载时发生了意外错误。请稍后重试。",
|
||||||
|
"retry": "重试"
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"newUsers": "新增用户",
|
||||||
|
"newSubmissions": "新增提交"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.FILE_UPLOAD,
|
Permissions.FILE_UPLOAD,
|
||||||
Permissions.FILE_READ,
|
Permissions.FILE_READ,
|
||||||
Permissions.FILE_DELETE,
|
Permissions.FILE_DELETE,
|
||||||
|
Permissions.DASHBOARD_ADMIN_READ,
|
||||||
],
|
],
|
||||||
teacher: [
|
teacher: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -105,6 +106,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.LESSON_PLAN_UPDATE,
|
Permissions.LESSON_PLAN_UPDATE,
|
||||||
Permissions.LESSON_PLAN_DELETE,
|
Permissions.LESSON_PLAN_DELETE,
|
||||||
Permissions.LESSON_PLAN_PUBLISH,
|
Permissions.LESSON_PLAN_PUBLISH,
|
||||||
|
Permissions.DASHBOARD_TEACHER_READ,
|
||||||
],
|
],
|
||||||
student: [
|
student: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -125,6 +127,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.ELECTIVE_SELECT,
|
Permissions.ELECTIVE_SELECT,
|
||||||
Permissions.ELECTIVE_READ,
|
Permissions.ELECTIVE_READ,
|
||||||
Permissions.DIAGNOSTIC_READ,
|
Permissions.DIAGNOSTIC_READ,
|
||||||
|
Permissions.DASHBOARD_STUDENT_READ,
|
||||||
],
|
],
|
||||||
parent: [
|
parent: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -137,6 +140,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.MESSAGE_SEND,
|
Permissions.MESSAGE_SEND,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.DASHBOARD_PARENT_READ,
|
||||||
],
|
],
|
||||||
grade_head: [
|
grade_head: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ export const Permissions = {
|
|||||||
LESSON_PLAN_UPDATE: "lesson_plan:update",
|
LESSON_PLAN_UPDATE: "lesson_plan:update",
|
||||||
LESSON_PLAN_DELETE: "lesson_plan:delete",
|
LESSON_PLAN_DELETE: "lesson_plan:delete",
|
||||||
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
|
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
|
||||||
|
|
||||||
|
// Dashboard (仪表盘 — 各角色独立读权限)
|
||||||
|
DASHBOARD_ADMIN_READ: "dashboard:admin_read",
|
||||||
|
DASHBOARD_TEACHER_READ: "dashboard:teacher_read",
|
||||||
|
DASHBOARD_STUDENT_READ: "dashboard:student_read",
|
||||||
|
DASHBOARD_PARENT_READ: "dashboard:parent_read",
|
||||||
} as const satisfies Record<string, string>
|
} as const satisfies Record<string, string>
|
||||||
|
|
||||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
||||||
|
|||||||
Reference in New Issue
Block a user