feat(school,classes): 学校/年级/班级模块审计修复 — 权限校验 + i18n + 架构图同步
- 新增审计报告 docs/architecture/audit/school-grade-class-audit-report.md - 修复 P0-4: teacher/classes 4 个页面补充 requirePermission 权限校验 - 修复 P0-5: 新增 school.json i18n 文件(zh-CN/en)并接入 schools-view 组件 - 同步架构图 004:补充 grade-management 死模块记录与 teacher/classes 权限修复说明
This commit is contained in:
@@ -402,6 +402,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
✅ P0 安全修复:admin/layout.tsx 提供登录态统一守卫,
|
✅ P0 安全修复:admin/layout.tsx 提供登录态统一守卫,
|
||||||
各页面 requirePermission() 提供细粒度权限校验
|
各页面 requirePermission() 提供细粒度权限校验
|
||||||
|
|
||||||
|
teacher/classes/* 路由权限校验(2026-06-22 审计修复):
|
||||||
|
├─▶ /teacher/classes/my → requirePermission(CLASS_READ) ✅ 已修复
|
||||||
|
├─▶ /teacher/classes/my/[id] → requirePermission(CLASS_READ) ✅ 已修复
|
||||||
|
├─▶ /teacher/classes/schedule → requirePermission(CLASS_READ) ✅ 已修复
|
||||||
|
└─▶ /teacher/classes/students → requirePermission(CLASS_READ) ✅ 已修复
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -674,6 +680,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`
|
- Actions:`getGradeRecordsAction` / `createGradeRecordAction` / `updateGradeRecordAction` / `deleteGradeRecordAction` / `exportGradesAction` / `getGradeTrendAction` / `getClassComparisonAction` / `getSubjectComparisonAction` / `getGradeDistributionAction` / `getClassRankingAction` / `getRankingTrendAction` / `getGradeRecordByIdAction` / `getClassGradeStatsAction` / `getStudentGradeSummaryAction` / `batchCreateGradeRecordsAction`
|
||||||
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend`
|
- Data-access:`getGradeRecords` / `getStudentGradeSummary` / `getClassRanking` / `getClassStudentsForEntry` / `getClassGradeStats` / `getClassGradeStatsWithMeta` / `getGradeTrend` / `getClassComparison` / `getSubjectComparison` / `getGradeDistribution` / `getRankingTrend`
|
||||||
- Lib(✅ P1-2 新增):`toNumber` / `normalize` / `buildScopeClassFilter`(从 3 个 data-access 文件抽取的公共工具函数)
|
- Lib(✅ P1-2 新增):`toNumber` / `normalize` / `buildScopeClassFilter`(从 3 个 data-access 文件抽取的公共工具函数)
|
||||||
|
- Stats-service(✅ P1-1 新增):`computeGradeStats` / `computeAverageScore` / `buildGradeTrendPoints` / `computeTrendAverage` / `computeClassComparisonStats` / `computeSubjectComparisonStats` / `computeGradeDistribution` / `buildRankingTrendPoints`(从 3 个 data-access 文件抽取的纯函数,使数据层专注 DB I/O,统计逻辑可独立测试)
|
||||||
|
- Components(✅ P1-5 新增):`WidgetBoundary`(Error Boundary + Suspense + Skeleton 组合,含 a11y 属性)
|
||||||
|
|
||||||
**依赖关系**:
|
**依赖关系**:
|
||||||
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
- 依赖:`shared/*`、`@/auth`、`classes`(✅ P1-1 已修复:通过 classes data-access.getClassExists/getClassNameById/getClassNamesByIds/getActiveStudentIdsByClassId/getStudentActiveClassId/getClassesByGradeId)、`school`(✅ P1-1 已修复:通过 school data-access.getSubjectOptions/getGradeOptions)、`users`(✅ P1-1 已修复:通过 users data-access.getUserNamesByIds)
|
||||||
@@ -681,11 +689,12 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
|
|
||||||
**已知问题**:
|
**已知问题**:
|
||||||
- ✅ P1-1 已修复:~~多处直查 `classes`/`classEnrollments`/`subjects`/`users` 表~~ 改为调用对应模块 data-access 函数(classes/school/users)
|
- ✅ P1-1 已修复:~~多处直查 `classes`/`classEnrollments`/`subjects`/`users` 表~~ 改为调用对应模块 data-access 函数(classes/school/users)
|
||||||
|
- ✅ P1-1 已修复:~~统计计算业务逻辑混入 data-access(`getClassGradeStats` / `getGradeDistribution`)~~ 抽取为纯函数到 `stats-service.ts`,数据层专注 DB I/O
|
||||||
- ✅ P1-2 已修复:~~`toNumber`/`normalize`/`buildScopeClassFilter` 在 3 个 data-access 文件中重复定义~~ 抽取到 `lib/grade-utils.ts` 统一维护
|
- ✅ P1-2 已修复:~~`toNumber`/`normalize`/`buildScopeClassFilter` 在 3 个 data-access 文件中重复定义~~ 抽取到 `lib/grade-utils.ts` 统一维护
|
||||||
- ✅ P1-3 已修复:~~12 个查询/分析 Action 缺少 Zod 校验~~ 新增 12 个查询 schema(DeleteGradeRecordSchema/GetGradeRecordByIdSchema/GradeQuerySchema/ClassGradeStatsQuerySchema/StudentGradeSummaryQuerySchema/ClassRankingQuerySchema/ExportGradesSchema/GradeTrendQuerySchema/ClassComparisonQuerySchema/SubjectComparisonQuerySchema/GradeDistributionQuerySchema/RankingTrendQuerySchema),所有 Action 使用 safeParse 校验
|
- ✅ P1-3 已修复:~~12 个查询/分析 Action 缺少 Zod 校验~~ 新增 12 个查询 schema(DeleteGradeRecordSchema/GetGradeRecordByIdSchema/GradeQuerySchema/ClassGradeStatsQuerySchema/StudentGradeSummaryQuerySchema/ClassRankingQuerySchema/ExportGradesSchema/GradeTrendQuerySchema/ClassComparisonQuerySchema/SubjectComparisonQuerySchema/GradeDistributionQuerySchema/RankingTrendQuerySchema),所有 Action 使用 safeParse 校验
|
||||||
- ✅ P1-4 已修复:~~`batch-grade-entry.tsx`/`grade-record-form.tsx`/`grade-distribution-chart.tsx` 中存在 `as` 断言~~ 改用类型守卫函数(isGradeType/isSemester/isDistributionTooltipPayload)
|
- ✅ P1-4 已修复:~~`batch-grade-entry.tsx`/`grade-record-form.tsx`/`grade-distribution-chart.tsx` 中存在 `as` 断言~~ 改用类型守卫函数(isGradeType/isSemester/isDistributionTooltipPayload)
|
||||||
|
- ✅ P1-5 已修复:~~teacher/grades 与 teacher/diagnostic 路由缺少 loading.tsx/error.tsx~~ 已为 7 个路由补齐 loading.tsx + error.tsx,并新增 `WidgetBoundary` 通用组件
|
||||||
- ✅ P2-2 已修复:~~diagnostic 组件中存在 Tailwind 任意值~~ 改用标准 Tailwind 类
|
- ✅ P2-2 已修复:~~diagnostic 组件中存在 Tailwind 任意值~~ 改用标准 Tailwind 类
|
||||||
- ⚠️ P2:统计计算业务逻辑混入 data-access(`getClassGradeStats` / `getGradeDistribution`)
|
|
||||||
- ✅ actions 层无直接 DB 访问(标杆)
|
- ✅ actions 层无直接 DB 访问(标杆)
|
||||||
- ✅ data-access 按职责拆分为 3 个文件(标杆)
|
- ✅ data-access 按职责拆分为 3 个文件(标杆)
|
||||||
- ✅ P2 已修复:`export.ts` 中 `scoreMap.get(r.studentId)!` 非空断言清理为安全守卫(`if (!subjMap) continue`)
|
- ✅ P2 已修复:`export.ts` 中 `scoreMap.get(r.studentId)!` 非空断言清理为安全守卫(`if (!subjMap) continue`)
|
||||||
@@ -698,9 +707,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
| `data-access.ts` | 361 | 成绩 CRUD + 统计 |
|
| `data-access.ts` | 361 | 成绩 CRUD + 统计 |
|
||||||
| `data-access-analytics.ts` | 266 | 趋势/对比分析 |
|
| `data-access-analytics.ts` | 266 | 趋势/对比分析 |
|
||||||
| `data-access-ranking.ts` | 96 | 排名查询 |
|
| `data-access-ranking.ts` | 96 | 排名查询 |
|
||||||
|
| `stats-service.ts` | - | 统计计算纯函数(P1-1 新增:8 个纯函数 + 2 个常量 + 2 个接口) |
|
||||||
| `export.ts` | 214 | Excel 导出 |
|
| `export.ts` | 214 | Excel 导出 |
|
||||||
| `schema.ts` | 100 | Zod 校验(含 12 个查询 schema) |
|
| `schema.ts` | 100 | Zod 校验(含 12 个查询 schema) |
|
||||||
| `lib/grade-utils.ts` | 46 | 公共工具函数(toNumber/normalize/buildScopeClassFilter) |
|
| `lib/grade-utils.ts` | 46 | 公共工具函数(toNumber/normalize/buildScopeClassFilter) |
|
||||||
|
| `components/widget-boundary.tsx` | - | Widget 边界组件(P1-5 新增) |
|
||||||
| `types.ts` | - | 类型定义 |
|
| `types.ts` | - | 类型定义 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -758,17 +769,58 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
|||||||
- ✅ P2 已修复:`data-access.ts` 中 8 处 catch 块添加 `console.error` 输出错误上下文(getDepartments/getAcademicYears/getSchools/getGrades/getStaffOptions/getGradesForStaff/getSubjectOptions/getGradeOptions)
|
- ✅ P2 已修复:`data-access.ts` 中 8 处 catch 块添加 `console.error` 输出错误上下文(getDepartments/getAcademicYears/getSchools/getGrades/getStaffOptions/getGradesForStaff/getSubjectOptions/getGradeOptions)
|
||||||
- ⚠️ P2:审计日志不一致(仅 school 实体记录,department/academicYear/grade 未记录)
|
- ⚠️ P2:审计日志不一致(仅 school 实体记录,department/academicYear/grade 未记录)
|
||||||
- ⚠️ P2:`getStaffOptions`/`getGrades` 直查 users/roles(展示用,可接受)
|
- ⚠️ P2:`getStaffOptions`/`getGrades` 直查 users/roles(展示用,可接受)
|
||||||
|
- ⚠️ P0-2(2026-06-22 审计发现):年级 CRUD 逻辑与 `grade-management` 模块重复定义,两套实现并存
|
||||||
|
- ⚠️ P0-5(2026-06-22 审计发现):`school/components/*` 4 个组件均缺少 i18n(`schools-view.tsx` 已修复,其余 3 个待修复);缺少 Error Boundary / Skeleton
|
||||||
|
- ✅ P0-5 部分修复(2026-06-22):新增 `school.json` i18n 文件,`schools-view.tsx` 接入 `useTranslations("school")`
|
||||||
|
|
||||||
**文件清单**:
|
**文件清单**:
|
||||||
| 文件 | 行数 | 职责 |
|
| 文件 | 行数 | 职责 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `actions.ts` | 326 | 12 个 Server Action(编排层,无 DB 直访) |
|
| `actions.ts` | 349 | 12 个 Server Action(编排层,无 DB 直访) |
|
||||||
| `data-access.ts` | 320 | 只读查询 + 12 个写操作(CRUD) |
|
| `data-access.ts` | 504 | 只读查询 + 12 个写操作 + 跨模块查询接口(`isGradeHead`/`isGradeManager`/`findGradeIdByHeadAndName`/`getGradeNameById`/`getSubjectNameById`) |
|
||||||
| `schema.ts` | 51 | Zod 校验 |
|
| `schema.ts` | 51 | Zod 校验 |
|
||||||
| `types.ts` | 96 | 类型定义(含 Insert/Update 入参类型) |
|
| `types.ts` | 96 | 类型定义(含 Insert/Update 入参类型) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2.8b grade-management(年级管理模块)— ⚠️ 死模块
|
||||||
|
|
||||||
|
> **2026-06-22 审计发现**:该模块拥有完整的理想架构(Service 接口 + Context DI + 角色配置 + Error Boundary + Skeleton + i18n + hooks 分离),但 **13 个相关页面中无任何一个导入此模块**。`management/grade/*` 页面实际依赖 `classes` 和 `school` 模块的 data-access。详见 `docs/architecture/audit/school-grade-class-audit-report.md`。
|
||||||
|
|
||||||
|
**职责**:年级 CRUD + 年级作业洞察(重构版,对标理想架构模式)。
|
||||||
|
|
||||||
|
**导出函数**:
|
||||||
|
- Actions:`createGradeAction` / `updateGradeAction` / `deleteGradeAction`(与 school 模块重复定义)
|
||||||
|
- Data-access:`getGrades` / `getGradesForStaff` / `getSchools` / `getStaffOptions` / `createGrade` / `updateGrade` / `deleteGrade` / `generateGradeId`(与 school 模块重复)
|
||||||
|
- Data-access-insights:`getGradeInsights`(通过 `classes/data-access.getGradeHomeworkInsights` 获取数据,跨模块通信合规)
|
||||||
|
- Services:`GradeService` 接口 + `AdminGradeService` / `TeacherGradeService` 实现 + `GradeServiceProvider` Context
|
||||||
|
- Config:`GRADE_ROLE_CONFIG` 角色配置 + `getGradeRoleConfig` / `resolveGradeRoleConfig`
|
||||||
|
- Hooks:`useGradeData` / `useGradeFilters` / `useGradeForm` / `useGradeInsights`
|
||||||
|
- Widgets:`GradeManagementWidget` / `GradeInsightsWidget`
|
||||||
|
|
||||||
|
**依赖关系**:
|
||||||
|
- 依赖:`shared/*`、`@/auth`、`classes`(通过 `data-access.getGradeHomeworkInsights`,合规)
|
||||||
|
- 被依赖:⚠️ **无任何模块或页面依赖此模块**
|
||||||
|
|
||||||
|
**已知问题**:
|
||||||
|
- ⚠️ P0-1:模块完全未被使用(死模块),所有页面使用 school/classes 模块的 data-access
|
||||||
|
- ⚠️ P0-2:年级 CRUD 逻辑与 school 模块重复定义
|
||||||
|
|
||||||
|
**文件清单**:
|
||||||
|
| 文件 | 行数 | 职责 |
|
||||||
|
|------|------|------|
|
||||||
|
| `actions.ts` | 213 | 3 个 Server Action(含审计日志,比 school 模块版本更完善) |
|
||||||
|
| `data-access.ts` | 238 | 年级 CRUD + 查询(与 school 模块重复) |
|
||||||
|
| `data-access-insights.ts` | 75 | 年级洞察(适配 classes 模块数据) |
|
||||||
|
| `types.ts` | 149 | 类型定义(含 GradeService 接口、角色配置、埋点接口) |
|
||||||
|
| `services/*.tsx` | 4 文件 | GradeService 接口 + 实现 + Context DI |
|
||||||
|
| `config/role-config.ts` | 66 | 角色配置驱动设计 |
|
||||||
|
| `hooks/*.ts` | 4 文件 | 数据/筛选/表单/洞察 hooks |
|
||||||
|
| `widgets/*.tsx` | 2 文件 | 管理面板 + 洞察面板 |
|
||||||
|
| `components/*.tsx` | 11 文件 | 表格/工具栏/对话框/骨架屏/错误边界/空状态 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2.9 scheduling(排课模块)
|
## 2.9 scheduling(排课模块)
|
||||||
|
|
||||||
**职责**:自动排课算法 + 课表调整 + 排课规则管理。
|
**职责**:自动排课算法 + 课表调整 + 排课规则管理。
|
||||||
|
|||||||
318
docs/architecture/audit/school-grade-class-audit-report.md
Normal file
318
docs/architecture/audit/school-grade-class-audit-report.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# 学校/年级/班级管理模块审计报告
|
||||||
|
|
||||||
|
> 审查范围:`school`(学校/学年/部门/年级 CRUD)、`grade-management`(年级管理重构模块)、`classes`(班级管理)
|
||||||
|
> 审查日期:2026-06-22
|
||||||
|
> 审查依据:项目规则(三层架构、权限校验、i18n、TypeScript 严格模式、单文件行数限制)、K12 行业优秀实践
|
||||||
|
> 审查方式:只读源码分析 + 架构图比对,未修改任何代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有实现概要
|
||||||
|
|
||||||
|
### 1.1 模块文件分布
|
||||||
|
|
||||||
|
| 模块 | 核心文件 | 行数(约) | 职责 |
|
||||||
|
|------|---------|-----------|------|
|
||||||
|
| `school` | `actions.ts` / `data-access.ts` / `schema.ts` / `types.ts` + 4 个组件 | 349 / 504 / 51 / 96 | 学校/学年/部门/年级的 CRUD |
|
||||||
|
| `grade-management` | `actions.ts` / `data-access.ts` / `data-access-insights.ts` / `schema.ts` / `types.ts` + 11 组件 + 4 hooks + 4 services + 2 widgets + 1 config | 213 / 238 / 75 / — / 149 | 年级管理(重构版,含洞察) |
|
||||||
|
| `classes` | `actions.ts` / `data-access.ts` / `data-access-{admin,stats,schedule,students,invitations}.ts` / `schema.ts` / `types.ts` + 14 组件 | 974 / 548 / 406 / 513 / 194 / 253 / — / 152 / 183 | 班级 CRUD + 学生/教师管理 + 邀请码 + 课表 + 作业洞察 |
|
||||||
|
|
||||||
|
### 1.2 页面分布(共 13 个 page.tsx)
|
||||||
|
|
||||||
|
| 路由分组 | 页面 | 权限校验 | i18n | 调用方式 |
|
||||||
|
|---------|------|---------|------|---------|
|
||||||
|
| `admin/school/*` | schools / grades / classes / departments / academic-year | ✅ `requirePermission(SCHOOL_MANAGE)` | ❌ 中文硬编码 | 直接调用 data-access |
|
||||||
|
| `management/grade/*` | classes / insights | ✅ `requirePermission(GRADE_MANAGE/GRADE_RECORD_READ)` | ❌ 英文硬编码 | 直接调用 classes/school data-access |
|
||||||
|
| `teacher/classes/*` | my / my/[id] / schedule / students | ❌ **无任何校验** | ❌ 英文硬编码 | 直接调用 data-access |
|
||||||
|
|
||||||
|
### 1.3 架构图记录情况
|
||||||
|
|
||||||
|
`docs/architecture/004_architecture_impact_map.md` 中:
|
||||||
|
- ✅ 已记录 `school` 模块(§2.8)和 `classes` 模块(§2.7)的职责、依赖关系、跨模块通信方式
|
||||||
|
- ✅ 已记录 `classes` 模块的文件拆分(5 个 data-access 子文件)和 P0-7 修复(homework 跨模块封装)
|
||||||
|
- ❌ **未记录 `grade-management` 模块** — 该模块拥有完整的 services/hooks/widgets/config 架构,但架构图中完全缺失
|
||||||
|
- ❌ **未记录 `grade-management` 模块未被任何页面使用的事实** — 这是重大架构偏差
|
||||||
|
|
||||||
|
### 1.4 数据流概要
|
||||||
|
|
||||||
|
```
|
||||||
|
admin/school/grades 页面
|
||||||
|
└─→ school/data-access.getGrades() / getSchools() / getStaffOptions()
|
||||||
|
└─→ school/components/grades-view.tsx(客户端组件)
|
||||||
|
└─→ school/actions.ts → createGradeAction / updateGradeAction / deleteGradeAction
|
||||||
|
|
||||||
|
management/grade/classes 页面
|
||||||
|
└─→ classes/data-access.getGradeManagedClasses() / getTeacherOptions()
|
||||||
|
└─→ school/data-access.getGradesForStaff()
|
||||||
|
└─→ classes/components/grade-classes-view.tsx
|
||||||
|
└─→ classes/actions.ts → createGradeClassAction / updateGradeClassAction / ...
|
||||||
|
|
||||||
|
teacher/classes/* 页面
|
||||||
|
└─→ classes/data-access.getTeacherClasses() / getClassStudents() / getClassSchedule()
|
||||||
|
└─→ classes/components/*(my-classes-grid / students-table / schedule-view)
|
||||||
|
└─→ classes/actions.ts → createTeacherClassAction / ...
|
||||||
|
|
||||||
|
grade-management 模块(⚠️ 完全未被使用)
|
||||||
|
└─→ services/grade-service.ts(接口定义)
|
||||||
|
└─→ services/admin-grade-service.ts / teacher-grade-service.ts(实现)
|
||||||
|
└─→ widgets/grade-management-widget.tsx(主面板)
|
||||||
|
└─→ ⚠️ 无任何页面导入此模块
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现存问题与原因分析
|
||||||
|
|
||||||
|
### 2.1 架构层面
|
||||||
|
|
||||||
|
#### P0-1:`grade-management` 模块完全未被使用(死模块)
|
||||||
|
|
||||||
|
- **位置**:`src/modules/grade-management/` 全模块
|
||||||
|
- **问题**:该模块拥有完整的理想架构(Service 接口 + Context 依赖注入 + 角色配置 + Error Boundary + Skeleton + i18n + hooks 分离),但 **13 个相关页面中无任何一个导入该模块**。`management/grade/*` 页面实际依赖 `classes` 和 `school` 模块的 data-access。
|
||||||
|
- **违反规则**:架构图优先规则(图未覆盖则先补图)、模块标准结构(该模块存在但未接入)
|
||||||
|
- **原因**:推测为未完成的重构 — 已建立目标架构但未将页面迁移过来
|
||||||
|
- **后果**:维护两套年级管理逻辑(`school` 模块的 grade CRUD + `grade-management` 模块的 grade CRUD),职责重叠、产生混淆;理想架构模式无法落地发挥价值
|
||||||
|
|
||||||
|
#### P0-2:年级 CRUD 逻辑重复定义
|
||||||
|
|
||||||
|
- **位置**:
|
||||||
|
- `src/modules/school/actions.ts` L268-349:`createGradeAction` / `updateGradeAction` / `deleteGradeAction`
|
||||||
|
- `src/modules/grade-management/actions.ts` L37-203:同名函数 `createGradeAction` / `updateGradeAction` / `deleteGradeAction`
|
||||||
|
- `src/modules/school/data-access.ts` L256-285:`createGrade` / `updateGrade` / `deleteGrade`
|
||||||
|
- `src/modules/grade-management/data-access.ts` L137-171:同名函数 `createGrade` / `updateGrade` / `deleteGrade`
|
||||||
|
- **问题**:两套模块各自定义了完全相同的年级 CRUD 逻辑,`admin/school/grades` 页面使用 `school` 模块版本,`grade-management` 模块版本无人调用
|
||||||
|
- **违反规则**:DRY 原则、模块标准结构(职责应归属单一模块)
|
||||||
|
- **后果**:修改年级逻辑需同步两处,极易遗漏;两套实现的审计日志策略不一致(school 模块 grade CRUD 无 `logAudit`,grade-management 模块有)
|
||||||
|
|
||||||
|
#### P0-3:`classes/actions.ts` 接近行数硬上限
|
||||||
|
|
||||||
|
- **位置**:`src/modules/classes/actions.ts`(974 行)
|
||||||
|
- **问题**:文件已达 974 行,接近 1000 行硬性上限。包含 3 组近乎重复的 CRUD Action(Teacher 系列 / Admin 系列 / Grade 系列)+ 邀请码 Action + 课表 Action
|
||||||
|
- **违反规则**:单文件行数限制(Server Actions 建议 ≤ 800 行,硬性上限 1000 行)
|
||||||
|
- **后果**:再增加任何功能即超限;文件过大降低可读性和可维护性
|
||||||
|
|
||||||
|
### 2.2 权限层面
|
||||||
|
|
||||||
|
#### P0-4:`teacher/classes/*` 4 个页面完全缺少权限校验
|
||||||
|
|
||||||
|
- **位置**:
|
||||||
|
- `src/app/(dashboard)/teacher/classes/my/page.tsx` — 无 `requirePermission()`
|
||||||
|
- `src/app/(dashboard)/teacher/classes/my/[id]/page.tsx` — 无 `requirePermission()`
|
||||||
|
- `src/app/(dashboard)/teacher/classes/schedule/page.tsx` — 无 `requirePermission()`
|
||||||
|
- `src/app/(dashboard)/teacher/classes/students/page.tsx` — 无 `requirePermission()`
|
||||||
|
- **问题**:这 4 个业务页面直接调用 data-access 获取数据,依赖路由中间件隐式保障身份,无显式权限校验
|
||||||
|
- **违反规则**:Server Action 规范(每个 Action 必须调用 `requirePermission()`);安全性规范(所有敏感数据查询必须在 data-access 层结合当前用户权限过滤)
|
||||||
|
- **后果**:若路由中间件配置错误或被绕过,教师可访问任意班级数据;data-access 层的 `getTeacherClasses()` 未接收 userId 参数做范围过滤
|
||||||
|
|
||||||
|
#### P1-1:`classes/actions.ts` 中存在 `ctx.roles.includes("xxx")` 硬编码
|
||||||
|
|
||||||
|
- **位置**:`src/modules/classes/actions.ts` L81、L420、L422、L428、L446、L451
|
||||||
|
- **问题**:Server Action 中使用 `ctx.roles.includes("admin")` / `ctx.roles.includes("teacher")` / `ctx.roles.includes("student")` 进行角色判断
|
||||||
|
- **违反规则**:前端组件禁止 `role === "xxx"` 硬编码(虽此处在 Server Action 而非前端组件,但精神一致 — 应使用权限点而非角色名)
|
||||||
|
- **后果**:新增角色(如 grade_head)需修改所有硬编码处;角色与权限耦合,不符合权限点驱动设计
|
||||||
|
|
||||||
|
### 2.3 国际化层面
|
||||||
|
|
||||||
|
#### P0-5:全部 13 个页面均未使用 i18n
|
||||||
|
|
||||||
|
- **位置**:所有 13 个 page.tsx 及其引用的组件
|
||||||
|
- **问题**:
|
||||||
|
- `admin/school/*` 页面使用**中文硬编码**(如 "学校管理"、"年级管理"、"班级管理")
|
||||||
|
- `management/grade/*` 和 `teacher/classes/*` 页面使用**英文硬编码**(如 "Class Management"、"Grade Insights")
|
||||||
|
- `school/components/*` 全部使用英文硬编码(如 "New school"、"All schools"、"Edit"、"Delete")
|
||||||
|
- `classes/components/*` 混用中英文
|
||||||
|
- **违反规则**:所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键
|
||||||
|
- **后果**:无法支持多语言;中英文混用严重影响一致性和专业度;i18n 资源文件(`grade.json`、`classes.json`)已存在但未被使用
|
||||||
|
|
||||||
|
#### P1-2:`school` 模块无 i18n 资源文件
|
||||||
|
|
||||||
|
- **位置**:`src/shared/i18n/messages/{zh-CN,en}/` 目录
|
||||||
|
- **问题**:存在 `grade.json`、`classes.json`,但**不存在 `school.json`**。school 模块的学校/学年/部门管理文本无翻译键可用
|
||||||
|
- **违反规则**:i18n 就绪规范
|
||||||
|
- **后果**:即使想为 school 模块补充 i18n,也缺少翻译文件基础设施
|
||||||
|
|
||||||
|
### 2.4 组件质量层面
|
||||||
|
|
||||||
|
#### P1-3:`school/components/*` 缺少 Error Boundary 和 Skeleton
|
||||||
|
|
||||||
|
- **位置**:`src/modules/school/components/schools-view.tsx` / `grades-view.tsx` / `departments-view.tsx` / `academic-year-view.tsx`
|
||||||
|
- **问题**:4 个组件均为 `"use client"` 客户端组件,无 Error Boundary 包裹、无加载骨架屏、无 Suspense 处理。对比 `grade-management` 模块已有 `grade-error-boundary.tsx` / `grade-skeleton.tsx` / `grade-states.tsx`(但未被使用)
|
||||||
|
- **违反规则**:错误与边界处理(每个独立数据区块必须用 React Error Boundary 包裹;异步数据使用 React Suspense + 骨架屏)
|
||||||
|
- **后果**:数据加载失败时整页崩溃无降级;加载过程无反馈
|
||||||
|
|
||||||
|
#### P1-4:`classes/types.ts` 跨领域类型污染
|
||||||
|
|
||||||
|
- **位置**:`src/modules/classes/types.ts`
|
||||||
|
- **问题**:定义了本应属于其他模块的类型:
|
||||||
|
- `ClassHomeworkInsights` / `GradeHomeworkInsights` / `ClassHomeworkAssignmentStats` / `ScoreStats` / `AssignmentSummary` — 应属 homework 模块
|
||||||
|
- `ClassScheduleItem` / `StudentScheduleItem` — 与 scheduling 模块概念重叠
|
||||||
|
- **违反规则**:模块标准结构(类型应归属对应模块)
|
||||||
|
- **后果**:classes 模块承担了 homework/scheduling 的类型定义职责,耦合度高
|
||||||
|
|
||||||
|
#### P1-5:`school/components/*` 未使用组合模式
|
||||||
|
|
||||||
|
- **位置**:`src/modules/school/components/schools-view.tsx`
|
||||||
|
- **问题**:`SchoolsClient` 组件内部硬编码了 Table + Dialog + AlertDialog 的完整结构,无法通过 slots/render props 定制。对比 `grade-management` 模块的 `GradeManagementWidget` 通过组合 `GradeListTable` + `GradeListToolbar` + `GradeFormDialog` + `GradeDeleteDialog` 实现灵活性
|
||||||
|
- **违反规则**:组合优先(所有 UI 通过组件组合实现灵活性)
|
||||||
|
- **后果**:无法复用表格/对话框子部件;新增角色差异需复制整个组件
|
||||||
|
|
||||||
|
### 2.5 数据安全层面
|
||||||
|
|
||||||
|
#### P1-6:data-access 层部分查询未结合用户权限过滤
|
||||||
|
|
||||||
|
- **位置**:
|
||||||
|
- `src/modules/classes/data-access.ts` — `getTeacherClasses()` 未接收 userId 参数
|
||||||
|
- `src/modules/school/data-access.ts` — `getGrades()` / `getSchools()` 返回全量数据,无权限过滤
|
||||||
|
- **问题**:data-access 函数为全局查询,不结合当前用户身份做数据范围过滤,完全依赖 actions 层或页面层校验
|
||||||
|
- **违反规则**:安全性规范(所有敏感数据查询必须在 data-access 层结合当前用户权限过滤)
|
||||||
|
- **后果**:若上层遗漏校验(如 P0-4 中 teacher/classes 页面),数据越权访问风险
|
||||||
|
|
||||||
|
### 2.6 可测试性层面
|
||||||
|
|
||||||
|
#### P2-1:`school` 和 `classes` 模块逻辑与 UI 耦合,难以单测
|
||||||
|
|
||||||
|
- **位置**:`school/components/*` / `classes/components/*`
|
||||||
|
- **问题**:组件内部直接调用 actions、管理状态、处理错误,未将数据获取/计算/格式化逻辑抽取为独立 hooks 或纯函数。对比 `grade-management` 模块已抽取 `use-grade-data` / `use-grade-filters` / `use-grade-form` / `use-grade-insights` 四个 hooks
|
||||||
|
- **违反规则**:可测试性(数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离)
|
||||||
|
- **后果**:无法对筛选逻辑、表单校验逻辑进行独立单测
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、行业差距对比
|
||||||
|
|
||||||
|
### 3.1 与优秀 K12 产品的差距
|
||||||
|
|
||||||
|
| 功能/交互 | 行业优秀实践(Google Classroom / 钉钉教育 / 智学网) | 当前状态 | 影响 |
|
||||||
|
|----------|------------------------------------------------------|---------|------|
|
||||||
|
| **学校切换** | 顶部全局学校切换器,切换后所有页面数据联动 | 仅 admin 跨校可见,无全局切换器 | 多校区场景下教师/学生无法快速切换视角 |
|
||||||
|
| **年级→班级树形导航** | 左侧树形结构(学校→年级→班级),支持展开/折叠/搜索 | 扁平列表,无层级导航 | 班级数量多时查找效率低 |
|
||||||
|
| **班级详情仪表盘** | 一页聚合:基本信息 + 学生名单 + 课表 + 作业 + 成绩趋势 | `teacher/classes/my/[id]` 已有 class-detail 子组件,但 admin/grade 视角无详情页 | admin/年级组长无法下钻查看班级详情 |
|
||||||
|
| **批量操作** | 批量导入学生、批量分配教师、批量升级班级 | 仅支持单条 CRUD + 邮箱注册 | 开学季配置效率低 |
|
||||||
|
| **空状态引导** | 空状态带引导按钮和说明文案 | schools-view 有 EmptyState,其他组件不一致 | 新用户不知道下一步该做什么 |
|
||||||
|
| **加载骨架屏** | 数据加载时显示骨架屏保持布局稳定 | school/classes 组件无骨架屏(grade-management 有但未使用) | 加载过程布局跳动,体验差 |
|
||||||
|
| **邀请码加入** | 二维码 + 链接 + 6 位码三种方式 | 仅 6 位码(v3 已支持有效期/次数) | 家长端操作门槛略高 |
|
||||||
|
| **年级升级** | 学年末一键升级(三年级→四年级),保留历史档案 | 无此功能 | 每年需手动重建班级 |
|
||||||
|
| **数据权限隔离** | 教师仅看到自己班级,年级组长看到年级所有班级,admin 看到全部 | teacher/classes 页面无权限校验(P0-4),data-access 无范围过滤(P1-6) | 存在越权风险 |
|
||||||
|
|
||||||
|
### 3.2 多角色体验差距
|
||||||
|
|
||||||
|
| 角色 | 优秀实践 | 当前状态 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **admin** | 统一管理面板,学校/年级/班级三级联动,支持批量配置 | 分散在 4 个独立页面,无联动 |
|
||||||
|
| **teacher** | 我的班级 + 可加入班级 + 邀请码管理一站式 | 有基本功能,但无权限校验、无 i18n |
|
||||||
|
| **parent** | 查看孩子所在班级信息、任课教师、班级通知 | 无专属页面(依赖 dashboard 间接展示) |
|
||||||
|
| **student** | 查看我的班级、同学名单、课表 | 有基本功能,但无权限校验、无 i18n |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、改进优先级建议
|
||||||
|
|
||||||
|
### P0(紧急 — 安全与架构正确性)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 改进方向 |
|
||||||
|
|------|------|---------|
|
||||||
|
| P0-1 | `grade-management` 模块完全未被使用 | **决策**:要么将 `admin/school/grades` 页面迁移到使用 `grade-management` 模块的 Widget + Service 模式,要么删除该死模块。**推荐迁移**,因为该模块实现了用户要求的全部原则(解耦/组合/i18n/复用/边界/可测试/可扩展) |
|
||||||
|
| P0-2 | 年级 CRUD 逻辑重复 | 统一到 `grade-management` 模块,`school` 模块仅保留学校/学年/部门 CRUD,删除 school 模块中的 grade CRUD |
|
||||||
|
| P0-3 | `classes/actions.ts` 974 行接近上限 | 按职责拆分为 `actions-teacher.ts` / `actions-admin.ts` / `actions-grade.ts` / `actions-invitations.ts` / `actions-schedule.ts` |
|
||||||
|
| P0-4 | `teacher/classes/*` 4 页面无权限校验 | 每个页面添加 `requirePermission(Permissions.CLASS_READ)` 或对应权限点 |
|
||||||
|
| P0-5 | 全部 13 页面无 i18n | 提取翻译键,使用 `getTranslations`(服务端组件)或 `useTranslations`(客户端组件)。补充 `school.json` 翻译文件 |
|
||||||
|
|
||||||
|
### P1(重要 — 代码质量与可维护性)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 改进方向 |
|
||||||
|
|------|------|---------|
|
||||||
|
| P1-1 | `classes/actions.ts` 角色硬编码 | 将 `ctx.roles.includes("admin")` 改为 `ctx.hasPermission(Permissions.xxx)` 或 `ctx.roles` 中的权限点判断 |
|
||||||
|
| P1-2 | `school` 模块无 i18n 文件 | 新建 `src/shared/i18n/messages/{zh-CN,en}/school.json` |
|
||||||
|
| P1-3 | `school/components/*` 缺少 Error Boundary/Skeleton | 参照 `grade-management` 模块的 `grade-error-boundary.tsx` / `grade-skeleton.tsx` 模式补充 |
|
||||||
|
| P1-4 | `classes/types.ts` 跨领域类型污染 | 将 `ClassHomeworkInsights` 等类型迁移至 homework 模块,classes 模块通过 import type 引用 |
|
||||||
|
| P1-5 | `school/components/*` 未使用组合模式 | 将 `SchoolsClient` 拆分为 `SchoolListTable` + `SchoolFormDialog` + `SchoolDeleteDialog` + `SchoolListToolbar` |
|
||||||
|
| P1-6 | data-access 层未结合权限过滤 | `getTeacherClasses(userId)` 接收 userId 参数,在查询中过滤 |
|
||||||
|
|
||||||
|
### P2(优化 — 体验与扩展性)
|
||||||
|
|
||||||
|
| 编号 | 问题 | 改进方向 |
|
||||||
|
|------|------|---------|
|
||||||
|
| P2-1 | 逻辑与 UI 耦合,难以单测 | 参照 `grade-management` 模块抽取 `use-school-data` / `use-class-data` 等 hooks |
|
||||||
|
| P2-2 | 缺少年级→班级树形导航 | 新增 `OrgTreeNav` 组件,学校→年级→班级三级树 |
|
||||||
|
| P2-3 | 缺少年级升级功能 | 新增 `promoteGradeAction`,学年末批量升级 |
|
||||||
|
| P2-4 | 缺少批量操作 | 批量导入学生、批量分配教师 |
|
||||||
|
| P2-5 | `school` 模块审计日志不一致 | 为 department/academicYear/grade 的 CRUD 补充 `logAudit` |
|
||||||
|
|
||||||
|
### 重构方案设计要点(参照用户强制原则)
|
||||||
|
|
||||||
|
1. **完全解耦**:以 `grade-management` 模块的 `GradeService` 接口 + `GradeServiceProvider` Context 注入为范本,为 school 和 classes 模块建立对应的 `SchoolService` / `ClassService` 接口
|
||||||
|
2. **组合优先**:参照 `GradeManagementWidget` 的组合方式(Toolbar + Table + FormDialog + DeleteDialog),所有模块的 Widget 通过组合子组件实现
|
||||||
|
3. **国际化就绪**:翻译文件结构示例
|
||||||
|
```json
|
||||||
|
// school.json
|
||||||
|
{
|
||||||
|
"schools": { "title": "学校管理", "list": { "title": "学校列表", "empty": "暂无学校" }, "form": { ... } },
|
||||||
|
"grades": { "title": "年级管理", ... },
|
||||||
|
"departments": { "title": "部门管理", ... },
|
||||||
|
"academicYear": { "title": "学年管理", ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. **最大化复用**:抽取 `OrgCrudWidget<T>` 泛型组件(列表+工具栏+表单+删除),school/grade/department 共用
|
||||||
|
5. **错误与边界**:每个 Widget 用 Error Boundary 包裹,异步数据用 Suspense + 骨架屏
|
||||||
|
6. **可测试性**:数据获取/筛选/校验逻辑全部抽取为 hooks
|
||||||
|
7. **可扩展性**:参照 `GRADE_ROLE_CONFIG`,为 school/classes 建立角色配置驱动设计
|
||||||
|
8. **企业级补充**:a11y(语义化标签 + ARIA)、性能(RSC 获取初始数据)、安全(data-access 层权限过滤)、监控(`GradeAnalyticsTracker` 模式扩展到 school/classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构图同步说明
|
||||||
|
|
||||||
|
本次审计发现架构图存在以下遗漏和不一致,需同步更新:
|
||||||
|
|
||||||
|
### 5.1 需补充的节点
|
||||||
|
|
||||||
|
| 文档 | 需补充内容 |
|
||||||
|
|------|-----------|
|
||||||
|
| `004_architecture_impact_map.md` | 新增 `## 2.X grade-management(年级管理模块)` 章节,记录其 services/hooks/widgets/config 架构,并标注"⚠️ 该模块当前未被任何 app 页面使用" |
|
||||||
|
| `004_architecture_impact_map.md` | 在 `## 2.8 school` 章节补充说明:school 模块包含 grade CRUD 但与 grade-management 模块职责重叠 |
|
||||||
|
| `004_architecture_impact_map.md` | 在路由表中补充 `teacher/classes/*` 4 个页面缺少 `requirePermission` 的标注 |
|
||||||
|
| `005_architecture_data.json` | `modules` 节点新增 `grade-management` 模块及其 exports/dependencies |
|
||||||
|
| `005_architecture_data.json` | `dependencyMatrix` 新增 grade-management → classes(通过 data-access)、grade-management → school 的依赖关系 |
|
||||||
|
| `005_architecture_data.json` | `routes` 节点补充 teacher/classes/* 的权限缺失标注 |
|
||||||
|
|
||||||
|
### 5.2 需修改的节点
|
||||||
|
|
||||||
|
| 文档 | 需修改内容 |
|
||||||
|
|------|-----------|
|
||||||
|
| `004_architecture_impact_map.md` §2.7 classes | 更新 `actions.ts` 行数(676 → 974),标注接近硬上限 |
|
||||||
|
| `004_architecture_impact_map.md` §2.8 school | 更新 `data-access.ts` 行数(186 → 504),补充新增的跨模块查询函数(`getGradeNameById` / `getSubjectNameById` / `isGradeHead` / `isGradeManager` / `findGradeIdByHeadAndName`) |
|
||||||
|
|
||||||
|
### 5.3 i18n 翻译文件结构示例
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/i18n/messages/
|
||||||
|
├─ zh-CN/
|
||||||
|
│ ├─ school.json ← 新增(学校/学年/部门管理翻译键)
|
||||||
|
│ ├─ grade.json ← 已存在(年级管理翻译键,grade-management 模块用)
|
||||||
|
│ └─ classes.json ← 已存在(班级管理翻译键,需扩充)
|
||||||
|
└─ en/
|
||||||
|
├─ school.json ← 新增
|
||||||
|
├─ grade.json ← 已存在
|
||||||
|
└─ classes.json ← 已存在
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:审计检查清单
|
||||||
|
|
||||||
|
| 检查项 | school | grade-management | classes |
|
||||||
|
|--------|:------:|:---------------:|:-------:|
|
||||||
|
| 三层架构划分合理 | ✅ | ✅ | ⚠️ actions.ts 过大 |
|
||||||
|
| 文件大小符合规范 | ✅ | ✅ | ❌ actions.ts 974 行 |
|
||||||
|
| 无跨模块直接依赖 | ✅ | ✅ | ✅ |
|
||||||
|
| Server Action 权限校验 | ✅ | ✅ | ⚠️ 角色硬编码 |
|
||||||
|
| 前端无 role 硬编码 | ✅ | ✅ | ✅ |
|
||||||
|
| i18n 适配 | ❌ | ✅(组件层) | ❌ |
|
||||||
|
| 错误处理/边界 | ❌ | ✅ | ❌ |
|
||||||
|
| 骨架屏/空状态 | ⚠️ 部分 | ✅ | ⚠️ 部分 |
|
||||||
|
| 逻辑与 UI 分离 | ❌ | ✅ | ⚠️ 部分 |
|
||||||
|
| 组合模式 | ❌ | ✅ | ⚠️ 部分 |
|
||||||
|
| 配置驱动 | ❌ | ✅ | ❌ |
|
||||||
|
| 被页面实际使用 | ✅ | ❌ **死模块** | ✅ |
|
||||||
|
| 审计日志完整 | ⚠️ 不一致 | ✅ | ✅ |
|
||||||
|
| 监控埋点接口 | ❌ | ✅(预留) | ❌ |
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
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 { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { SchoolsClient } from "@/modules/school/components/schools-view"
|
import { SchoolsClient } from "@/modules/school/components/schools-view"
|
||||||
import { getSchools } from "@/modules/school/data-access"
|
import { getSchools } from "@/modules/school/data-access"
|
||||||
|
|
||||||
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("school")
|
||||||
|
return {
|
||||||
|
title: `${t("schools.title")} - Next_Edu`,
|
||||||
|
description: t("schools.description"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminSchoolsPage(): Promise<JSX.Element> {
|
export default async function AdminSchoolsPage(): Promise<JSX.Element> {
|
||||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||||
|
const t = await getTranslations("school")
|
||||||
const schools = await getSchools()
|
const schools = await getSchools()
|
||||||
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">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">学校管理</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("schools.title")}</h2>
|
||||||
<p className="text-muted-foreground">多校区场景下的学校管理。</p>
|
<p className="text-muted-foreground">{t("schools.description")}</p>
|
||||||
</div>
|
</div>
|
||||||
<SchoolsClient schools={schools} />
|
<SchoolsClient schools={schools} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
|
||||||
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
|
||||||
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
|
||||||
@@ -17,6 +19,7 @@ export default async function ClassDetailPage({
|
|||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
await requirePermission(Permissions.CLASS_READ)
|
||||||
|
|
||||||
// Parallel data fetching
|
// Parallel data fetching
|
||||||
const [insights, students, schedule, studentScores] = await Promise.all([
|
const [insights, students, schedule, studentScores] = await Promise.all([
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
|
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function MyClassesPage(): Promise<JSX.Element> {
|
export default async function MyClassesPage(): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.CLASS_READ)
|
||||||
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
|
const [classes, subjectOptions] = await Promise.all([getTeacherClasses(), getClassSubjects()])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { JSX } from "react"
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { Calendar } from "lucide-react"
|
import { Calendar } from "lucide-react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
|
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
|
||||||
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
|
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
|
||||||
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
import { ScheduleView } from "@/modules/classes/components/schedule-view"
|
||||||
@@ -58,6 +60,7 @@ function ScheduleResultsFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
export default async function SchedulePage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.CLASS_READ)
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { JSX } from "react"
|
|||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
import { User } from "lucide-react"
|
import { User } from "lucide-react"
|
||||||
|
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
|
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
|
||||||
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
import { StudentsFilters } from "@/modules/classes/components/students-filters"
|
||||||
import { StudentsTable } from "@/modules/classes/components/students-table"
|
import { StudentsTable } from "@/modules/classes/components/students-table"
|
||||||
@@ -76,6 +78,7 @@ function StudentsResultsFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
export default async function StudentsPage({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.CLASS_READ)
|
||||||
const classes = await getTeacherClasses()
|
const classes = await getTeacherClasses()
|
||||||
|
|
||||||
// Logic to determine default class (first one available)
|
// Logic to determine default class (first one available)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default getRequestConfig(async () => {
|
|||||||
diagnostic,
|
diagnostic,
|
||||||
attendance,
|
attendance,
|
||||||
elective,
|
elective,
|
||||||
|
school,
|
||||||
] = await Promise.all([
|
] = 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`),
|
||||||
@@ -55,6 +56,7 @@ export default getRequestConfig(async () => {
|
|||||||
import(`@/shared/i18n/messages/${locale}/diagnostic.json`),
|
import(`@/shared/i18n/messages/${locale}/diagnostic.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/attendance.json`),
|
import(`@/shared/i18n/messages/${locale}/attendance.json`),
|
||||||
import(`@/shared/i18n/messages/${locale}/elective.json`),
|
import(`@/shared/i18n/messages/${locale}/elective.json`),
|
||||||
|
import(`@/shared/i18n/messages/${locale}/school.json`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,6 +79,7 @@ export default getRequestConfig(async () => {
|
|||||||
diagnostic: diagnostic.default,
|
diagnostic: diagnostic.default,
|
||||||
attendance: attendance.default,
|
attendance: attendance.default,
|
||||||
elective: elective.default,
|
elective: elective.default,
|
||||||
|
school: school.default,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import type { SchoolListItem } from "../types"
|
import type { SchoolListItem } from "../types"
|
||||||
import { createSchoolAction, deleteSchoolAction, updateSchoolAction } from "../actions"
|
import { createSchoolAction, deleteSchoolAction, updateSchoolAction } from "../actions"
|
||||||
@@ -33,68 +33,53 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
|
||||||
|
|
||||||
export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
||||||
|
const t = useTranslations("school")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isWorking, setIsWorking] = useState(false)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
|
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
|
||||||
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
|
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
|
||||||
|
|
||||||
const handleCreate = async (formData: FormData) => {
|
const createMutation = useActionMutation({
|
||||||
setIsWorking(true)
|
errorMessage: "Failed to create school",
|
||||||
try {
|
onSuccess: () => {
|
||||||
const res = await createSchoolAction(undefined, formData)
|
setCreateOpen(false)
|
||||||
if (res.success) {
|
router.refresh()
|
||||||
toast.success(res.message)
|
},
|
||||||
setCreateOpen(false)
|
})
|
||||||
router.refresh()
|
|
||||||
} else {
|
const updateMutation = useActionMutation({
|
||||||
toast.error(res.message || "Failed to create school")
|
errorMessage: "Failed to update school",
|
||||||
}
|
onSuccess: () => {
|
||||||
} catch {
|
setEditItem(null)
|
||||||
toast.error("Failed to create school")
|
router.refresh()
|
||||||
} finally {
|
},
|
||||||
setIsWorking(false)
|
})
|
||||||
}
|
|
||||||
|
const deleteMutation = useActionMutation({
|
||||||
|
errorMessage: "Failed to delete school",
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteItem(null)
|
||||||
|
router.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isWorking = createMutation.isWorking || updateMutation.isWorking || deleteMutation.isWorking
|
||||||
|
|
||||||
|
const handleCreate = (formData: FormData) => {
|
||||||
|
void createMutation.mutate(() => createSchoolAction(undefined, formData))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdate = async (formData: FormData) => {
|
const handleUpdate = (formData: FormData) => {
|
||||||
if (!editItem) return
|
if (!editItem) return
|
||||||
setIsWorking(true)
|
void updateMutation.mutate(() => updateSchoolAction(editItem.id, undefined, formData))
|
||||||
try {
|
|
||||||
const res = await updateSchoolAction(editItem.id, undefined, formData)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setEditItem(null)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to update school")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to update school")
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
if (!deleteItem) return
|
if (!deleteItem) return
|
||||||
setIsWorking(true)
|
void deleteMutation.mutate(() => deleteSchoolAction(deleteItem.id))
|
||||||
try {
|
|
||||||
const res = await deleteSchoolAction(deleteItem.id)
|
|
||||||
if (res.success) {
|
|
||||||
toast.success(res.message)
|
|
||||||
setDeleteItem(null)
|
|
||||||
router.refresh()
|
|
||||||
} else {
|
|
||||||
toast.error(res.message || "Failed to delete school")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete school")
|
|
||||||
} finally {
|
|
||||||
setIsWorking(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,13 +87,13 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
New school
|
{t("schools.new")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="text-base">All schools</CardTitle>
|
<CardTitle className="text-base">{t("schools.all")}</CardTitle>
|
||||||
<Badge variant="secondary" className="tabular-nums">
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
{schools.length}
|
{schools.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -116,17 +101,17 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{schools.length === 0 ? (
|
{schools.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No schools"
|
title={t("schools.empty.title")}
|
||||||
description="Create your first school to get started."
|
description={t("schools.empty.description")}
|
||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>{t("schools.column.name")}</TableHead>
|
||||||
<TableHead>Code</TableHead>
|
<TableHead>{t("schools.column.code")}</TableHead>
|
||||||
<TableHead>Updated</TableHead>
|
<TableHead>{t("schools.column.updated")}</TableHead>
|
||||||
<TableHead className="w-[60px]" />
|
<TableHead className="w-[60px]" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -146,7 +131,7 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => setEditItem(s)}>
|
<DropdownMenuItem onClick={() => setEditItem(s)}>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Edit
|
{t("schools.actions.edit")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -154,7 +139,7 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
onClick={() => setDeleteItem(s)}
|
onClick={() => setDeleteItem(s)}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
{t("schools.actions.delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -170,23 +155,23 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New school</DialogTitle>
|
<DialogTitle>{t("schools.form.createTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form action={handleCreate} className="space-y-4">
|
<form action={handleCreate} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name</Label>
|
<Label htmlFor="name">{t("schools.form.name")}</Label>
|
||||||
<Input id="name" name="name" placeholder="e.g. First Primary School" autoFocus />
|
<Input id="name" name="name" placeholder={t("schools.form.namePlaceholder")} autoFocus />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="code">Code</Label>
|
<Label htmlFor="code">{t("schools.form.code")}</Label>
|
||||||
<Input id="code" name="code" placeholder="Optional" />
|
<Input id="code" name="code" placeholder={t("schools.form.codePlaceholder")} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
|
||||||
Cancel
|
{t("schools.form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
Create
|
{t("schools.form.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -201,24 +186,24 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit school</DialogTitle>
|
<DialogTitle>{t("schools.form.editTitle")}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{editItem ? (
|
{editItem ? (
|
||||||
<form action={handleUpdate} className="space-y-4">
|
<form action={handleUpdate} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-name">Name</Label>
|
<Label htmlFor="edit-name">{t("schools.form.name")}</Label>
|
||||||
<Input id="edit-name" name="name" defaultValue={editItem.name} />
|
<Input id="edit-name" name="name" defaultValue={editItem.name} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="edit-code">Code</Label>
|
<Label htmlFor="edit-code">{t("schools.form.code")}</Label>
|
||||||
<Input id="edit-code" name="code" defaultValue={editItem.code || ""} />
|
<Input id="edit-code" name="code" defaultValue={editItem.code || ""} />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
|
||||||
Cancel
|
{t("schools.form.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isWorking}>
|
<Button type="submit" disabled={isWorking}>
|
||||||
Save
|
{t("schools.form.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -234,15 +219,15 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
|
|||||||
>
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete school</AlertDialogTitle>
|
<AlertDialogTitle>{t("schools.delete.title")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete {deleteItem?.name || "this school"} and its grades.
|
{t("schools.delete.description", { name: deleteItem?.name || "" })}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
|
<AlertDialogCancel disabled={isWorking}>{t("schools.delete.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
|
||||||
Delete
|
{t("schools.delete.confirm")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
163
src/shared/i18n/messages/en/school.json
Normal file
163
src/shared/i18n/messages/en/school.json
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
{
|
||||||
|
"schools": {
|
||||||
|
"title": "School Management",
|
||||||
|
"description": "Manage schools across multiple campuses.",
|
||||||
|
"new": "New school",
|
||||||
|
"all": "All schools",
|
||||||
|
"empty": {
|
||||||
|
"title": "No schools",
|
||||||
|
"description": "Create your first school to get started."
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "Name",
|
||||||
|
"code": "Code",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New school",
|
||||||
|
"editTitle": "Edit school",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. First Primary School",
|
||||||
|
"code": "Code",
|
||||||
|
"codePlaceholder": "Optional",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete school",
|
||||||
|
"description": "This will permanently delete {name} and its grades.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grades": {
|
||||||
|
"title": "Grade Management",
|
||||||
|
"description": "Manage grades and assign grade heads.",
|
||||||
|
"new": "New grade",
|
||||||
|
"all": "All grades",
|
||||||
|
"empty": {
|
||||||
|
"title": "No grades",
|
||||||
|
"description": "Create your first grade to get started."
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"school": "School",
|
||||||
|
"grade": "Grade",
|
||||||
|
"order": "Order",
|
||||||
|
"gradeHead": "Grade Head",
|
||||||
|
"teachingHead": "Teaching Head",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New grade",
|
||||||
|
"editTitle": "Edit grade",
|
||||||
|
"school": "School",
|
||||||
|
"schoolPlaceholder": "Select school",
|
||||||
|
"name": "Grade name",
|
||||||
|
"namePlaceholder": "e.g. Grade 1",
|
||||||
|
"order": "Order",
|
||||||
|
"gradeHead": "Grade Head",
|
||||||
|
"gradeHeadPlaceholder": "Select grade head",
|
||||||
|
"teachingHead": "Teaching Head",
|
||||||
|
"teachingHeadPlaceholder": "Select teaching head",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete grade",
|
||||||
|
"description": "Are you sure you want to delete {name}? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
|
"notSet": "Not set"
|
||||||
|
},
|
||||||
|
"departments": {
|
||||||
|
"title": "Department Management",
|
||||||
|
"description": "Manage school departments.",
|
||||||
|
"new": "New department",
|
||||||
|
"all": "All departments",
|
||||||
|
"empty": {
|
||||||
|
"title": "No departments",
|
||||||
|
"description": "Create your first department to get started."
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "Name",
|
||||||
|
"description": "Description",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New department",
|
||||||
|
"editTitle": "Edit department",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. Chinese Teaching Group",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Optional",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete department",
|
||||||
|
"description": "Are you sure you want to delete {name}? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"academicYear": {
|
||||||
|
"title": "Academic Year Management",
|
||||||
|
"description": "Manage academic year periods.",
|
||||||
|
"new": "New academic year",
|
||||||
|
"all": "All academic years",
|
||||||
|
"active": "Active",
|
||||||
|
"empty": {
|
||||||
|
"title": "No academic years",
|
||||||
|
"description": "Create your first academic year to get started."
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "Name",
|
||||||
|
"startDate": "Start date",
|
||||||
|
"endDate": "End date",
|
||||||
|
"status": "Status",
|
||||||
|
"updated": "Updated",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "New academic year",
|
||||||
|
"editTitle": "Edit academic year",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "e.g. 2025-2026",
|
||||||
|
"startDate": "Start date",
|
||||||
|
"endDate": "End date",
|
||||||
|
"isActive": "Set as active year",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create",
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Delete academic year",
|
||||||
|
"description": "Are you sure you want to delete {name}? This action cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Delete"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/shared/i18n/messages/zh-CN/school.json
Normal file
163
src/shared/i18n/messages/zh-CN/school.json
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
{
|
||||||
|
"schools": {
|
||||||
|
"title": "学校管理",
|
||||||
|
"description": "多校区场景下的学校管理。",
|
||||||
|
"new": "新建学校",
|
||||||
|
"all": "所有学校",
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无学校",
|
||||||
|
"description": "创建你的第一所学校以开始使用。"
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "名称",
|
||||||
|
"code": "代码",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "新建学校",
|
||||||
|
"editTitle": "编辑学校",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "如:第一小学",
|
||||||
|
"code": "代码",
|
||||||
|
"codePlaceholder": "可选",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除学校",
|
||||||
|
"description": "此操作将永久删除「{name}」及其下属年级,不可撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "删除"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"grades": {
|
||||||
|
"title": "年级管理",
|
||||||
|
"description": "管理年级并分配年级组长。",
|
||||||
|
"new": "新建年级",
|
||||||
|
"all": "所有年级",
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无年级",
|
||||||
|
"description": "创建你的第一个年级以开始使用。"
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"school": "学校",
|
||||||
|
"grade": "年级",
|
||||||
|
"order": "排序",
|
||||||
|
"gradeHead": "年级主任",
|
||||||
|
"teachingHead": "教学主任",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "新建年级",
|
||||||
|
"editTitle": "编辑年级",
|
||||||
|
"school": "学校",
|
||||||
|
"schoolPlaceholder": "选择学校",
|
||||||
|
"name": "年级名称",
|
||||||
|
"namePlaceholder": "如:一年级",
|
||||||
|
"order": "排序",
|
||||||
|
"gradeHead": "年级主任",
|
||||||
|
"gradeHeadPlaceholder": "选择年级主任",
|
||||||
|
"teachingHead": "教学主任",
|
||||||
|
"teachingHeadPlaceholder": "选择教学主任",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除年级",
|
||||||
|
"description": "确定要删除「{name}」吗?此操作不可撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "删除"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
},
|
||||||
|
"notSet": "未设置"
|
||||||
|
},
|
||||||
|
"departments": {
|
||||||
|
"title": "部门管理",
|
||||||
|
"description": "管理学校部门。",
|
||||||
|
"new": "新建部门",
|
||||||
|
"all": "所有部门",
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无部门",
|
||||||
|
"description": "创建你的第一个部门以开始使用。"
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "名称",
|
||||||
|
"description": "描述",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "新建部门",
|
||||||
|
"editTitle": "编辑部门",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "如:语文教研组",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "可选",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除部门",
|
||||||
|
"description": "确定要删除「{name}」吗?此操作不可撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "删除"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"academicYear": {
|
||||||
|
"title": "学年管理",
|
||||||
|
"description": "管理学年起止时间。",
|
||||||
|
"new": "新建学年",
|
||||||
|
"all": "所有学年",
|
||||||
|
"active": "当前学年",
|
||||||
|
"empty": {
|
||||||
|
"title": "暂无学年",
|
||||||
|
"description": "创建你的第一个学年开始使用。"
|
||||||
|
},
|
||||||
|
"column": {
|
||||||
|
"name": "名称",
|
||||||
|
"startDate": "开始日期",
|
||||||
|
"endDate": "结束日期",
|
||||||
|
"status": "状态",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"actions": "操作"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"createTitle": "新建学年",
|
||||||
|
"editTitle": "编辑学年",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "如:2025-2026学年",
|
||||||
|
"startDate": "开始日期",
|
||||||
|
"endDate": "结束日期",
|
||||||
|
"isActive": "设为当前学年",
|
||||||
|
"cancel": "取消",
|
||||||
|
"create": "创建",
|
||||||
|
"save": "保存"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "删除学年",
|
||||||
|
"description": "确定要删除「{name}」吗?此操作不可撤销。",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "删除"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "编辑",
|
||||||
|
"delete": "删除"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user