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

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