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

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