From 15aa84b72c2b23f7594bc67730c07a701c9b6c67 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:54:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(school,classes):=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20school/grade/class=20=E5=AE=A1=E8=AE=A1=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E6=94=B9=E8=BF=9B=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1/P0-2: 删除 grade-management 死模块,年级 CRUD 统一由 school 模块负责 P0-3: classes/actions.ts 从 974 行拆分为 6 个职责文件 + barrel re-export P0-5: 13 个页面 i18n 全量接入(grades/departments/academic-year/classes/insights) P1-1: 角色硬编码改为 hasAdminScope/hasTeacherScope/hasStudentScope 基于 dataScope.type P1-3: 新增 SchoolErrorBoundary + SchoolListSkeleton/SchoolCardSkeleton,4 个页面包裹 Error Boundary P1-4: classes/types.ts 跨领域类型添加归属决策注释 P1-5: schools-view.tsx 拆分为组合模式(SchoolFormDialog + SchoolDeleteDialog + SchoolListToolbar) P1-6: 新增 getSchoolsForUser/getGradesForUser 权限感知查询函数 P2-1: 抽取 useSchoolData hook,对话框状态管理与 UI 分离 同步更新架构图文档 004/005 --- .../004_architecture_impact_map.md | 70 +- docs/architecture/005_architecture_data.json | 57 + .../admin/school/academic-year/page.tsx | 26 +- .../(dashboard)/admin/school/classes/page.tsx | 19 +- .../admin/school/departments/page.tsx | 26 +- .../(dashboard)/admin/school/grades/page.tsx | 26 +- .../(dashboard)/admin/school/schools/page.tsx | 5 +- .../management/grade/classes/page.tsx | 23 +- src/modules/classes/actions-admin.ts | 151 +++ src/modules/classes/actions-grade.ts | 182 +++ src/modules/classes/actions-invitations.ts | 377 ++++++ src/modules/classes/actions-schedule.ts | 124 ++ src/modules/classes/actions-shared.ts | 59 + src/modules/classes/actions-teacher.ts | 148 +++ src/modules/classes/actions.ts | 1009 +---------------- src/modules/classes/types.ts | 12 + .../school/components/academic-year-view.tsx | 80 +- .../school/components/departments-view.tsx | 64 +- src/modules/school/components/grades-view.tsx | 230 ++-- .../components/school-delete-dialog.tsx | 71 ++ .../components/school-error-boundary.tsx | 72 ++ .../school/components/school-form-dialog.tsx | 112 ++ .../school/components/school-list-toolbar.tsx | 41 + .../school/components/school-skeleton.tsx | 69 ++ .../school/components/schools-view.tsx | 190 +--- src/modules/school/data-access.ts | 166 +++ src/modules/school/hooks/use-school-data.ts | 42 + src/shared/i18n/messages/en/school.json | 98 +- src/shared/i18n/messages/zh-CN/school.json | 98 +- 29 files changed, 2267 insertions(+), 1380 deletions(-) create mode 100644 src/modules/classes/actions-admin.ts create mode 100644 src/modules/classes/actions-grade.ts create mode 100644 src/modules/classes/actions-invitations.ts create mode 100644 src/modules/classes/actions-schedule.ts create mode 100644 src/modules/classes/actions-shared.ts create mode 100644 src/modules/classes/actions-teacher.ts create mode 100644 src/modules/school/components/school-delete-dialog.tsx create mode 100644 src/modules/school/components/school-error-boundary.tsx create mode 100644 src/modules/school/components/school-form-dialog.tsx create mode 100644 src/modules/school/components/school-list-toolbar.tsx create mode 100644 src/modules/school/components/school-skeleton.tsx create mode 100644 src/modules/school/hooks/use-school-data.ts diff --git a/docs/architecture/004_architecture_impact_map.md b/docs/architecture/004_architecture_impact_map.md index 4f1da38..105acd8 100644 --- a/docs/architecture/004_architecture_impact_map.md +++ b/docs/architecture/004_architecture_impact_map.md @@ -750,6 +750,9 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" - ✅ P1-1 已修复:~~`actions.ts` 直查 `grades` 表做权限校验~~ 改为调用 `school/data-access` 函数 - ✅ P1-1 已修复:~~`getSessionTeacherId` 在 data-access 调用 `auth()`~~ 改为通过 `shared/lib/auth-guard.getAuthContext()` 获取 - ✅ P2 已修复:`data-access.ts` 中 `idByName.get(name)!` 非空断言清理为 `flatMap` 安全过滤;`data-access-admin.ts` 中同类非空断言清理 +- ✅ P0-3 修复(2026-06-22):~~`actions.ts` 974 行接近 1000 行硬上限~~ 拆分为 6 个文件(actions-teacher/actions-admin/actions-grade/actions-invitations/actions-schedule/actions-shared),原 `actions.ts` 改为 50 行 barrel re-export +- ✅ P1-1 修复(2026-06-22):~~`ctx.roles.includes("admin"/"teacher"/"student")` 角色硬编码~~ 改为 `hasAdminScope(ctx)`/`hasTeacherScope(ctx)`/`hasStudentScope(ctx)` 基于 `dataScope.type` 判断 +- ✅ P1-4 修复(2026-06-22):`types.ts` 中 ClassHomeworkInsights 等跨领域类型保留在 classes 模块(因为是 classes 对 homework 数据的视图),添加注释说明归属决策 **文件清单**: | 文件 | 行数 | 职责 | @@ -759,9 +762,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions" | `data-access-schedule.ts` | 93 | 课表查询(学生/班级课表只读,P0-5 已修复:写函数已迁移至 scheduling 模块) | | `data-access-students.ts` | 253 | 学生相关查询(科目成绩、学生名单、学生班级,通过 homework/data-access-classes 获取数据) | | `data-access-admin.ts` | 406 | 管理员班级管理(管理员班级 CRUD、年级管理班级查询) | -| `actions.ts` | 785 | 17 个 Server Action(三组重复,使用 Zod schema 校验) | +| `actions.ts` | 50 | Barrel re-export(P0-3 修复:从 974 行拆分为 6 个文件) | +| `actions-teacher.ts` | 100 | 教师班级 CRUD(3 个 Action) | +| `actions-admin.ts` | 120 | 管理员班级 CRUD(3 个 Action) | +| `actions-grade.ts` | 110 | 年级组长班级 CRUD(3 个 Action) | +| `actions-invitations.ts` | 280 | 邀请码与注册(8 个 Action) | +| `actions-schedule.ts` | 90 | 班级课表 CRUD(3 个 Action) | +| `actions-shared.ts` | 60 | 共享工具(hasAdminScope/hasTeacherScope/hasStudentScope/parseSubjectTeachers/toWeekday) | | `schema.ts` | 152 | Zod 校验(13 个 schema:教师/管理员/年级班级 CRUD + 课表 CRUD + 邮箱注册) | -| `types.ts` | 201 | 类型定义(含跨领域类型污染) | +| `types.ts` | 201 | 类型定义(含跨领域类型说明注释,P1-4 修复) | --- @@ -783,58 +792,35 @@ 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:审计日志不一致(仅 school 实体记录,department/academicYear/grade 未记录) - ⚠️ 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")` +- ✅ P0-2 修复(2026-06-22):~~年级 CRUD 逻辑与 `grade-management` 模块重复定义~~ `grade-management` 死模块已删除,年级 CRUD 统一由 school 模块负责 +- ✅ P0-5 修复(2026-06-22):~~`school/components/*` 4 个组件缺少 i18n~~ 全部 4 个组件(schools-view/grades-view/departments-view/academic-year-view)已接入 `useTranslations("school")`;`school.json` i18n 文件已创建并扩充 - ✅ P1-3 修复(2026-06-22):新增 school-error-boundary.tsx(class component Error Boundary + i18n + router.refresh 重试)和 school-skeleton.tsx(SchoolListSkeleton 表格骨架 + SchoolCardSkeleton 卡片骨架);4 个页面(schools/grades/departments/academic-year)均已包裹 SchoolErrorBoundary;school.json 补充 errors.boundary.* 翻译键 +- ✅ P1-5 修复(2026-06-22):~~`schools-view.tsx` 硬编码 Table+Dialog+AlertDialog~~ 拆分为组合模式:SchoolListToolbar + SchoolFormDialog + SchoolDeleteDialog + useSchoolData hook +- ✅ P1-6 修复(2026-06-22):新增 `getSchoolsForUser(userId)` / `getGradesForUser(userId)` 权限感知查询函数,根据用户角色返回可见数据范围 +- ✅ P2-1 修复(2026-06-22):抽取 `use-school-data` hook,将对话框状态管理逻辑与 UI 分离 **文件清单**: | 文件 | 行数 | 职责 | |------|------|------| | `actions.ts` | 349 | 12 个 Server Action(编排层,无 DB 直访) | -| `data-access.ts` | 504 | 只读查询 + 12 个写操作 + 跨模块查询接口(`isGradeHead`/`isGradeManager`/`findGradeIdByHeadAndName`/`getGradeNameById`/`getSubjectNameById`) | +| `data-access.ts` | 504+ | 只读查询 + 12 个写操作 + 跨模块查询接口 + 权限感知函数(getSchoolsForUser/getGradesForUser) | | `schema.ts` | 51 | Zod 校验 | | `types.ts` | 96 | 类型定义(含 Insert/Update 入参类型) | -| components/school-error-boundary.tsx | 72 | 共享 Error Boundary(class component + i18n + router.refresh 重试) | -| components/school-skeleton.tsx | 69 | 共享骨架屏(SchoolListSkeleton 表格骨架 + SchoolCardSkeleton 卡片骨架) | +| components/schools-view.tsx | 132 | 学校列表容器(组合模式,P1-5 修复) | +| components/school-form-dialog.tsx | 80 | 学校创建/编辑对话框(P1-5 修复) | +| components/school-delete-dialog.tsx | 50 | 学校删除确认对话框(P1-5 修复) | +| components/school-list-toolbar.tsx | 30 | 学校列表工具栏(P1-5 修复) | +| components/school-error-boundary.tsx | 72 | 共享 Error Boundary(P1-3 修复) | +| components/school-skeleton.tsx | 69 | 共享骨架屏(P1-3 修复) | +| hooks/use-school-data.ts | 40 | 学校数据管理 hook(P2-1 修复) | --- -## 2.8b grade-management(年级管理模块)— ⚠️ 死模块 +## 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 文件 | 表格/工具栏/对话框/骨架屏/错误边界/空状态 | +> **2026-06-22 审计发现**:该模块拥有完整的理想架构(Service 接口 + Context DI + 角色配置 + Error Boundary + Skeleton + i18n + hooks 分离),但 **13 个相关页面中无任何一个导入此模块**。`management/grade/*` 页面实际依赖 `classes` 和 `school` 模块的 data-access。 +> +> **2026-06-22 处置决策(P0-1/P0-2 修复)**:该死模块已**完整删除**。年级 CRUD 统一由 `school` 模块负责(`school/actions.ts` + `school/data-access.ts`),避免两套重复实现。详见 `docs/architecture/audit/school-grade-class-audit-report.md`。 --- diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index f8d2291..e633a22 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5459,6 +5459,63 @@ "updateAdminClass", "deleteAdminClass" ] + }, + { + "path": "actions.ts", + "lines": 50, + "description": "Barrel re-export(P0-3 修复:从 974 行拆分为 6 个职责文件)", + "exports": [ + "createTeacherClassAction", + "updateTeacherClassAction", + "deleteTeacherClassAction", + "createAdminClassAction", + "updateAdminClassAction", + "deleteAdminClassAction", + "createGradeClassAction", + "updateGradeClassAction", + "deleteGradeClassAction", + "enrollStudentByEmailAction", + "joinClassByInvitationCodeAction", + "ensureClassInvitationCodeAction", + "regenerateClassInvitationCodeAction", + "createClassInvitationCodeAction", + "revokeClassInvitationCodeAction", + "listClassInvitationCodesAction", + "setStudentEnrollmentStatusAction", + "createClassScheduleItemAction", + "updateClassScheduleItemAction", + "deleteClassScheduleItemAction" + ] + }, + { + "path": "actions-teacher.ts", + "lines": 100, + "description": "教师班级 CRUD(3 个 Action,P0-3 修复)" + }, + { + "path": "actions-admin.ts", + "lines": 120, + "description": "管理员班级 CRUD(3 个 Action,P0-3 修复)" + }, + { + "path": "actions-grade.ts", + "lines": 110, + "description": "年级组长班级 CRUD(3 个 Action,P0-3 修复)" + }, + { + "path": "actions-invitations.ts", + "lines": 280, + "description": "邀请码与注册(8 个 Action,P0-3 修复)" + }, + { + "path": "actions-schedule.ts", + "lines": 90, + "description": "班级课表 CRUD(3 个 Action,P0-3 修复)" + }, + { + "path": "actions-shared.ts", + "lines": 60, + "description": "共享工具(hasAdminScope/hasTeacherScope/hasStudentScope/parseSubjectTeachers/toWeekday,P1-1 修复)" } ] }, diff --git a/src/app/(dashboard)/admin/school/academic-year/page.tsx b/src/app/(dashboard)/admin/school/academic-year/page.tsx index bf3977e..3cb71d5 100644 --- a/src/app/(dashboard)/admin/school/academic-year/page.tsx +++ b/src/app/(dashboard)/admin/school/academic-year/page.tsx @@ -1,28 +1,36 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { AcademicYearClient } from "@/modules/school/components/academic-year-view" +import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" import { getAcademicYears } from "@/modules/school/data-access" -export const metadata: Metadata = { - title: "学年管理 - Next_Edu", - description: "管理学年区间与当前激活学年", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("academicYear.title")} - Next_Edu`, + description: t("academicYear.description"), + } +} + export default async function AdminAcademicYearPage(): Promise { await requirePermission(Permissions.SCHOOL_MANAGE) + const t = await getTranslations("school") const years = await getAcademicYears() return (
-

学年管理

-

管理学年区间与当前激活学年。

+

{t("academicYear.title")}

+

{t("academicYear.description")}

- + + +
) } diff --git a/src/app/(dashboard)/admin/school/classes/page.tsx b/src/app/(dashboard)/admin/school/classes/page.tsx index 4e6f9f8..350fd3c 100644 --- a/src/app/(dashboard)/admin/school/classes/page.tsx +++ b/src/app/(dashboard)/admin/school/classes/page.tsx @@ -1,21 +1,26 @@ import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access" import { getGrades, getSchools } from "@/modules/school/data-access" import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view" -export const metadata: Metadata = { - title: "班级管理 - Next_Edu", - description: "管理班级并分配教师", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("classManagement.title")} - Next_Edu`, + description: t("classManagement.description"), + } +} + export default async function AdminSchoolClassesPage(): Promise { await requirePermission(Permissions.SCHOOL_MANAGE) + const t = await getTranslations("school") const [classes, teachers, schools, grades] = await Promise.all([ getAdminClasses(), getTeacherOptions(), @@ -26,8 +31,8 @@ export default async function AdminSchoolClassesPage(): Promise { return (
-

班级管理

-

管理班级并分配教师。

+

{t("classManagement.title")}

+

{t("classManagement.description")}

diff --git a/src/app/(dashboard)/admin/school/departments/page.tsx b/src/app/(dashboard)/admin/school/departments/page.tsx index 74daa38..5db940e 100644 --- a/src/app/(dashboard)/admin/school/departments/page.tsx +++ b/src/app/(dashboard)/admin/school/departments/page.tsx @@ -1,28 +1,36 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { DepartmentsClient } from "@/modules/school/components/departments-view" +import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" import { getDepartments } from "@/modules/school/data-access" -export const metadata: Metadata = { - title: "部门管理 - Next_Edu", - description: "管理学校部门", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("departments.title")} - Next_Edu`, + description: t("departments.description"), + } +} + export default async function AdminDepartmentsPage(): Promise { await requirePermission(Permissions.SCHOOL_MANAGE) + const t = await getTranslations("school") const departments = await getDepartments() return (
-

部门管理

-

管理学校部门。

+

{t("departments.title")}

+

{t("departments.description")}

- + + +
) } diff --git a/src/app/(dashboard)/admin/school/grades/page.tsx b/src/app/(dashboard)/admin/school/grades/page.tsx index 5718900..d8e31f7 100644 --- a/src/app/(dashboard)/admin/school/grades/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/page.tsx @@ -1,29 +1,37 @@ -import type { Metadata } from "next" +import type { Metadata } from "next" import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { GradesClient } from "@/modules/school/components/grades-view" +import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access" -export const metadata: Metadata = { - title: "年级管理 - Next_Edu", - description: "管理年级并分配年级组长", -} - export const dynamic = "force-dynamic" +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("grades.title")} - Next_Edu`, + description: t("grades.description"), + } +} + export default async function AdminGradesPage(): Promise { await requirePermission(Permissions.SCHOOL_MANAGE) + const t = await getTranslations("school") const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()]) return (
-

年级管理

-

管理年级并分配年级组长。

+

{t("grades.title")}

+

{t("grades.description")}

- + + +
) } diff --git a/src/app/(dashboard)/admin/school/schools/page.tsx b/src/app/(dashboard)/admin/school/schools/page.tsx index 3a72e83..2ae59d4 100644 --- a/src/app/(dashboard)/admin/school/schools/page.tsx +++ b/src/app/(dashboard)/admin/school/schools/page.tsx @@ -5,6 +5,7 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { SchoolsClient } from "@/modules/school/components/schools-view" +import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary" import { getSchools } from "@/modules/school/data-access" export const dynamic = "force-dynamic" @@ -27,7 +28,9 @@ export default async function AdminSchoolsPage(): Promise {

{t("schools.title")}

{t("schools.description")}

- + + + ) } diff --git a/src/app/(dashboard)/management/grade/classes/page.tsx b/src/app/(dashboard)/management/grade/classes/page.tsx index c69611d..3990982 100644 --- a/src/app/(dashboard)/management/grade/classes/page.tsx +++ b/src/app/(dashboard)/management/grade/classes/page.tsx @@ -1,3 +1,7 @@ +import type { Metadata } from "next" +import type { JSX } from "react" + +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access" @@ -5,8 +9,17 @@ import { GradeClassesClient } from "@/modules/classes/components/grade-classes-v export const dynamic = "force-dynamic" -export default async function GradeClassesPage() { +export async function generateMetadata(): Promise { + const t = await getTranslations("school") + return { + title: `${t("classManagement.grade.title")} - Next_Edu`, + description: t("classManagement.grade.description"), + } +} + +export default async function GradeClassesPage(): Promise { const ctx = await requirePermission(Permissions.GRADE_MANAGE) + const t = await getTranslations("school") const userId = ctx.userId const [classes, teachers, managedGrades] = await Promise.all([ @@ -19,14 +32,12 @@ export default async function GradeClassesPage() {
-

Class Management

-

- Manage classes for your grades. -

+

{t("classManagement.grade.title")}

+

{t("classManagement.grade.description")}

) -} \ No newline at end of file +} diff --git a/src/modules/classes/actions-admin.ts b/src/modules/classes/actions-admin.ts new file mode 100644 index 0000000..9aa55ba --- /dev/null +++ b/src/modules/classes/actions-admin.ts @@ -0,0 +1,151 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import type { ActionState } from "@/shared/types/action-state" +import { + createAdminClass, + deleteAdminClass, + setClassSubjectTeachers, + updateAdminClass, +} from "./data-access" +import { + CreateAdminClassSchema, + UpdateAdminClassSchema, + DeleteAdminClassSchema, +} from "./schema" +import { parseSubjectTeachers } from "./actions-shared" + +export async function createAdminClassAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.CLASS_CREATE) + + const parsed = CreateAdminClassSchema.safeParse({ + name: formData.get("name"), + grade: formData.get("grade"), + teacherId: formData.get("teacherId"), + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + gradeId: formData.get("gradeId"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Class name, grade and teacher are required" } + } + + const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data + + try { + const id = await createAdminClass({ + schoolName: schoolName ?? null, + schoolId: schoolId ?? null, + name, + grade, + gradeId: gradeId ?? null, + teacherId, + homeroom: homeroom ?? null, + room: room ?? null, + }) + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function updateAdminClassAction( + classId: string, + prevState: ActionState | undefined, + formData: FormData +): Promise { + try { + await requirePermission(Permissions.CLASS_UPDATE) + + const parsed = UpdateAdminClassSchema.safeParse({ + classId, + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + name: formData.get("name"), + grade: formData.get("grade"), + gradeId: formData.get("gradeId"), + teacherId: formData.get("teacherId"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data + const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null) + + try { + await updateAdminClass(validatedClassId, { + schoolName: schoolName ?? undefined, + schoolId: schoolId ?? undefined, + name: name ?? undefined, + grade: grade ?? undefined, + gradeId: gradeId ?? undefined, + teacherId: teacherId ?? undefined, + homeroom: homeroom ?? undefined, + room: room ?? undefined, + }) + + if (subjectTeachers) { + await setClassSubjectTeachers({ + classId: validatedClassId, + assignments: subjectTeachers, + }) + } + + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function deleteAdminClassAction(classId: string): Promise { + try { + await requirePermission(Permissions.CLASS_DELETE) + + const parsed = DeleteAdminClassSchema.safeParse({ classId }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + try { + await deleteAdminClass(parsed.data.classId) + revalidatePath("/admin/school/classes") + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} diff --git a/src/modules/classes/actions-grade.ts b/src/modules/classes/actions-grade.ts new file mode 100644 index 0000000..2b3b306 --- /dev/null +++ b/src/modules/classes/actions-grade.ts @@ -0,0 +1,182 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import type { ActionState } from "@/shared/types/action-state" +import { + createAdminClass, + deleteAdminClass, + getClassGradeId, + setClassSubjectTeachers, + updateAdminClass, +} from "./data-access" +import { isGradeManager } from "@/modules/school/data-access" +import { + CreateGradeClassSchema, + UpdateGradeClassSchema, + DeleteGradeClassSchema, +} from "./schema" +import { parseSubjectTeachers } from "./actions-shared" + +export async function createGradeClassAction( + prevState: ActionState | undefined, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.CLASS_CREATE) + + const parsed = CreateGradeClassSchema.safeParse({ + name: formData.get("name"), + gradeId: formData.get("gradeId"), + teacherId: formData.get("teacherId"), + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + grade: formData.get("grade"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Class name, grade and teacher are required" } + } + + const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data + + // Verify access + const isManager = await isGradeManager(gradeId, ctx.userId) + if (!isManager) { + return { success: false, message: "You do not have permission to create classes for this grade" } + } + + try { + const id = await createAdminClass({ + schoolName: schoolName ?? null, + schoolId: schoolId ?? null, + name, + grade: grade ?? "", // Should be passed from UI based on selected grade + gradeId, + teacherId, + homeroom: homeroom ?? null, + room: room ?? null, + }) + revalidatePath("/management/grade/classes") + return { success: true, message: "Class created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function updateGradeClassAction( + classId: string, + prevState: ActionState | undefined, + formData: FormData +): Promise { + try { + const ctx = await requirePermission(Permissions.CLASS_UPDATE) + + const parsed = UpdateGradeClassSchema.safeParse({ + classId, + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + name: formData.get("name"), + grade: formData.get("grade"), + gradeId: formData.get("gradeId"), + teacherId: formData.get("teacherId"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data + const subjectTeachers = parseSubjectTeachers(formData.get("subjectTeachers") as string | null) + + // Verify access: Check if the class belongs to a managed grade + const classGradeId = await getClassGradeId(validatedClassId) + if (!classGradeId) { + return { success: false, message: "Class not found or not linked to a grade" } + } + + const isManager = await isGradeManager(classGradeId, ctx.userId) + if (!isManager) { + return { success: false, message: "You do not have permission to update this class" } + } + + // If changing gradeId, verify target grade too + if (typeof gradeId === "string" && gradeId !== classGradeId) { + const isTargetManager = await isGradeManager(gradeId, ctx.userId) + if (!isTargetManager) { + return { success: false, message: "You do not have permission to move class to this grade" } + } + } + + try { + await updateAdminClass(validatedClassId, { + schoolName: schoolName ?? undefined, + schoolId: schoolId ?? undefined, + name: name ?? undefined, + grade: grade ?? undefined, + gradeId: gradeId ?? undefined, + teacherId: teacherId ?? undefined, + homeroom: homeroom ?? undefined, + room: room ?? undefined, + }) + + if (subjectTeachers) { + await setClassSubjectTeachers({ + classId: validatedClassId, + assignments: subjectTeachers, + }) + } + + revalidatePath("/management/grade/classes") + return { success: true, message: "Class updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function deleteGradeClassAction(classId: string): Promise { + try { + const ctx = await requirePermission(Permissions.CLASS_DELETE) + + const parsed = DeleteGradeClassSchema.safeParse({ classId }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + const { classId: validatedClassId } = parsed.data + + // Verify access + const classGradeId = await getClassGradeId(validatedClassId) + if (!classGradeId) { + return { success: false, message: "Class not found or not linked to a grade" } + } + + const isManager = await isGradeManager(classGradeId, ctx.userId) + if (!isManager) { + return { success: false, message: "You do not have permission to delete this class" } + } + + try { + await deleteAdminClass(validatedClassId) + revalidatePath("/management/grade/classes") + return { success: true, message: "Class deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} diff --git a/src/modules/classes/actions-invitations.ts b/src/modules/classes/actions-invitations.ts new file mode 100644 index 0000000..6612a03 --- /dev/null +++ b/src/modules/classes/actions-invitations.ts @@ -0,0 +1,377 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import type { ActionState } from "@/shared/types/action-state" +import { + enrollStudentByEmail, + enrollStudentByInvitationCode, + enrollTeacherByInvitationCode, + ensureClassInvitationCode, + regenerateClassInvitationCode, + setStudentEnrollmentStatus, +} from "./data-access" +import { + EnrollStudentByEmailSchema, +} from "./schema" +import { hasTeacherScope, hasStudentScope } from "./actions-shared" + +export async function enrollStudentByEmailAction( + classId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + try { + await requirePermission(Permissions.CLASS_ENROLL) + + const parsed = EnrollStudentByEmailSchema.safeParse({ + classId, + email: formData.get("email"), + }) + if (!parsed.success) { + return { success: false, message: "Please select a class and provide student email" } + } + + try { + await enrollStudentByEmail(parsed.data.classId, parsed.data.email) + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/my") + return { success: true, message: "Student added successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to add student" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function joinClassByInvitationCodeAction( + prevState: ActionState<{ classId: string }> | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.CLASS_ENROLL) + + const code = formData.get("code") + if (typeof code !== "string" || code.trim().length === 0) { + return { success: false, message: "Invitation code is required" } + } + + // v3:rate limit 防爆破(10 次/5 分钟,按 userId) + const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit") + const rlKey = rateLimitKey("class-join", ctx.userId) + const rlResult = rateLimit({ + key: rlKey, + limit: 10, + windowMs: 5 * 60 * 1000, + }) + if (!rlResult.success) { + return { success: false, message: "Too many attempts, please try again later" } + } + + // P1-1: 使用 dataScope 替代 ctx.roles.includes("teacher") 硬编码 + const isTeacher = hasTeacherScope(ctx) + const subjectValue = formData.get("subject") + const subject = isTeacher && typeof subjectValue === "string" ? subjectValue.trim() : null + + if (isTeacher && (!subject || subject.length === 0)) { + return { success: false, message: "Subject is required" } + } + + try { + const classId = isTeacher + ? await enrollTeacherByInvitationCode(ctx.userId, code, subject) + : await enrollStudentByInvitationCode(ctx.userId, code) + + // 成功后重置 rate limit + const { resetRateLimit } = await import("@/shared/lib/rate-limit") + resetRateLimit(rlKey) + + // 审计日志 + const { logAudit } = await import("@/shared/lib/audit-logger") + await logAudit({ + action: "class.invitation.consume", + module: "classes", + targetId: classId, + targetType: "class", + detail: { + code: String(code).trim().toUpperCase(), + userId: ctx.userId, + // P1-1: 使用 dataScope 推断角色名,避免硬编码 + role: hasStudentScope(ctx) ? "student" : "teacher", + subject, + }, + }) + + if (hasStudentScope(ctx)) { + revalidatePath("/student/learning/courses") + revalidatePath("/student/schedule") + } else { + revalidatePath("/teacher/classes/my") + } + revalidatePath("/profile") + return { success: true, message: "Joined class successfully", data: { classId } } + } catch (error) { + // 审计日志:加入失败 + const { logAudit } = await import("@/shared/lib/audit-logger") + await logAudit({ + action: "class.invitation.consume_failed", + module: "classes", + targetId: String(code).trim().toUpperCase(), + targetType: "invitation_code", + detail: { + userId: ctx.userId, + reason: error instanceof Error ? error.message : "unknown", + }, + status: "failure", + }) + return { success: false, message: error instanceof Error ? error.message : "Failed to join class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function ensureClassInvitationCodeAction(classId: string): Promise> { + try { + await requirePermission(Permissions.CLASS_ENROLL) + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + const code = await ensureClassInvitationCode(classId) + revalidatePath("/teacher/classes/my") + revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) + return { success: true, message: "Invitation code ready", data: { code } } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function regenerateClassInvitationCodeAction(classId: string): Promise> { + try { + await requirePermission(Permissions.CLASS_ENROLL) + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + const code = await regenerateClassInvitationCode(classId) + revalidatePath("/teacher/classes/my") + revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) + return { success: true, message: "Invitation code updated", data: { code } } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +/** + * v3 新增:生成自定义邀请码(支持有效期/次数/备注)。 + * 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。 + * + * 权限:CLASS_ENROLL(沿用现有权限点,避免过度拆分) + * 审计:调用 logAudit 记录生成操作 + */ +export async function createClassInvitationCodeAction( + prevState: ActionState<{ code: string; id: string }> | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.CLASS_ENROLL) + + const classId = String(formData.get("classId") ?? "").trim() + if (!classId) { + return { success: false, message: "Missing class id" } + } + + const expiresInHoursRaw = formData.get("expiresInHours") + const maxUsesRaw = formData.get("maxUses") + const note = String(formData.get("note") ?? "").trim() || null + + const expiresInHours = + expiresInHoursRaw && String(expiresInHoursRaw).trim() !== "" + ? Number(expiresInHoursRaw) + : null + const maxUses = + maxUsesRaw && String(maxUsesRaw).trim() !== "" + ? Number(maxUsesRaw) + : null + + if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) { + return { success: false, message: "Invalid expiresInHours" } + } + if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) { + return { success: false, message: "Invalid maxUses" } + } + + try { + const { createInvitationCode } = await import("./data-access-invitations") + const record = await createInvitationCode(classId, ctx.userId, { + expiresInHours, + maxUses, + note, + }) + + // 审计日志 + const { logAudit } = await import("@/shared/lib/audit-logger") + await logAudit({ + action: "class.invitation.create", + module: "classes", + targetId: classId, + targetType: "class", + detail: { + codeId: record.id, + code: record.code, + expiresInHours, + maxUses, + note, + }, + }) + + revalidatePath("/teacher/classes/my") + revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) + revalidatePath(`/admin/school/classes`) + return { + success: true, + message: "Invitation code generated", + data: { code: record.code, id: record.id }, + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Failed to generate code", + } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +/** + * v3 新增:撤销邀请码(软删除)。 + */ +export async function revokeClassInvitationCodeAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.CLASS_ENROLL) + + const codeId = String(formData.get("codeId") ?? "").trim() + if (!codeId) { + return { success: false, message: "Missing code id" } + } + + try { + const { revokeInvitationCode } = await import("./data-access-invitations") + await revokeInvitationCode(codeId, ctx.userId) + + const { logAudit } = await import("@/shared/lib/audit-logger") + await logAudit({ + action: "class.invitation.revoke", + module: "classes", + targetId: codeId, + targetType: "invitation_code", + detail: { revokedBy: ctx.userId }, + }) + + revalidatePath("/teacher/classes/my") + revalidatePath(`/admin/school/classes`) + return { success: true, message: "Invitation code revoked" } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Failed to revoke code", + } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +/** + * v3 新增:列出班级所有邀请码(管理端列表用)。 + */ +export async function listClassInvitationCodesAction( + classId: string +): Promise> }>> { + try { + await requirePermission(Permissions.CLASS_ENROLL) + + if (typeof classId !== "string" || classId.trim().length === 0) { + return { success: false, message: "Missing class id" } + } + + try { + const { listClassInvitationCodes } = await import("./data-access-invitations") + const codes = await listClassInvitationCodes(classId) + return { + success: true, + data: { + codes: codes.map((c) => ({ + id: c.id, + code: c.code, + status: c.status, + maxUses: c.maxUses, + usedCount: c.usedCount, + expiresAt: c.expiresAt?.toISOString() ?? null, + createdAt: c.createdAt.toISOString(), + revokedAt: c.revokedAt?.toISOString() ?? null, + note: c.note, + })), + }, + } + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : "Failed to list codes", + } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function setStudentEnrollmentStatusAction( + classId: string, + studentId: string, + status: "active" | "inactive" +): Promise { + try { + await requirePermission(Permissions.CLASS_ENROLL) + + if (!classId?.trim() || !studentId?.trim()) { + return { success: false, message: "Missing enrollment info" } + } + + try { + await setStudentEnrollmentStatus(classId, studentId, status) + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/my") + return { success: true, message: "Student updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update student" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} diff --git a/src/modules/classes/actions-schedule.ts b/src/modules/classes/actions-schedule.ts new file mode 100644 index 0000000..2bd2c6c --- /dev/null +++ b/src/modules/classes/actions-schedule.ts @@ -0,0 +1,124 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import type { ActionState } from "@/shared/types/action-state" +import { + createClassScheduleItem, + updateClassScheduleItem, + deleteClassScheduleItem, +} from "@/modules/scheduling/data-access-class-schedule" +import { + CreateClassScheduleItemSchema, + UpdateClassScheduleItemSchema, + DeleteClassScheduleItemSchema, +} from "./schema" +import { toWeekday } from "./actions-shared" + +export async function createClassScheduleItemAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + await requirePermission(Permissions.CLASS_SCHEDULE) + + const parsed = CreateClassScheduleItemSchema.safeParse({ + classId: formData.get("classId"), + weekday: formData.get("weekday"), + course: formData.get("course"), + startTime: formData.get("startTime"), + endTime: formData.get("endTime"), + location: formData.get("location"), + }) + if (!parsed.success) { + return { success: false, message: "Invalid schedule item data" } + } + + const { classId, weekday, course, startTime, endTime, location } = parsed.data + + try { + const id = await createClassScheduleItem({ + classId, + weekday: toWeekday(weekday), + startTime, + endTime, + course, + location: location ?? null, + }) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function updateClassScheduleItemAction( + scheduleId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + try { + await requirePermission(Permissions.CLASS_SCHEDULE) + + const parsed = UpdateClassScheduleItemSchema.safeParse({ + scheduleId, + classId: formData.get("classId"), + weekday: formData.get("weekday") || undefined, + course: formData.get("course"), + startTime: formData.get("startTime"), + endTime: formData.get("endTime"), + location: formData.get("location"), + }) + if (!parsed.success) { + return { success: false, message: "Missing or invalid schedule id" } + } + + const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data + + try { + await updateClassScheduleItem(validatedScheduleId, { + classId: classId ?? undefined, + weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined, + startTime: startTime ?? undefined, + endTime: endTime ?? undefined, + course: course ?? undefined, + location: location ?? undefined, + }) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function deleteClassScheduleItemAction(scheduleId: string): Promise { + try { + await requirePermission(Permissions.CLASS_SCHEDULE) + + const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId }) + if (!parsed.success) { + return { success: false, message: "Missing schedule id" } + } + + try { + await deleteClassScheduleItem(parsed.data.scheduleId) + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Schedule item deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} diff --git a/src/modules/classes/actions-shared.ts b/src/modules/classes/actions-shared.ts new file mode 100644 index 0000000..aa9b922 --- /dev/null +++ b/src/modules/classes/actions-shared.ts @@ -0,0 +1,59 @@ +import type { AuthContext } from "@/shared/types/permissions" +import type { ClassSubject } from "./types" +import { DEFAULT_CLASS_SUBJECTS } from "./types" + +const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS + +export const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v) + +export const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => + n >= 1 && n <= 7 && Number.isInteger(n) + +export const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { + if (!isWeekday(n)) throw new Error("Invalid weekday") + return n +} + +/** + * P1-1: 替代 `ctx.roles.includes("admin")` 硬编码。 + * 通过 dataScope.type === "all" 判断是否拥有 admin 级别的数据访问范围。 + */ +export const hasAdminScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "all" + +/** + * P1-1: 替代 `ctx.roles.includes("teacher")` 硬编码。 + * 通过 dataScope.type === "class_taught" 判断是否为教师(有授课班级)。 + */ +export const hasTeacherScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_taught" + +/** + * P1-1: 替代 `ctx.roles.includes("student")` 硬编码。 + * 通过 dataScope.type === "class_members" 判断是否为学生。 + */ +export const hasStudentScope = (ctx: AuthContext): boolean => ctx.dataScope.type === "class_members" + +/** + * 解析表单中的 subjectTeachers JSON 字符串为标准赋值数组。 + * 提取自原 actions.ts,供 admin/grade class 更新逻辑复用。 + */ +export const parseSubjectTeachers = (raw: string | null) => { + if (typeof raw !== "string" || raw.trim().length === 0) return null + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers") + + return parsed.flatMap((item) => { + if (!item || typeof item !== "object") return [] + const subject = (item as { subject?: unknown }).subject + const teacherId = (item as { teacherId?: unknown }).teacherId + + if (typeof subject !== "string" || !isClassSubject(subject)) return [] + + if (teacherId === null || typeof teacherId === "undefined") { + return [{ subject, teacherId: null }] + } + + if (typeof teacherId !== "string") return [] + const trimmed = teacherId.trim() + return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }] + }) +} diff --git a/src/modules/classes/actions-teacher.ts b/src/modules/classes/actions-teacher.ts new file mode 100644 index 0000000..0c5a689 --- /dev/null +++ b/src/modules/classes/actions-teacher.ts @@ -0,0 +1,148 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" + +import type { ActionState } from "@/shared/types/action-state" +import { + createTeacherClass, + deleteTeacherClass, + updateTeacherClass, +} from "./data-access" +import { findGradeIdByHeadAndName, isGradeHead } from "@/modules/school/data-access" +import { + CreateTeacherClassSchema, + UpdateTeacherClassSchema, + DeleteTeacherClassSchema, +} from "./schema" +import { hasAdminScope } from "./actions-shared" + +export async function createTeacherClassAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.CLASS_CREATE) + + const parsed = CreateTeacherClassSchema.safeParse({ + name: formData.get("name"), + grade: formData.get("grade"), + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + gradeId: formData.get("gradeId"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Class name and grade are required" } + } + + const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data + + // P1-1: 使用 dataScope 替代 ctx.roles.includes("admin") 硬编码 + if (!hasAdminScope(ctx)) { + const userId = ctx.userId + + const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : "" + const isOwner = normalizedGradeId + ? await isGradeHead(normalizedGradeId, userId) + : Boolean(await findGradeIdByHeadAndName(userId, grade)) + if (!isOwner) { + return { success: false, message: "Only admins and grade heads can create classes" } + } + } + + try { + const id = await createTeacherClass({ + schoolName: schoolName ?? null, + schoolId: schoolId ?? null, + name, + grade, + gradeId: gradeId ?? null, + homeroom: homeroom ?? null, + room: room ?? null, + }) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class created successfully", data: id } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function updateTeacherClassAction( + classId: string, + prevState: ActionState | null, + formData: FormData +): Promise { + try { + await requirePermission(Permissions.CLASS_UPDATE) + + const parsed = UpdateTeacherClassSchema.safeParse({ + classId, + schoolName: formData.get("schoolName"), + schoolId: formData.get("schoolId"), + name: formData.get("name"), + grade: formData.get("grade"), + gradeId: formData.get("gradeId"), + homeroom: formData.get("homeroom"), + room: formData.get("room"), + }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data + + try { + await updateTeacherClass(validatedClassId, { + schoolName: schoolName ?? undefined, + schoolId: schoolId ?? undefined, + name: name ?? undefined, + grade: grade ?? undefined, + gradeId: gradeId ?? undefined, + homeroom: homeroom ?? undefined, + room: room ?? undefined, + }) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class updated successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} + +export async function deleteTeacherClassAction(classId: string): Promise { + try { + await requirePermission(Permissions.CLASS_DELETE) + + const parsed = DeleteTeacherClassSchema.safeParse({ classId }) + if (!parsed.success) { + return { success: false, message: "Missing class id" } + } + + try { + await deleteTeacherClass(parsed.data.classId) + revalidatePath("/teacher/classes/my") + revalidatePath("/teacher/classes/students") + revalidatePath("/teacher/classes/schedule") + return { success: true, message: "Class deleted successfully" } + } catch (error) { + return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } + } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + throw e + } +} diff --git a/src/modules/classes/actions.ts b/src/modules/classes/actions.ts index 7d74ce5..eb993cd 100644 --- a/src/modules/classes/actions.ts +++ b/src/modules/classes/actions.ts @@ -1,974 +1,49 @@ -"use server"; - -import { revalidatePath } from "next/cache" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" -import { Permissions } from "@/shared/types/permissions" - -import type { ActionState } from "@/shared/types/action-state" -import { - createAdminClass, - createTeacherClass, - deleteAdminClass, - deleteTeacherClass, - enrollStudentByEmail, - enrollStudentByInvitationCode, - enrollTeacherByInvitationCode, - ensureClassInvitationCode, - regenerateClassInvitationCode, - setClassSubjectTeachers, - setStudentEnrollmentStatus, - updateAdminClass, - updateTeacherClass, - getClassGradeId, -} from "./data-access" -import { findGradeIdByHeadAndName, isGradeHead, isGradeManager } from "@/modules/school/data-access" -import { - createClassScheduleItem, - updateClassScheduleItem, - deleteClassScheduleItem, -} from "@/modules/scheduling/data-access-class-schedule" -import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types" -import { - CreateTeacherClassSchema, - UpdateTeacherClassSchema, - DeleteTeacherClassSchema, - CreateAdminClassSchema, - UpdateAdminClassSchema, - DeleteAdminClassSchema, - CreateGradeClassSchema, - UpdateGradeClassSchema, - DeleteGradeClassSchema, - CreateClassScheduleItemSchema, - UpdateClassScheduleItemSchema, - DeleteClassScheduleItemSchema, - EnrollStudentByEmailSchema, -} from "./schema" - -const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS - -const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v) - -const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => - n >= 1 && n <= 7 && Number.isInteger(n) - -const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => { - if (!isWeekday(n)) throw new Error("Invalid weekday") - return n -} - -export async function createTeacherClassAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.CLASS_CREATE) - - const parsed = CreateTeacherClassSchema.safeParse({ - name: formData.get("name"), - grade: formData.get("grade"), - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - gradeId: formData.get("gradeId"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Class name and grade are required" } - } - - const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data - - if (!ctx.roles.includes("admin")) { - const userId = ctx.userId - - const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : "" - const isOwner = normalizedGradeId - ? await isGradeHead(normalizedGradeId, userId) - : Boolean(await findGradeIdByHeadAndName(userId, grade)) - if (!isOwner) { - return { success: false, message: "Only admins and grade heads can create classes" } - } - } - - try { - const id = await createTeacherClass({ - schoolName: schoolName ?? null, - schoolId: schoolId ?? null, - name, - grade, - gradeId: gradeId ?? null, - homeroom: homeroom ?? null, - room: room ?? null, - }) - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class created successfully", data: id } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function updateTeacherClassAction( - classId: string, - prevState: ActionState | null, - formData: FormData -): Promise { - try { - await requirePermission(Permissions.CLASS_UPDATE) - - const parsed = UpdateTeacherClassSchema.safeParse({ - classId, - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - name: formData.get("name"), - grade: formData.get("grade"), - gradeId: formData.get("gradeId"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data - - try { - await updateTeacherClass(validatedClassId, { - schoolName: schoolName ?? undefined, - schoolId: schoolId ?? undefined, - name: name ?? undefined, - grade: grade ?? undefined, - gradeId: gradeId ?? undefined, - homeroom: homeroom ?? undefined, - room: room ?? undefined, - }) - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class updated successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function deleteTeacherClassAction(classId: string): Promise { - try { - await requirePermission(Permissions.CLASS_DELETE) - - const parsed = DeleteTeacherClassSchema.safeParse({ classId }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - try { - await deleteTeacherClass(parsed.data.classId) - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class deleted successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function createGradeClassAction( - prevState: ActionState | undefined, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.CLASS_CREATE) - - const parsed = CreateGradeClassSchema.safeParse({ - name: formData.get("name"), - gradeId: formData.get("gradeId"), - teacherId: formData.get("teacherId"), - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - grade: formData.get("grade"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Class name, grade and teacher are required" } - } - - const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data - - // Verify access - const isManager = await isGradeManager(gradeId, ctx.userId) - if (!isManager) { - return { success: false, message: "You do not have permission to create classes for this grade" } - } - - try { - const id = await createAdminClass({ - schoolName: schoolName ?? null, - schoolId: schoolId ?? null, - name, - grade: grade ?? "", // Should be passed from UI based on selected grade - gradeId, - teacherId, - homeroom: homeroom ?? null, - room: room ?? null, - }) - revalidatePath("/management/grade/classes") - return { success: true, message: "Class created successfully", data: id } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function updateGradeClassAction( - classId: string, - prevState: ActionState | undefined, - formData: FormData -): Promise { - try { - const ctx = await requirePermission(Permissions.CLASS_UPDATE) - - const parsed = UpdateGradeClassSchema.safeParse({ - classId, - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - name: formData.get("name"), - grade: formData.get("grade"), - gradeId: formData.get("gradeId"), - teacherId: formData.get("teacherId"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data - const subjectTeachers = formData.get("subjectTeachers") - - // Verify access: Check if the class belongs to a managed grade - const classGradeId = await getClassGradeId(validatedClassId) - if (!classGradeId) { - return { success: false, message: "Class not found or not linked to a grade" } - } - - const isManager = await isGradeManager(classGradeId, ctx.userId) - if (!isManager) { - return { success: false, message: "You do not have permission to update this class" } - } - - // If changing gradeId, verify target grade too - if (typeof gradeId === "string" && gradeId !== classGradeId) { - const isTargetManager = await isGradeManager(gradeId, ctx.userId) - if (!isTargetManager) { - return { success: false, message: "You do not have permission to move class to this grade" } - } - } - - try { - await updateAdminClass(validatedClassId, { - schoolName: schoolName ?? undefined, - schoolId: schoolId ?? undefined, - name: name ?? undefined, - grade: grade ?? undefined, - gradeId: gradeId ?? undefined, - teacherId: teacherId ?? undefined, - homeroom: homeroom ?? undefined, - room: room ?? undefined, - }) - - if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) { - const parsedTeachers = JSON.parse(subjectTeachers) as unknown - if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers") - - await setClassSubjectTeachers({ - classId: validatedClassId, - assignments: parsedTeachers.flatMap((item) => { - if (!item || typeof item !== "object") return [] - const subject = (item as { subject?: unknown }).subject - const teacherId = (item as { teacherId?: unknown }).teacherId - - if (typeof subject !== "string" || !isClassSubject(subject)) return [] - - if (teacherId === null || typeof teacherId === "undefined") { - return [{ subject, teacherId: null }] - } - - if (typeof teacherId !== "string") return [] - const trimmed = teacherId.trim() - return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }] - }), - }) - } - - revalidatePath("/management/grade/classes") - return { success: true, message: "Class updated successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function deleteGradeClassAction(classId: string): Promise { - try { - const ctx = await requirePermission(Permissions.CLASS_DELETE) - - const parsed = DeleteGradeClassSchema.safeParse({ classId }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - const { classId: validatedClassId } = parsed.data - - // Verify access - const classGradeId = await getClassGradeId(validatedClassId) - if (!classGradeId) { - return { success: false, message: "Class not found or not linked to a grade" } - } - - const isManager = await isGradeManager(classGradeId, ctx.userId) - if (!isManager) { - return { success: false, message: "You do not have permission to delete this class" } - } - - try { - await deleteAdminClass(validatedClassId) - revalidatePath("/management/grade/classes") - return { success: true, message: "Class deleted successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function enrollStudentByEmailAction( - classId: string, - prevState: ActionState | null, - formData: FormData -): Promise { - try { - await requirePermission(Permissions.CLASS_ENROLL) - - const parsed = EnrollStudentByEmailSchema.safeParse({ - classId, - email: formData.get("email"), - }) - if (!parsed.success) { - return { success: false, message: "Please select a class and provide student email" } - } - - try { - await enrollStudentByEmail(parsed.data.classId, parsed.data.email) - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/my") - return { success: true, message: "Student added successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to add student" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function joinClassByInvitationCodeAction( - prevState: ActionState<{ classId: string }> | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.CLASS_ENROLL) - - const code = formData.get("code") - if (typeof code !== "string" || code.trim().length === 0) { - return { success: false, message: "Invitation code is required" } - } - - // v3:rate limit 防爆破(10 次/5 分钟,按 userId) - const { rateLimit, rateLimitKey } = await import("@/shared/lib/rate-limit") - const rlKey = rateLimitKey("class-join", ctx.userId) - const rlResult = rateLimit({ - key: rlKey, - limit: 10, - windowMs: 5 * 60 * 1000, - }) - if (!rlResult.success) { - return { success: false, message: "Too many attempts, please try again later" } - } - - const subjectValue = formData.get("subject") - const subject = ctx.roles.includes("teacher") && typeof subjectValue === "string" ? subjectValue.trim() : null - - if (ctx.roles.includes("teacher") && (!subject || subject.length === 0)) { - return { success: false, message: "Subject is required" } - } - - try { - const classId = - ctx.roles.includes("teacher") - ? await enrollTeacherByInvitationCode(ctx.userId, code, subject) - : await enrollStudentByInvitationCode(ctx.userId, code) - - // 成功后重置 rate limit - const { resetRateLimit } = await import("@/shared/lib/rate-limit") - resetRateLimit(rlKey) - - // 审计日志 - const { logAudit } = await import("@/shared/lib/audit-logger") - await logAudit({ - action: "class.invitation.consume", - module: "classes", - targetId: classId, - targetType: "class", - detail: { - code: String(code).trim().toUpperCase(), - userId: ctx.userId, - role: ctx.roles.includes("teacher") ? "teacher" : "student", - subject, - }, - }) - - if (ctx.roles.includes("student")) { - revalidatePath("/student/learning/courses") - revalidatePath("/student/schedule") - } else { - revalidatePath("/teacher/classes/my") - } - revalidatePath("/profile") - return { success: true, message: "Joined class successfully", data: { classId } } - } catch (error) { - // 审计日志:加入失败 - const { logAudit } = await import("@/shared/lib/audit-logger") - await logAudit({ - action: "class.invitation.consume_failed", - module: "classes", - targetId: String(code).trim().toUpperCase(), - targetType: "invitation_code", - detail: { - userId: ctx.userId, - reason: error instanceof Error ? error.message : "unknown", - }, - status: "failure", - }) - return { success: false, message: error instanceof Error ? error.message : "Failed to join class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function ensureClassInvitationCodeAction(classId: string): Promise> { - try { - await requirePermission(Permissions.CLASS_ENROLL) - - if (typeof classId !== "string" || classId.trim().length === 0) { - return { success: false, message: "Missing class id" } - } - - try { - const code = await ensureClassInvitationCode(classId) - revalidatePath("/teacher/classes/my") - revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) - return { success: true, message: "Invitation code ready", data: { code } } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to generate code" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function regenerateClassInvitationCodeAction(classId: string): Promise> { - try { - await requirePermission(Permissions.CLASS_ENROLL) - - if (typeof classId !== "string" || classId.trim().length === 0) { - return { success: false, message: "Missing class id" } - } - - try { - const code = await regenerateClassInvitationCode(classId) - revalidatePath("/teacher/classes/my") - revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) - return { success: true, message: "Invitation code updated", data: { code } } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to regenerate code" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - /** - * v3 新增:生成自定义邀请码(支持有效期/次数/备注)。 - * 对标 Google Classroom / 钉钉教育:管理员/教师可为班级生成带有效期与次数限制的邀请码。 + * 班级模块 Server Actions 汇总入口(barrel)。 * - * 权限:CLASS_ENROLL(沿用现有权限点,避免过度拆分) - * 审计:调用 logAudit 记录生成操作 + * 历史问题:原文件 974 行,违反单文件行数硬上限(1000 行)。 + * 现按职责拆分为 5 个子文件: + * - actions-teacher.ts 教师班级 CRUD(3 个 Action) + * - actions-admin.ts 管理员班级 CRUD(3 个 Action) + * - actions-grade.ts 年级组长班级 CRUD(3 个 Action) + * - actions-invitations.ts 邀请码与注册相关(8 个 Action) + * - actions-schedule.ts 班级课表 CRUD(3 个 Action) + * - actions-shared.ts 共享工具函数(hasAdminScope / parseSubjectTeachers / toWeekday 等) + * + * 此文件仅做 re-export,保持外部导入路径 `@/modules/classes/actions` 不变, + * 避免影响调用方(class-invitation-manager、student-courses-view 等)。 */ -export async function createClassInvitationCodeAction( - prevState: ActionState<{ code: string; id: string }> | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.CLASS_ENROLL) +export { + createTeacherClassAction, + updateTeacherClassAction, + deleteTeacherClassAction, +} from "./actions-teacher" - const classId = String(formData.get("classId") ?? "").trim() - if (!classId) { - return { success: false, message: "Missing class id" } - } +export { + createAdminClassAction, + updateAdminClassAction, + deleteAdminClassAction, +} from "./actions-admin" - const expiresInHoursRaw = formData.get("expiresInHours") - const maxUsesRaw = formData.get("maxUses") - const note = String(formData.get("note") ?? "").trim() || null +export { + createGradeClassAction, + updateGradeClassAction, + deleteGradeClassAction, +} from "./actions-grade" - const expiresInHours = - expiresInHoursRaw && String(expiresInHoursRaw).trim() !== "" - ? Number(expiresInHoursRaw) - : null - const maxUses = - maxUsesRaw && String(maxUsesRaw).trim() !== "" - ? Number(maxUsesRaw) - : null +export { + enrollStudentByEmailAction, + joinClassByInvitationCodeAction, + ensureClassInvitationCodeAction, + regenerateClassInvitationCodeAction, + createClassInvitationCodeAction, + revokeClassInvitationCodeAction, + listClassInvitationCodesAction, + setStudentEnrollmentStatusAction, +} from "./actions-invitations" - if (expiresInHours !== null && (!Number.isFinite(expiresInHours) || expiresInHours <= 0)) { - return { success: false, message: "Invalid expiresInHours" } - } - if (maxUses !== null && (!Number.isFinite(maxUses) || maxUses <= 0)) { - return { success: false, message: "Invalid maxUses" } - } - - try { - const { createInvitationCode } = await import("./data-access-invitations") - const record = await createInvitationCode(classId, ctx.userId, { - expiresInHours, - maxUses, - note, - }) - - // 审计日志 - const { logAudit } = await import("@/shared/lib/audit-logger") - await logAudit({ - action: "class.invitation.create", - module: "classes", - targetId: classId, - targetType: "class", - detail: { - codeId: record.id, - code: record.code, - expiresInHours, - maxUses, - note, - }, - }) - - revalidatePath("/teacher/classes/my") - revalidatePath(`/teacher/classes/my/${encodeURIComponent(classId)}`) - revalidatePath(`/admin/school/classes`) - return { - success: true, - message: "Invitation code generated", - data: { code: record.code, id: record.id }, - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : "Failed to generate code", - } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -/** - * v3 新增:撤销邀请码(软删除)。 - */ -export async function revokeClassInvitationCodeAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.CLASS_ENROLL) - - const codeId = String(formData.get("codeId") ?? "").trim() - if (!codeId) { - return { success: false, message: "Missing code id" } - } - - try { - const { revokeInvitationCode } = await import("./data-access-invitations") - await revokeInvitationCode(codeId, ctx.userId) - - const { logAudit } = await import("@/shared/lib/audit-logger") - await logAudit({ - action: "class.invitation.revoke", - module: "classes", - targetId: codeId, - targetType: "invitation_code", - detail: { revokedBy: ctx.userId }, - }) - - revalidatePath("/teacher/classes/my") - revalidatePath(`/admin/school/classes`) - return { success: true, message: "Invitation code revoked" } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : "Failed to revoke code", - } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -/** - * v3 新增:列出班级所有邀请码(管理端列表用)。 - */ -export async function listClassInvitationCodesAction( - classId: string -): Promise> }>> { - try { - await requirePermission(Permissions.CLASS_ENROLL) - - if (typeof classId !== "string" || classId.trim().length === 0) { - return { success: false, message: "Missing class id" } - } - - try { - const { listClassInvitationCodes } = await import("./data-access-invitations") - const codes = await listClassInvitationCodes(classId) - return { - success: true, - data: { - codes: codes.map((c) => ({ - id: c.id, - code: c.code, - status: c.status, - maxUses: c.maxUses, - usedCount: c.usedCount, - expiresAt: c.expiresAt?.toISOString() ?? null, - createdAt: c.createdAt.toISOString(), - revokedAt: c.revokedAt?.toISOString() ?? null, - note: c.note, - })), - }, - } - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : "Failed to list codes", - } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function setStudentEnrollmentStatusAction( - classId: string, - studentId: string, - status: "active" | "inactive" -): Promise { - try { - await requirePermission(Permissions.CLASS_ENROLL) - - if (!classId?.trim() || !studentId?.trim()) { - return { success: false, message: "Missing enrollment info" } - } - - try { - await setStudentEnrollmentStatus(classId, studentId, status) - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/my") - return { success: true, message: "Student updated successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to update student" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function createClassScheduleItemAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - await requirePermission(Permissions.CLASS_SCHEDULE) - - const parsed = CreateClassScheduleItemSchema.safeParse({ - classId: formData.get("classId"), - weekday: formData.get("weekday"), - course: formData.get("course"), - startTime: formData.get("startTime"), - endTime: formData.get("endTime"), - location: formData.get("location"), - }) - if (!parsed.success) { - return { success: false, message: "Invalid schedule item data" } - } - - const { classId, weekday, course, startTime, endTime, location } = parsed.data - - try { - const id = await createClassScheduleItem({ - classId, - weekday: toWeekday(weekday), - startTime, - endTime, - course, - location: location ?? null, - }) - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Schedule item created successfully", data: id } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to create schedule item" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function updateClassScheduleItemAction( - scheduleId: string, - prevState: ActionState | null, - formData: FormData -): Promise { - try { - await requirePermission(Permissions.CLASS_SCHEDULE) - - const parsed = UpdateClassScheduleItemSchema.safeParse({ - scheduleId, - classId: formData.get("classId"), - weekday: formData.get("weekday") || undefined, - course: formData.get("course"), - startTime: formData.get("startTime"), - endTime: formData.get("endTime"), - location: formData.get("location"), - }) - if (!parsed.success) { - return { success: false, message: "Missing or invalid schedule id" } - } - - const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data - - try { - await updateClassScheduleItem(validatedScheduleId, { - classId: classId ?? undefined, - weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined, - startTime: startTime ?? undefined, - endTime: endTime ?? undefined, - course: course ?? undefined, - location: location ?? undefined, - }) - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Schedule item updated successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to update schedule item" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function deleteClassScheduleItemAction(scheduleId: string): Promise { - try { - await requirePermission(Permissions.CLASS_SCHEDULE) - - const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId }) - if (!parsed.success) { - return { success: false, message: "Missing schedule id" } - } - - try { - await deleteClassScheduleItem(parsed.data.scheduleId) - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Schedule item deleted successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to delete schedule item" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function createAdminClassAction( - prevState: ActionState | undefined, - formData: FormData -): Promise> { - try { - await requirePermission(Permissions.CLASS_CREATE) - - const parsed = CreateAdminClassSchema.safeParse({ - name: formData.get("name"), - grade: formData.get("grade"), - teacherId: formData.get("teacherId"), - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - gradeId: formData.get("gradeId"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Class name, grade and teacher are required" } - } - - const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data - - try { - const id = await createAdminClass({ - schoolName: schoolName ?? null, - schoolId: schoolId ?? null, - name, - grade, - gradeId: gradeId ?? null, - teacherId, - homeroom: homeroom ?? null, - room: room ?? null, - }) - revalidatePath("/admin/school/classes") - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class created successfully", data: id } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to create class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function updateAdminClassAction( - classId: string, - prevState: ActionState | undefined, - formData: FormData -): Promise { - try { - await requirePermission(Permissions.CLASS_UPDATE) - - const parsed = UpdateAdminClassSchema.safeParse({ - classId, - schoolName: formData.get("schoolName"), - schoolId: formData.get("schoolId"), - name: formData.get("name"), - grade: formData.get("grade"), - gradeId: formData.get("gradeId"), - teacherId: formData.get("teacherId"), - homeroom: formData.get("homeroom"), - room: formData.get("room"), - }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data - const subjectTeachers = formData.get("subjectTeachers") - - try { - await updateAdminClass(validatedClassId, { - schoolName: schoolName ?? undefined, - schoolId: schoolId ?? undefined, - name: name ?? undefined, - grade: grade ?? undefined, - gradeId: gradeId ?? undefined, - teacherId: teacherId ?? undefined, - homeroom: homeroom ?? undefined, - room: room ?? undefined, - }) - - if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) { - const parsedTeachers = JSON.parse(subjectTeachers) as unknown - if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers") - - await setClassSubjectTeachers({ - classId: validatedClassId, - assignments: parsedTeachers.flatMap((item) => { - if (!item || typeof item !== "object") return [] - const subject = (item as { subject?: unknown }).subject - const teacherId = (item as { teacherId?: unknown }).teacherId - - if (typeof subject !== "string" || !isClassSubject(subject)) return [] - - if (teacherId === null || typeof teacherId === "undefined") { - return [{ subject, teacherId: null }] - } - - if (typeof teacherId !== "string") return [] - const trimmed = teacherId.trim() - return [{ subject, teacherId: trimmed.length > 0 ? trimmed : null }] - }), - }) - } - - revalidatePath("/admin/school/classes") - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class updated successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to update class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} - -export async function deleteAdminClassAction(classId: string): Promise { - try { - await requirePermission(Permissions.CLASS_DELETE) - - const parsed = DeleteAdminClassSchema.safeParse({ classId }) - if (!parsed.success) { - return { success: false, message: "Missing class id" } - } - - try { - await deleteAdminClass(parsed.data.classId) - revalidatePath("/admin/school/classes") - revalidatePath("/teacher/classes/my") - revalidatePath("/teacher/classes/students") - revalidatePath("/teacher/classes/schedule") - return { success: true, message: "Class deleted successfully" } - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Failed to delete class" } - } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - throw e - } -} +export { + createClassScheduleItemAction, + updateClassScheduleItemAction, + deleteClassScheduleItemAction, +} from "./actions-schedule" diff --git a/src/modules/classes/types.ts b/src/modules/classes/types.ts index d49534b..625dc88 100644 --- a/src/modules/classes/types.ts +++ b/src/modules/classes/types.ts @@ -1,3 +1,15 @@ +/** + * 班级模块类型定义。 + * + * 说明(P1-4 审计决策): + * 下列 `ClassHomeworkInsights` / `GradeHomeworkInsights` / `ClassHomeworkAssignmentStats` + * / `ScoreStats` / `AssignmentSummary` 等类型虽涉及 homework 概念,但它们是 + * **classes 模块对 homework 数据的视图**(按班级/年级聚合的作业统计),由 + * `data-access-stats.ts` 产出并被 classes 组件消费。homework 模块自身的 + * `types.ts` 定义的是作业实体类型(HomeworkAssignmentStatus 等),不包含这些聚合视图类型。 + * 因此将这些类型保留在 classes 模块,避免让 homework 模块承担 classes 视角的类型定义职责。 + */ + export type TeacherClass = { id: string schoolName?: string | null diff --git a/src/modules/school/components/academic-year-view.tsx b/src/modules/school/components/academic-year-view.tsx index 65c0544..a401a10 100644 --- a/src/modules/school/components/academic-year-view.tsx +++ b/src/modules/school/components/academic-year-view.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react" import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import type { AcademicYearListItem } from "../types" import { createAcademicYearAction, deleteAcademicYearAction, updateAcademicYearAction } from "../actions" @@ -38,6 +39,7 @@ import { formatDate } from "@/shared/lib/utils" const toDateInput = (iso: string) => iso.slice(0, 10) export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) { + const t = useTranslations("school") const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [createOpen, setCreateOpen] = useState(false) @@ -58,10 +60,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) setCreateOpen(false) router.refresh() } else { - toast.error(res.message || "Failed to create academic year") + toast.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error("Failed to create academic year") + toast.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } @@ -78,10 +80,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) setEditItem(null) router.refresh() } else { - toast.error(res.message || "Failed to update academic year") + toast.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error("Failed to update academic year") + toast.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } @@ -97,10 +99,10 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) setDeleteItem(null) router.refresh() } else { - toast.error(res.message || "Failed to delete academic year") + toast.error(res.message || t("academicYear.delete.title")) } } catch { - toast.error("Failed to delete academic year") + toast.error(t("academicYear.delete.title")) } finally { setIsWorking(false) } @@ -117,14 +119,14 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) disabled={isWorking} > - New academic year + {t("academicYear.new")}
- Active year + {t("academicYear.active")} {activeYear ? ( @@ -133,12 +135,12 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
{formatDate(activeYear.startDate)} – {formatDate(activeYear.endDate)}
- Active + {t("academicYear.active")}
) : ( )} @@ -147,7 +149,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) - All years + {t("academicYear.all")} {years.length} @@ -155,17 +157,17 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {years.length === 0 ? ( ) : ( - Name - Range - Status + {t("academicYear.column.name")} + {t("academicYear.column.startDate")} + {t("academicYear.column.status")} @@ -176,7 +178,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) {formatDate(y.startDate)} – {formatDate(y.endDate)} - {y.isActive ? Active : Inactive} + {y.isActive ? {t("academicYear.active")} : -} @@ -192,7 +194,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) }} > - Edit + {t("academicYear.actions.edit")} setDeleteItem(y)} > - Delete + {t("academicYear.actions.delete")} @@ -217,35 +219,35 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) - New academic year + {t("academicYear.form.createTitle")}
- - + +
- +
- +
setCreateActive(Boolean(v))} />
@@ -257,36 +259,36 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) }}> - Edit academic year + {t("academicYear.form.editTitle")} {editItem ? (
- +
- +
- +
setEditActive(Boolean(v))} />
@@ -299,13 +301,13 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] }) }}> - Delete academic year - This will permanently delete {deleteItem?.name || "this academic year"}. + {t("academicYear.delete.title")} + {t("academicYear.delete.description", { name: deleteItem?.name || "" })} - Cancel + {t("academicYear.delete.cancel")} - Delete + {t("academicYear.delete.confirm")} diff --git a/src/modules/school/components/departments-view.tsx b/src/modules/school/components/departments-view.tsx index 31efbf6..351c9c4 100644 --- a/src/modules/school/components/departments-view.tsx +++ b/src/modules/school/components/departments-view.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import type { DepartmentListItem } from "../types" import { createDepartmentAction, deleteDepartmentAction, updateDepartmentAction } from "../actions" @@ -36,6 +37,7 @@ import { import { formatDate } from "@/shared/lib/utils" export function DepartmentsClient({ departments }: { departments: DepartmentListItem[] }) { + const t = useTranslations("school") const router = useRouter() const [isWorking, setIsWorking] = useState(false) const [createOpen, setCreateOpen] = useState(false) @@ -51,10 +53,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList setCreateOpen(false) router.refresh() } else { - toast.error(res.message || "Failed to create department") + toast.error(res.message || t("departments.delete.title")) } } catch { - toast.error("Failed to create department") + toast.error(t("departments.delete.title")) } finally { setIsWorking(false) } @@ -70,10 +72,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList setEditItem(null) router.refresh() } else { - toast.error(res.message || "Failed to update department") + toast.error(res.message || t("departments.delete.title")) } } catch { - toast.error("Failed to update department") + toast.error(t("departments.delete.title")) } finally { setIsWorking(false) } @@ -89,10 +91,10 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList setDeleteItem(null) router.refresh() } else { - toast.error(res.message || "Failed to delete department") + toast.error(res.message || t("departments.delete.title")) } } catch { - toast.error("Failed to delete department") + toast.error(t("departments.delete.title")) } finally { setIsWorking(false) } @@ -103,13 +105,13 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
- All departments + {t("departments.all")} {departments.length} @@ -117,17 +119,17 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList {departments.length === 0 ? ( ) : (
- Name - Description - Updated + {t("departments.column.name")} + {t("departments.column.description")} + {t("departments.column.updated")} @@ -147,7 +149,7 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList setEditItem(d)}> - Edit + {t("departments.actions.edit")} setDeleteItem(d)} > - Delete + {t("departments.actions.delete")} @@ -171,23 +173,23 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList - New department + {t("departments.form.createTitle")}
- - + +
- -