refactor(school,classes): 完成 school/grade/class 审计全量改进项

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
This commit is contained in:
SpecialX
2026-06-22 18:54:01 +08:00
parent 97e59b95a1
commit 15aa84b72c
29 changed files with 2267 additions and 1380 deletions

View File

@@ -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-exportP0-3 修复:从 974 行拆分为 6 个文件 |
| `actions-teacher.ts` | 100 | 教师班级 CRUD3 个 Action |
| `actions-admin.ts` | 120 | 管理员班级 CRUD3 个 Action |
| `actions-grade.ts` | 110 | 年级组长班级 CRUD3 个 Action |
| `actions-invitations.ts` | 280 | 邀请码与注册8 个 Action |
| `actions-schedule.ts` | 90 | 班级课表 CRUD3 个 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-22026-06-22 审计发现):年级 CRUD 逻辑与 `grade-management` 模块重复定义,两套实现并存
- ⚠️ P0-52026-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.tsxclass component Error Boundary + i18n + router.refresh 重试)和 school-skeleton.tsxSchoolListSkeleton 表格骨架 + SchoolCardSkeleton 卡片骨架4 个页面schools/grades/departments/academic-year均已包裹 SchoolErrorBoundaryschool.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 Boundaryclass 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 BoundaryP1-3 修复) |
| components/school-skeleton.tsx | 69 | 共享骨架屏P1-3 修复) |
| hooks/use-school-data.ts | 40 | 学校数据管理 hookP2-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`
---

View File

@@ -5459,6 +5459,63 @@
"updateAdminClass",
"deleteAdminClass"
]
},
{
"path": "actions.ts",
"lines": 50,
"description": "Barrel re-exportP0-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": "教师班级 CRUD3 个 ActionP0-3 修复)"
},
{
"path": "actions-admin.ts",
"lines": 120,
"description": "管理员班级 CRUD3 个 ActionP0-3 修复)"
},
{
"path": "actions-grade.ts",
"lines": 110,
"description": "年级组长班级 CRUD3 个 ActionP0-3 修复)"
},
{
"path": "actions-invitations.ts",
"lines": 280,
"description": "邀请码与注册8 个 ActionP0-3 修复)"
},
{
"path": "actions-schedule.ts",
"lines": 90,
"description": "班级课表 CRUD3 个 ActionP0-3 修复)"
},
{
"path": "actions-shared.ts",
"lines": 60,
"description": "共享工具hasAdminScope/hasTeacherScope/hasStudentScope/parseSubjectTeachers/toWeekdayP1-1 修复)"
}
]
},

View File

@@ -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<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("academicYear.title")} - Next_Edu`,
description: t("academicYear.description"),
}
}
export default async function AdminAcademicYearPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const t = await getTranslations("school")
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("academicYear.title")}</h2>
<p className="text-muted-foreground">{t("academicYear.description")}</p>
</div>
<SchoolErrorBoundary>
<AcademicYearClient years={years} />
</SchoolErrorBoundary>
</div>
)
}

View File

@@ -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<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("classManagement.title")} - Next_Edu`,
description: t("classManagement.description"),
}
}
export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
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<JSX.Element> {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.title")}</h2>
<p className="text-muted-foreground">{t("classManagement.description")}</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
</div>

View File

@@ -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<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("departments.title")} - Next_Edu`,
description: t("departments.description"),
}
}
export default async function AdminDepartmentsPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const t = await getTranslations("school")
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("departments.title")}</h2>
<p className="text-muted-foreground">{t("departments.description")}</p>
</div>
<SchoolErrorBoundary>
<DepartmentsClient departments={departments} />
</SchoolErrorBoundary>
</div>
)
}

View File

