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

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