@@ -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<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("grades.title")} - Next_Edu`,
description: t("grades.description"),
}
}
export default async function AdminGradesPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const t = await getTranslations("school")
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
<h2 className="text-2xl font-bold tracking-tight">{t("grades.title")}</h2>
<p className="text-muted-foreground">{t("grades.description")}</p>
</div>
<SchoolErrorBoundary>
<GradesClient grades={grades} schools={schools} staff={staff} />
</SchoolErrorBoundary>
</div>
)
}

View File

@@ -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<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("schools.title")}</h2>
<p className="text-muted-foreground">{t("schools.description")}</p>
</div>
<SchoolErrorBoundary>
<SchoolsClient schools={schools} />
</SchoolErrorBoundary>
</div>
)
}

View File

@@ -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<Metadata> {
const t = await getTranslations("school")
return {
title: `${t("classManagement.grade.title")} - Next_Edu`,
description: t("classManagement.grade.description"),
}
}
export default async function GradeClassesPage(): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.GRADE_MANAGE)
const t = await getTranslations("school")
const userId = ctx.userId
const [classes, teachers, managedGrades] = await Promise.all([
@@ -19,10 +32,8 @@ export default async function GradeClassesPage() {
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h2 className="text-2xl font-bold tracking-tight">Class Management</h2>
<p className="text-muted-foreground">
Manage classes for your grades.
</p>
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.grade.title")}</h2>
<p className="text-muted-foreground">{t("classManagement.grade.description")}</p>
</div>
</div>

View File

@@ -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<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState> {
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<ActionState> {
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
}
}

View File

@@ -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<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState> {
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<ActionState> {
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
}
}

View File

@@ -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<ActionState> {
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<ActionState<{ classId: string }>> {
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" }
}
// v3rate 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<ActionState<{ code: string }>> {
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<ActionState<{ code: string }>> {
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<ActionState<{ code: string; id: string }>> {
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> | null,
formData: FormData
): Promise<ActionState<null>> {
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<ActionState<{ codes: Array<Record<string, unknown>> }>> {
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<ActionState> {
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
}
}

View File

@@ -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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState> {
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<ActionState> {
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
}
}

View File

@@ -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 }]
})
}

View File

@@ -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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState> {
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<ActionState> {
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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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}
>
<Plus className="mr-2 h-4 w-4" />
New academic year
{t("academicYear.new")}
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1 shadow-none">
<CardHeader>
<CardTitle className="text-base">Active year</CardTitle>
<CardTitle className="text-base">{t("academicYear.active")}</CardTitle>
</CardHeader>
<CardContent>
{activeYear ? (
@@ -133,12 +135,12 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
<div className="text-sm text-muted-foreground">
{formatDate(activeYear.startDate)} {formatDate(activeYear.endDate)}
</div>
<Badge variant="secondary">Active</Badge>
<Badge variant="secondary">{t("academicYear.active")}</Badge>
</div>
) : (
<EmptyState
title="No active year"
description="Set one academic year as active."
title={t("academicYear.empty.title")}
description={t("academicYear.empty.description")}
className="h-auto border-none shadow-none"
/>
)}
@@ -147,7 +149,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
<Card className="lg:col-span-2 shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All years</CardTitle>
<CardTitle className="text-base">{t("academicYear.all")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{years.length}
</Badge>
@@ -155,17 +157,17 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
<CardContent>
{years.length === 0 ? (
<EmptyState
title="No academic years"
description="Create an academic year to define school calendar."
title={t("academicYear.empty.title")}
description={t("academicYear.empty.description")}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Range</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t("academicYear.column.name")}</TableHead>
<TableHead>{t("academicYear.column.startDate")}</TableHead>
<TableHead>{t("academicYear.column.status")}</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
@@ -176,7 +178,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
<TableCell className="text-muted-foreground">
{formatDate(y.startDate)} {formatDate(y.endDate)}
</TableCell>
<TableCell>{y.isActive ? <Badge variant="secondary">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<TableCell>{y.isActive ? <Badge variant="secondary">{t("academicYear.active")}</Badge> : <Badge variant="outline">-</Badge>}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -192,7 +194,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
}}
>
<Pencil className="mr-2 h-4 w-4" />
Edit
{t("academicYear.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -200,7 +202,7 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
onClick={() => setDeleteItem(y)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t("academicYear.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -217,35 +219,35 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New academic year</DialogTitle>
<DialogTitle>{t("academicYear.form.createTitle")}</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. 2025-2026" autoFocus />
<Label htmlFor="name">{t("academicYear.form.name")}</Label>
<Input id="name" name="name" placeholder={t("academicYear.form.namePlaceholder")} autoFocus />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="startDate">Start date</Label>
<Label htmlFor="startDate">{t("academicYear.form.startDate")}</Label>
<Input id="startDate" name="startDate" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="endDate">End date</Label>
<Label htmlFor="endDate">{t("academicYear.form.endDate")}</Label>
<Input id="endDate" name="endDate" type="date" />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={createActive} onCheckedChange={(v) => setCreateActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setCreateActive((v) => !v)}>
Set as active
{t("academicYear.form.isActive")}
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
{t("academicYear.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
Create
{t("academicYear.form.create")}
</Button>
</DialogFooter>
</form>
@@ -257,36 +259,36 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit academic year</DialogTitle>
<DialogTitle>{t("academicYear.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Label htmlFor="edit-name">{t("academicYear.form.name")}</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-startDate">Start date</Label>
<Label htmlFor="edit-startDate">{t("academicYear.form.startDate")}</Label>
<Input id="edit-startDate" name="startDate" type="date" defaultValue={toDateInput(editItem.startDate)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-endDate">End date</Label>
<Label htmlFor="edit-endDate">{t("academicYear.form.endDate")}</Label>
<Input id="edit-endDate" name="endDate" type="date" defaultValue={toDateInput(editItem.endDate)} />
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox checked={editActive} onCheckedChange={(v) => setEditActive(Boolean(v))} />
<Label className="cursor-pointer" onClick={() => setEditActive((v) => !v)}>
Set as active
{t("academicYear.form.isActive")}
</Label>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
{t("academicYear.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
Save
{t("academicYear.form.save")}
</Button>
</DialogFooter>
</form>
@@ -299,13 +301,13 @@ export function AcademicYearClient({ years }: { years: AcademicYearListItem[] })
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete academic year</AlertDialogTitle>
<AlertDialogDescription>This will permanently delete {deleteItem?.name || "this academic year"}.</AlertDialogDescription>
<AlertDialogTitle>{t("academicYear.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>{t("academicYear.delete.description", { name: deleteItem?.name || "" })}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isWorking}>{t("academicYear.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
{t("academicYear.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -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
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
New department
{t("departments.new")}
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">All departments</CardTitle>
<CardTitle className="text-base">{t("departments.all")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{departments.length}
</Badge>
@@ -117,17 +119,17 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
<CardContent>
{departments.length === 0 ? (
<EmptyState
title="No departments"
description="Create your first department to get started."
title={t("departments.empty.title")}
description={t("departments.empty.description")}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Updated</TableHead>
<TableHead>{t("departments.column.name")}</TableHead>
<TableHead>{t("departments.column.description")}</TableHead>
<TableHead>{t("departments.column.updated")}</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
@@ -147,7 +149,7 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditItem(d)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
{t("departments.actions.edit")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
@@ -155,7 +157,7 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
onClick={() => setDeleteItem(d)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t("departments.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -171,23 +173,23 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New department</DialogTitle>
<DialogTitle>{t("departments.form.createTitle")}</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" name="name" placeholder="e.g. Mathematics" autoFocus />
<Label htmlFor="name">{t("departments.form.name")}</Label>
<Input id="name" name="name" placeholder={t("departments.form.namePlaceholder")} autoFocus />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" name="description" placeholder="Optional" />
<Label htmlFor="description">{t("departments.form.description")}</Label>
<Textarea id="description" name="description" placeholder={t("departments.form.descriptionPlaceholder")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
{t("departments.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
Create
{t("departments.form.create")}
</Button>
</DialogFooter>
</form>
@@ -199,24 +201,24 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit department</DialogTitle>
<DialogTitle>{t("departments.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name</Label>
<Label htmlFor="edit-name">{t("departments.form.name")}</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Label htmlFor="edit-description">{t("departments.form.description")}</Label>
<Textarea id="edit-description" name="description" defaultValue={editItem.description || ""} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
{t("departments.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
Save
{t("departments.form.save")}
</Button>
</DialogFooter>
</form>
@@ -229,15 +231,15 @@ export function DepartmentsClient({ departments }: { departments: DepartmentList
}}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete department</AlertDialogTitle>
<AlertDialogTitle>{t("departments.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete {deleteItem?.name || "this department"}.
{t("departments.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isWorking}>{t("departments.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
Delete
{t("departments.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,10 +1,11 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { parseAsString, useQueryState } from "nuqs"
import { useTranslations } from "next-intl"
import type { GradeListItem, SchoolListItem, StaffOption } from "../types"
import { createGradeAction, deleteGradeAction, updateGradeAction } from "../actions"
@@ -66,43 +67,6 @@ const parseOrder = (raw: string) => {
return n
}
const validateForm = (
state: FormState,
params: { grades: GradeListItem[]; excludeGradeId?: string }
): { ok: boolean; errors: FormErrors } => {
const errors: FormErrors = {}
const schoolId = state.schoolId.trim()
if (!schoolId) errors.schoolId = "请选择学校"
const name = normalizeName(state.name)
if (!name) errors.name = "请输入年级名称"
if (name.length > 100) errors.name = "年级名称最多 100 个字符"
const order = parseOrder(state.order)
if (order === null) errors.order = "Order 必须是非负整数"
if (schoolId && name) {
const dup = params.grades.find((g) => {
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
})
if (dup) errors.name = "该学校下已存在同名年级"
}
return { ok: Object.keys(errors).length === 0, errors }
}
const formatStaffDetail = (u: StaffOption | null) => {
if (!u) return <Badge variant="outline"></Badge>
return (
<div className="min-w-0">
<div className="truncate">{u.name}</div>
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
</div>
)
}
export function GradesClient({
grades,
schools,
@@ -112,6 +76,7 @@ export function GradesClient({
schools: SchoolListItem[]
staff: StaffOption[]
}) {
const t = useTranslations("school")
const router = useRouter()
const [isWorking, setIsWorking] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
@@ -149,6 +114,46 @@ export function GradesClient({
})
}, [staff])
const validateForm = useCallback(
(state: FormState, params: { grades: GradeListItem[]; excludeGradeId?: string }): {
ok: boolean
errors: FormErrors
} => {
const errors: FormErrors = {}
const schoolId = state.schoolId.trim()
if (!schoolId) errors.schoolId = t("grades.validation.selectSchool")
const name = normalizeName(state.name)
if (!name) errors.name = t("grades.validation.enterName")
if (name.length > 100) errors.name = t("grades.validation.nameTooLong")
const order = parseOrder(state.order)
if (order === null) errors.order = t("grades.validation.orderInvalid")
if (schoolId && name) {
const dup = params.grades.find((g) => {
if (params.excludeGradeId && g.id === params.excludeGradeId) return false
return g.school.id === schoolId && normalizeName(g.name).toLowerCase() === name.toLowerCase()
})
if (dup) errors.name = t("grades.validation.duplicateName")
}
return { ok: Object.keys(errors).length === 0, errors }
},
[t]
)
const formatStaffDetail = (u: StaffOption | null) => {
if (!u) return <Badge variant="outline">{t("grades.notSet")}</Badge>
return (
<div className="min-w-0">
<div className="truncate">{u.name}</div>
<div className="truncate text-xs text-muted-foreground">{u.email}</div>
</div>
)
}
const filteredGrades = useMemo(() => {
const needle = q.trim().toLowerCase()
const bySchool = school === "all" ? "" : school
@@ -202,10 +207,13 @@ export function GradesClient({
setCreateOpen(true)
}
const createValidation = useMemo(() => validateForm(createState, { grades }), [createState, grades])
const createValidation = useMemo(
() => validateForm(createState, { grades }),
[createState, grades, validateForm]
)
const editValidation = useMemo(
() => validateForm(editState, { grades, excludeGradeId: editItem?.id }),
[editItem?.id, editState, grades]
[editItem?.id, editState, grades, validateForm]
)
const isEditDirty = useMemo(() => {
@@ -236,7 +244,7 @@ export function GradesClient({
const handleCreate = async () => {
const validation = validateForm(createState, { grades })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
return
}
@@ -255,10 +263,10 @@ export function GradesClient({
setCreateOpen(false)
router.refresh()
} else {
toast.error(res.message || "Failed to create grade")
toast.error(res.message || t("grades.failedCreate"))
}
} catch {
toast.error("Failed to create grade")
toast.error(t("grades.failedCreate"))
} finally {
setIsWorking(false)
}
@@ -268,11 +276,11 @@ export function GradesClient({
if (!editItem) return
const validation = validateForm(editState, { grades, excludeGradeId: editItem.id })
if (!validation.ok) {
toast.error(Object.values(validation.errors)[0] || "请完善表单信息")
toast.error(Object.values(validation.errors)[0] || t("grades.validation.fixForm"))
return
}
if (!isEditDirty) {
toast.message("没有可保存的变更")
toast.message(t("grades.validation.noChanges"))
return
}
@@ -291,10 +299,10 @@ export function GradesClient({
setEditItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to update grade")
toast.error(res.message || t("grades.failedUpdate"))
}
} catch {
toast.error("Failed to update grade")
toast.error(t("grades.failedUpdate"))
} finally {
setIsWorking(false)
}
@@ -310,10 +318,10 @@ export function GradesClient({
setDeleteItem(null)
router.refresh()
} else {
toast.error(res.message || "Failed to delete grade")
toast.error(res.message || t("grades.failedDelete"))
}
} catch {
toast.error("Failed to delete grade")
toast.error(t("grades.failedDelete"))
} finally {
setIsWorking(false)
}
@@ -324,15 +332,15 @@ export function GradesClient({
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1 md:max-w-sm">
<Input placeholder="搜索年级/学校/组长..." value={q} onChange={(e) => setQ(e.target.value || null)} />
<Input placeholder={t("grades.filters.search")} value={q} onChange={(e) => setQ(e.target.value || null)} />
</div>
<Select value={school} onValueChange={(v) => setSchool(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="学校" />
<SelectValue placeholder={t("grades.filters.school")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="all">{t("grades.filters.allSchools")}</SelectItem>
{schools.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
@@ -343,28 +351,28 @@ export function GradesClient({
<Select value={head} onValueChange={(v) => setHead(v === "all" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="负责人" />
<SelectValue placeholder={t("grades.filters.head")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="missing"></SelectItem>
<SelectItem value="missing_grade_head"></SelectItem>
<SelectItem value="missing_teaching_head"></SelectItem>
<SelectItem value="all">{t("grades.filters.allHeads")}</SelectItem>
<SelectItem value="missing">{t("grades.filters.missing")}</SelectItem>
<SelectItem value="missing_grade_head">{t("grades.filters.missingGradeHead")}</SelectItem>
<SelectItem value="missing_teaching_head">{t("grades.filters.missingTeachingHead")}</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v === "default" ? null : v)}>
<SelectTrigger className="w-full md:w-[220px]">
<SelectValue placeholder="排序" />
<SelectValue placeholder={t("grades.filters.sort")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="updated_desc"></SelectItem>
<SelectItem value="updated_asc"></SelectItem>
<SelectItem value="name_asc">AZ</SelectItem>
<SelectItem value="name_desc">ZA</SelectItem>
<SelectItem value="order_asc">Order</SelectItem>
<SelectItem value="order_desc">Order</SelectItem>
<SelectItem value="default">{t("grades.filters.defaultSort")}</SelectItem>
<SelectItem value="updated_desc">{t("grades.filters.updatedDesc")}</SelectItem>
<SelectItem value="updated_asc">{t("grades.filters.updatedAsc")}</SelectItem>
<SelectItem value="name_asc">{t("grades.filters.nameAsc")}</SelectItem>
<SelectItem value="name_desc">{t("grades.filters.nameDesc")}</SelectItem>
<SelectItem value="order_asc">{t("grades.filters.orderAsc")}</SelectItem>
<SelectItem value="order_desc">{t("grades.filters.orderDesc")}</SelectItem>
</SelectContent>
</Select>
@@ -378,20 +386,20 @@ export function GradesClient({
setSort(null)
}}
>
{t("grades.filters.reset")}
</Button>
) : null}
</div>
<Button onClick={openCreate} disabled={isWorking || schools.length === 0}>
<Plus className="mr-2 h-4 w-4" />
{t("grades.new")}
</Button>
</div>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base"></CardTitle>
<CardTitle className="text-base">{t("grades.list.title")}</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="tabular-nums">
{filteredGrades.length}
@@ -404,26 +412,30 @@ export function GradesClient({
<CardContent>
{schools.length === 0 ? (
<EmptyState
title="暂无学校"
description="请先创建学校,再在学校下创建年级。"
title={t("grades.list.noSchools")}
description={t("grades.list.noSchoolsDescription")}
className="h-auto border-none shadow-none"
/>
) : filteredGrades.length === 0 ? (
<EmptyState
title={grades.length === 0 ? "暂无年级" : "没有匹配结果"}
description={grades.length === 0 ? "创建年级以便管理负责人和班级。" : "尝试修改筛选条件或清空搜索。"}
title={grades.length === 0 ? t("grades.list.noGrades") : t("grades.list.noMatch")}
description={
grades.length === 0
? t("grades.list.noGradesDescription")
: t("grades.list.noMatchDescription")
}
className="h-auto border-none shadow-none"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Order</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>{t("grades.column.school")}</TableHead>
<TableHead>{t("grades.column.grade")}</TableHead>
<TableHead>{t("grades.column.order")}</TableHead>
<TableHead>{t("grades.column.gradeHead")}</TableHead>
<TableHead>{t("grades.column.teachingHead")}</TableHead>
<TableHead>{t("grades.column.updated")}</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
@@ -449,19 +461,19 @@ export function GradesClient({
router.push(`/admin/school/grades/insights?gradeId=${encodeURIComponent(g.id)}`)
}
>
{t("grades.actions.insights")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => openEdit(g)}>
<Pencil className="mr-2 h-4 w-4" />
{t("grades.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setDeleteItem(g)}
>
<Trash2 className="mr-2 h-4 w-4" />
{t("grades.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -477,7 +489,7 @@ export function GradesClient({
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("grades.form.createTitle")}</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
@@ -487,14 +499,14 @@ export function GradesClient({
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">School</Label>
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={createState.schoolId}
onValueChange={(v) => setCreateState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a school" />
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
@@ -514,14 +526,14 @@ export function GradesClient({
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-name" className="text-right">
Grade
{t("grades.form.name")}
</Label>
<Input
id="create-grade-name"
className="col-span-3"
value={createState.name}
onChange={(e) => setCreateState((p) => ({ ...p, name: e.target.value }))}
placeholder="e.g. Grade 10"
placeholder={t("grades.form.name")}
autoFocus
/>
{createValidation.errors.name ? (
@@ -533,7 +545,7 @@ export function GradesClient({
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="create-grade-order" className="text-right">
Order
{t("grades.form.order")}
</Label>
<Input
id="create-grade-order"
@@ -553,7 +565,7 @@ export function GradesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={createState.gradeHeadId}
@@ -562,7 +574,7 @@ export function GradesClient({
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
@@ -577,7 +589,7 @@ export function GradesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={createState.teachingHeadId}
@@ -586,7 +598,7 @@ export function GradesClient({
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
@@ -602,10 +614,10 @@ export function GradesClient({
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
Cancel
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("grades.form.create")}
</Button>
</DialogFooter>
</form>
@@ -620,7 +632,7 @@ export function GradesClient({
>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t("grades.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form
@@ -631,14 +643,14 @@ export function GradesClient({
}}
>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">School</Label>
<Label className="text-right">{t("grades.form.school")}</Label>
<div className="col-span-3">
<Select
value={editState.schoolId}
onValueChange={(v) => setEditState((p) => ({ ...p, schoolId: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select a school" />
<SelectValue placeholder={t("grades.form.school")} />
</SelectTrigger>
<SelectContent>
{schools.map((s) => (
@@ -658,7 +670,7 @@ export function GradesClient({
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-name" className="text-right">
Grade
{t("grades.form.name")}
</Label>
<Input
id="edit-grade-name"
@@ -675,7 +687,7 @@ export function GradesClient({
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-grade-order" className="text-right">
Order
{t("grades.form.order")}
</Label>
<Input
id="edit-grade-order"
@@ -695,7 +707,7 @@ export function GradesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Label className="text-right">{t("grades.form.gradeHead")}</Label>
<div className="col-span-3">
<Select
value={editState.gradeHeadId}
@@ -704,7 +716,7 @@ export function GradesClient({
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
@@ -719,7 +731,7 @@ export function GradesClient({
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right"></Label>
<Label className="text-right">{t("grades.form.teachingHead")}</Label>
<div className="col-span-3">
<Select
value={editState.teachingHeadId}
@@ -728,7 +740,7 @@ export function GradesClient({
}
>
<SelectTrigger>
<SelectValue placeholder="Optional" />
<SelectValue placeholder={t("grades.optional")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_SELECT_VALUE}>-</SelectItem>
@@ -744,10 +756,10 @@ export function GradesClient({
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
Cancel
{t("grades.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("grades.form.save")}
</Button>
</DialogFooter>
</form>
@@ -763,13 +775,15 @@ export function GradesClient({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription> {deleteItem?.name || "该年级"}</AlertDialogDescription>
<AlertDialogTitle>{t("grades.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("grades.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}></AlertDialogCancel>
<AlertDialogCancel disabled={isWorking}>{t("grades.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("grades.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,71 @@
"use client"
import { useTranslations } from "next-intl"
import type { SchoolListItem } from "../types"
import { deleteSchoolAction } from "../actions"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
type SchoolDeleteDialogProps = {
deleteItem: SchoolListItem | null
onOpenChange: (open: boolean) => void
onSuccess: () => void
}
/**
* 学校删除确认对话框。
*
* 内部管理 deleteMutation对话框的 open 状态由 `deleteItem` 是否为空推导。
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
*/
export function SchoolDeleteDialog({
deleteItem,
onOpenChange,
onSuccess,
}: SchoolDeleteDialogProps) {
const t = useTranslations("school")
const deleteMutation = useActionMutation({
errorMessage: "Failed to delete school",
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const isWorking = deleteMutation.isWorking
const handleDelete = (): void => {
if (!deleteItem) return
void deleteMutation.mutate(() => deleteSchoolAction(deleteItem.id))
}
return (
<AlertDialog open={Boolean(deleteItem)} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("schools.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("schools.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("schools.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("schools.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { Component, type ErrorInfo, type JSX, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
interface SchoolErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
interface SchoolErrorBoundaryState {
hasError: boolean
}
function SchoolErrorFallback({ onReset }: { onReset: () => void }): JSX.Element {
const t = useTranslations("school")
const router = useRouter()
const handleRetry = (): void => {
onReset()
router.refresh()
}
return (
<div
role="alert"
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
{t("errors.boundary.description")}
</p>
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
</div>
)
}
export class SchoolErrorBoundary extends Component<
SchoolErrorBoundaryProps,
SchoolErrorBoundaryState
> {
constructor(props: SchoolErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): SchoolErrorBoundaryState {
return { hasError: true }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("SchoolErrorBoundary caught an error:", error, errorInfo)
}
private handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? <SchoolErrorFallback onReset={this.handleReset} />
}
return this.props.children
}
}

View File

@@ -0,0 +1,112 @@
"use client"
import { useTranslations } from "next-intl"
import type { SchoolListItem } from "../types"
import { createSchoolAction, updateSchoolAction } from "../actions"
import { Button } from "@/shared/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
type SchoolFormDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
editItem?: SchoolListItem | null
onSuccess: () => void
}
/**
* 学校创建/编辑表单对话框。
*
* 内部管理 createMutation 与 updateMutation根据 `editItem` 是否存在自动切换模式。
* 成功后调用 `onOpenChange(false)` 关闭对话框并触发 `onSuccess` 通知父组件刷新。
*/
export function SchoolFormDialog({
open,
onOpenChange,
editItem,
onSuccess,
}: SchoolFormDialogProps) {
const t = useTranslations("school")
const isEdit = Boolean(editItem)
const createMutation = useActionMutation({
errorMessage: "Failed to create school",
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const updateMutation = useActionMutation({
errorMessage: "Failed to update school",
onSuccess: () => {
onOpenChange(false)
onSuccess()
},
})
const isWorking = createMutation.isWorking || updateMutation.isWorking
const handleCreate = (formData: FormData): void => {
void createMutation.mutate(() => createSchoolAction(undefined, formData))
}
const handleUpdate = (formData: FormData): void => {
if (!editItem) return
void updateMutation.mutate(() => updateSchoolAction(editItem.id, undefined, formData))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEdit ? t("schools.form.editTitle") : t("schools.form.createTitle")}
</DialogTitle>
</DialogHeader>
{isEdit ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">{t("schools.form.name")}</Label>
<Input id="edit-name" name="name" defaultValue={editItem?.name} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-code">{t("schools.form.code")}</Label>
<Input id="edit-code" name="code" defaultValue={editItem?.code || ""} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
{t("schools.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("schools.form.save")}
</Button>
</DialogFooter>
</form>
) : (
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("schools.form.name")}</Label>
<Input id="name" name="name" placeholder={t("schools.form.namePlaceholder")} autoFocus />
</div>
<div className="space-y-2">
<Label htmlFor="code">{t("schools.form.code")}</Label>
<Input id="code" name="code" placeholder={t("schools.form.codePlaceholder")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isWorking}>
{t("schools.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("schools.form.create")}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import { Plus } from "lucide-react"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
type SchoolListToolbarProps = {
count: number
onCreate: () => void
isWorking: boolean
}
/**
* 学校列表工具栏。
*
* 展示学校数量 Badge 与「新建学校」按钮,按钮在任意对话框打开期间禁用。
*/
export function SchoolListToolbar({
count,
onCreate,
isWorking,
}: SchoolListToolbarProps) {
const t = useTranslations("school")
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-base font-medium">{t("schools.all")}</span>
<Badge variant="secondary" className="tabular-nums">
{count}
</Badge>
</div>
<Button onClick={onCreate} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
{t("schools.new")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
interface SchoolListSkeletonProps {
rows?: number
}
export function SchoolListSkeleton({ rows = 5 }: SchoolListSkeletonProps): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-10 rounded-full" />
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-16" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-24" />
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export function SchoolCardSkeleton(): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
)
}

View File

@@ -1,20 +1,18 @@
"use client"
import { useState } from "react"
import { MoreHorizontal, Pencil, Plus, Trash2 } from "lucide-react"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import type { SchoolListItem } from "../types"
import { createSchoolAction, deleteSchoolAction, updateSchoolAction } from "../actions"
import { useSchoolData } from "../hooks/use-school-data"
import { SchoolDeleteDialog } from "./school-delete-dialog"
import { SchoolFormDialog } from "./school-form-dialog"
import { SchoolListToolbar } from "./school-list-toolbar"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Badge } from "@/shared/components/ui/badge"
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,82 +20,47 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { formatDate } from "@/shared/lib/utils"
import { useActionMutation } from "@/shared/hooks/use-action-mutation"
export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
const t = useTranslations("school")
const router = useRouter()
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
const {
createOpen,
editItem,
deleteItem,
setCreateOpen,
setEditItem,
setDeleteItem,
isWorking,
} = useSchoolData()
const createMutation = useActionMutation({
errorMessage: "Failed to create school",
onSuccess: () => {
const handleSuccess = (): void => {
router.refresh()
}
const handleFormOpenChange = (open: boolean): void => {
if (!open) {
setCreateOpen(false)
router.refresh()
},
})
const updateMutation = useActionMutation({
errorMessage: "Failed to update school",
onSuccess: () => {
setEditItem(null)
router.refresh()
},
})
}
}
const deleteMutation = useActionMutation({
errorMessage: "Failed to delete school",
onSuccess: () => {
const handleDeleteOpenChange = (open: boolean): void => {
if (!open) {
setDeleteItem(null)
router.refresh()
},
})
const isWorking = createMutation.isWorking || updateMutation.isWorking || deleteMutation.isWorking
const handleCreate = (formData: FormData) => {
void createMutation.mutate(() => createSchoolAction(undefined, formData))
}
const handleUpdate = (formData: FormData) => {
if (!editItem) return
void updateMutation.mutate(() => updateSchoolAction(editItem.id, undefined, formData))
}
const handleDelete = () => {
if (!deleteItem) return
void deleteMutation.mutate(() => deleteSchoolAction(deleteItem.id))
}
return (
<>
<div className="flex justify-end">
<Button onClick={() => setCreateOpen(true)} disabled={isWorking}>
<Plus className="mr-2 h-4 w-4" />
{t("schools.new")}
</Button>
</div>
<SchoolListToolbar
count={schools.length}
onCreate={() => setCreateOpen(true)}
isWorking={isWorking}
/>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("schools.all")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{schools.length}
</Badge>
</CardHeader>
<CardContent>
{schools.length === 0 ? (
<EmptyState
@@ -152,87 +115,18 @@ export function SchoolsClient({ schools }: { schools: SchoolListItem[] }) {
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("schools.form.createTitle")}</DialogTitle>
</DialogHeader>
<form action={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">{t("schools.form.name")}</Label>
<Input id="name" name="name" placeholder={t("schools.form.namePlaceholder")} autoFocus />
</div>
<div className="space-y-2">
<Label htmlFor="code">{t("schools.form.code")}</Label>
<Input id="code" name="code" placeholder={t("schools.form.codePlaceholder")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)} disabled={isWorking}>
{t("schools.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("schools.form.create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<SchoolFormDialog
open={createOpen || Boolean(editItem)}
onOpenChange={handleFormOpenChange}
editItem={editItem}
onSuccess={handleSuccess}
/>
<Dialog
open={Boolean(editItem)}
onOpenChange={(open) => {
if (!open) setEditItem(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("schools.form.editTitle")}</DialogTitle>
</DialogHeader>
{editItem ? (
<form action={handleUpdate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">{t("schools.form.name")}</Label>
<Input id="edit-name" name="name" defaultValue={editItem.name} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-code">{t("schools.form.code")}</Label>
<Input id="edit-code" name="code" defaultValue={editItem.code || ""} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditItem(null)} disabled={isWorking}>
{t("schools.form.cancel")}
</Button>
<Button type="submit" disabled={isWorking}>
{t("schools.form.save")}
</Button>
</DialogFooter>
</form>
) : null}
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteItem)}
onOpenChange={(open) => {
if (!open) setDeleteItem(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("schools.delete.title")}</AlertDialogTitle>
<AlertDialogDescription>
{t("schools.delete.description", { name: deleteItem?.name || "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isWorking}>{t("schools.delete.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} disabled={isWorking}>
{t("schools.delete.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SchoolDeleteDialog
deleteItem={deleteItem}
onOpenChange={handleDeleteOpenChange}
onSuccess={handleSuccess}
/>
</>
)
}

View File

@@ -205,6 +205,172 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
}
})
/**
* 根据用户角色返回可见的学校列表(权限感知)。
* - admin: 返回全量学校
* - grade_head / teaching_head: 返回其负责年级所在学校
* - teacher: 返回其任课班级所在学校
* - 其他角色: 返回空数组
*/
export const getSchoolsForUser = cache(async (userId: string): Promise<SchoolListItem[]> => {
const id = userId.trim()
if (!id) return []
try {
const roleRows = await db
.select({ name: roles.name })
.from(roles)
.innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, id))
const roleNames = new Set(roleRows.map((r) => r.name))
if (roleNames.has("admin")) {
return await getSchools()
}
let schoolIds: string[] = []
if (roleNames.has("grade_head") || roleNames.has("teaching_head")) {
const gradeRows = await db
.select({ schoolId: grades.schoolId })
.from(grades)
.where(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id)))
schoolIds = gradeRows.map((r) => r.schoolId)
} else if (roleNames.has("teacher")) {
const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access")
const classIds = await getAccessibleClassIdsForTeacher(id)
if (classIds.length === 0) return []
const gradeIds = await getGradeIdsByClassIds(classIds)
if (gradeIds.length === 0) return []
const gradeRows = await db
.select({ schoolId: grades.schoolId })
.from(grades)
.where(inArray(grades.id, gradeIds))
schoolIds = gradeRows.map((r) => r.schoolId)
} else {
return []
}
const uniqueSchoolIds = Array.from(
new Set(schoolIds.filter((v): v is string => typeof v === "string" && v.length > 0))
)
if (uniqueSchoolIds.length === 0) return []
const rows = await db
.select()
.from(schools)
.where(inArray(schools.id, uniqueSchoolIds))
.orderBy(asc(schools.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
code: r.code ?? null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch (error) {
console.error("getSchoolsForUser failed:", error)
return []
}
})
/**
* 根据用户角色返回可见的年级列表(权限感知)。
* - admin: 返回全量年级
* - grade_head / teaching_head: 返回其负责的年级
* - teacher: 返回其任课班级所在年级
* - 其他角色: 返回空数组
*/
export const getGradesForUser = cache(async (userId: string): Promise<GradeListItem[]> => {
const id = userId.trim()
if (!id) return []
try {
const roleRows = await db
.select({ name: roles.name })
.from(roles)
.innerJoin(usersToRoles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, id))
const roleNames = new Set(roleRows.map((r) => r.name))
if (roleNames.has("admin")) {
return await getGrades()
}
if (roleNames.has("grade_head") || roleNames.has("teaching_head")) {
return await getGradesForStaff(id)
}
if (roleNames.has("teacher")) {
const { getAccessibleClassIdsForTeacher, getGradeIdsByClassIds } = await import("@/modules/classes/data-access")
const classIds = await getAccessibleClassIdsForTeacher(id)
if (classIds.length === 0) return []
const gradeIds = await getGradeIdsByClassIds(classIds)
if (gradeIds.length === 0) return []
const uniqueGradeIds = Array.from(new Set(gradeIds))
if (uniqueGradeIds.length === 0) return []
const rows = await db
.select({
id: grades.id,
name: grades.name,
order: grades.order,
schoolId: schools.id,
schoolName: schools.name,
gradeHeadId: grades.gradeHeadId,
teachingHeadId: grades.teachingHeadId,
createdAt: grades.createdAt,
updatedAt: grades.updatedAt,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(inArray(grades.id, uniqueGradeIds))
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
const headIds = Array.from(
new Set(
rows
.flatMap((r) => [r.gradeHeadId, r.teachingHeadId])
.filter((v): v is string => typeof v === "string" && v.length > 0)
)
)
const heads = headIds.length
? await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(inArray(users.id, headIds))
: []
const headById = new Map<string, StaffOption>()
for (const u of heads) headById.set(u.id, { id: u.id, name: u.name ?? "Unnamed", email: u.email })
return rows.map((r) => ({
id: r.id,
school: { id: r.schoolId, name: r.schoolName },
name: r.name,
order: Number(r.order ?? 0),
gradeHead: r.gradeHeadId ? headById.get(r.gradeHeadId) ?? null : null,
teachingHead: r.teachingHeadId ? headById.get(r.teachingHeadId) ?? null : null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
}
return []
} catch (error) {
console.error("getGradesForUser failed:", error)
return []
}
})
// ---------------------------------------------------------------------------
// Mutations — DB write operations (called only from actions.ts)
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,42 @@
"use client"
import { useState } from "react"
import type { SchoolListItem } from "../types"
export type UseSchoolDataReturn = {
createOpen: boolean
editItem: SchoolListItem | null
deleteItem: SchoolListItem | null
setCreateOpen: (open: boolean) => void
setEditItem: (item: SchoolListItem | null) => void
setDeleteItem: (item: SchoolListItem | null) => void
isWorking: boolean
}
/**
* 学校管理客户端的数据/状态 Hook。
*
* 集中管理创建/编辑/删除对话框的开关状态以及当前操作的学校项,
* 供 SchoolsClient 组合容器及其子组件共享。
*
* `isWorking` 表示任意对话框处于打开状态,用于禁用工具栏按钮与行内操作菜单,
* 避免并发打开多个对话框;各对话框内部的 mutation loading 由对应组件自行管理。
*/
export function useSchoolData(): UseSchoolDataReturn {
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<SchoolListItem | null>(null)
const [deleteItem, setDeleteItem] = useState<SchoolListItem | null>(null)
const isWorking = createOpen || Boolean(editItem) || Boolean(deleteItem)
return {
createOpen,
editItem,
deleteItem,
setCreateOpen,
setEditItem,
setDeleteItem,
isWorking,
}
}

View File

@@ -78,9 +78,51 @@
},
"actions": {
"edit": "Edit",
"delete": "Delete"
"delete": "Delete",
"insights": "Insights"
},
"notSet": "Not set"
"notSet": "Not set",
"filters": {
"search": "Search grade/school/head...",
"school": "School",
"allSchools": "All schools",
"head": "Head",
"allHeads": "All",
"missing": "Both missing",
"missingGradeHead": "Missing grade head",
"missingTeachingHead": "Missing teaching head",
"sort": "Sort",
"defaultSort": "Default",
"updatedDesc": "Updated (new→old)",
"updatedAsc": "Updated (old→new)",
"nameAsc": "Name (A→Z)",
"nameDesc": "Name (Z→A)",
"orderAsc": "Order (low→high)",
"orderDesc": "Order (high→low)",
"reset": "Reset"
},
"list": {
"title": "Grade list",
"noSchools": "No schools",
"noSchoolsDescription": "Please create a school first, then create grades under it.",
"noGrades": "No grades",
"noGradesDescription": "Create a grade to manage heads and classes.",
"noMatch": "No matching results",
"noMatchDescription": "Try adjusting filters or clearing the search."
},
"validation": {
"selectSchool": "Please select a school",
"enterName": "Please enter a grade name",
"nameTooLong": "Grade name must be at most 100 characters",
"orderInvalid": "Order must be a non-negative integer",
"duplicateName": "A grade with this name already exists in this school",
"fixForm": "Please complete the form",
"noChanges": "No changes to save"
},
"optional": "Optional",
"failedCreate": "Failed to create grade",
"failedUpdate": "Failed to update grade",
"failedDelete": "Failed to delete grade"
},
"departments": {
"title": "Department Management",
@@ -159,5 +201,57 @@
"edit": "Edit",
"delete": "Delete"
}
},
"classManagement": {
"title": "Class Management",
"description": "Manage classes and assign teachers.",
"grade": {
"title": "Class Management",
"description": "Manage classes for your grades.",
"insights": {
"title": "Grade Insights",
"description": "View grade-level homework statistics for grades you lead.",
"filters": "Filters",
"grade": "Grade",
"apply": "Apply",
"selectGrade": "Select a grade",
"noGrades": "No grades assigned",
"noGradesDescription": "You are not assigned as a grade head or teaching head for any grade.",
"selectToView": "Select a grade to view insights",
"selectToViewDescription": "Pick a grade to see latest homework and historical score statistics.",
"notFound": "Grade not found",
"notFoundDescription": "This grade may not exist or has no accessible data.",
"noData": "No homework data for this grade",
"noDataDescription": "No homework assignments were targeted to students in this grade yet.",
"classes": "Classes",
"students": "Students",
"overallAvg": "Overall Avg",
"latestAvg": "Latest Avg",
"homeworkTimeline": "Homework timeline",
"classRanking": "Class ranking",
"assignment": "Assignment",
"status": "Status",
"created": "Created",
"targeted": "Targeted",
"submitted": "Submitted",
"graded": "Graded",
"avg": "Avg",
"median": "Median",
"class": "Class",
"latestAvgCol": "Latest Avg",
"prevAvg": "Prev Avg",
"delta": "Δ",
"overallAvgCol": "Overall Avg",
"active": "Active",
"inactive": "Inactive"
}
}
},
"errors": {
"boundary": {
"title": "Loading Failed",
"description": "An error occurred while loading data. Please retry.",
"retry": "Retry"
}
}
}

View File

@@ -78,9 +78,51 @@
},
"actions": {
"edit": "编辑",
"delete": "删除"
"delete": "删除",
"insights": "学情"
},
"notSet": "未设置"
"notSet": "未设置",
"filters": {
"search": "搜索年级/学校/组长...",
"school": "学校",
"allSchools": "全部学校",
"head": "负责人",
"allHeads": "全部",
"missing": "两者都未设置",
"missingGradeHead": "未设置年级组长",
"missingTeachingHead": "未设置教研组长",
"sort": "排序",
"defaultSort": "默认排序",
"updatedDesc": "更新时间(新→旧)",
"updatedAsc": "更新时间(旧→新)",
"nameAsc": "年级名称A→Z",
"nameDesc": "年级名称Z→A",
"orderAsc": "Order小→大",
"orderDesc": "Order大→小",
"reset": "重置"
},
"list": {
"title": "年级列表",
"noSchools": "暂无学校",
"noSchoolsDescription": "请先创建学校,再在学校下创建年级。",
"noGrades": "暂无年级",
"noGradesDescription": "创建年级以便管理负责人和班级。",
"noMatch": "没有匹配结果",
"noMatchDescription": "尝试修改筛选条件或清空搜索。"
},
"validation": {
"selectSchool": "请选择学校",
"enterName": "请输入年级名称",
"nameTooLong": "年级名称最多 100 个字符",
"orderInvalid": "Order 必须是非负整数",
"duplicateName": "该学校下已存在同名年级",
"fixForm": "请完善表单信息",
"noChanges": "没有可保存的变更"
},
"optional": "可选",
"failedCreate": "创建年级失败",
"failedUpdate": "更新年级失败",
"failedDelete": "删除年级失败"
},
"departments": {
"title": "部门管理",
@@ -159,5 +201,57 @@
"edit": "编辑",
"delete": "删除"
}
},
"classManagement": {
"title": "班级管理",
"description": "管理班级并分配教师。",
"grade": {
"title": "班级管理",
"description": "管理你所在年级的班级。",
"insights": {
"title": "年级学情",
"description": "查看你负责年级的作业整体统计。",
"filters": "筛选",
"grade": "年级",
"apply": "应用",
"selectGrade": "选择年级",
"noGrades": "暂无负责年级",
"noGradesDescription": "你尚未被指派为任何年级的年级主任或教研组长。",
"selectToView": "请选择年级查看学情",
"selectToViewDescription": "选择一个年级以查看最新作业与历史成绩统计。",
"notFound": "年级不存在",
"notFoundDescription": "该年级可能不存在或暂无可访问数据。",
"noData": "该年级暂无作业数据",
"noDataDescription": "该年级学生尚未收到任何作业。",
"classes": "班级",
"students": "学生",
"overallAvg": "整体均分",
"latestAvg": "最新均分",
"homeworkTimeline": "作业时间线",
"classRanking": "班级排名",
"assignment": "作业",
"status": "状态",
"created": "创建时间",
"targeted": "目标人数",
"submitted": "已提交",
"graded": "已批改",
"avg": "均分",
"median": "中位数",
"class": "班级",
"latestAvgCol": "最新均分",
"prevAvg": "上次均分",
"delta": "变化",
"overallAvgCol": "整体均分",
"active": "活跃",
"inactive": "非活跃"
}
}
},
"errors": {
"boundary": {
"title": "加载失败",
"description": "数据加载时发生错误,请重试",
"retry": "重试"
}
}
}