feat(school,classes): 实现 P2 长期问题全量改进项

P2-2: 新增 OrgTreeNav 组件(学校→年级→班级三级树形导航,支持搜索过滤/选中高亮/展开折叠)

P2-3: 新增 promoteGradesAction 年级升级功能(中文数字/阿拉伯数字识别,按 order 降序避免冲突)

P2-4: 新增 bulkEnrollStudentsAction(CSV 批量导入学生)+ bulkAssignSubjectTeachersAction(CSV 批量分配教师)

P2-5: 为 department/academicYear/grade 的 9 个 CRUD Action 补充 logAudit 审计日志

同步更新架构图文档 004/005
This commit is contained in:
SpecialX
2026-06-23 08:55:21 +08:00
parent 4da9194a5e
commit c766951374
11 changed files with 761 additions and 81 deletions

View File

@@ -8,7 +8,7 @@ import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { logAudit } from "@/shared/lib/audit-logger"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema, PromoteGradesSchema } from "./schema"
import {
createAcademicYear,
createDepartment,
@@ -18,6 +18,7 @@ import {
deleteDepartment,
deleteGrade,
deleteSchool,
promoteGrades,
updateAcademicYear,
updateDepartment,
updateGrade,
@@ -44,6 +45,15 @@ export async function createDepartmentAction(
description: parsed.data.description ?? null,
})
after(() =>
logAudit({
action: "department.create",
module: "school",
targetType: "department",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/departments")
return { success: true, message: "Department created" }
} catch (error) {
@@ -73,6 +83,16 @@ export async function updateDepartmentAction(
description: parsed.data.description ?? null,
})
after(() =>
logAudit({
action: "department.update",
module: "school",
targetId: departmentId,
targetType: "department",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/departments")
return { success: true, message: "Department updated" }
} catch (error) {
@@ -86,6 +106,14 @@ export async function deleteDepartmentAction(departmentId: string): Promise<Acti
try {
await requirePermission(Permissions.SCHOOL_MANAGE)
await deleteDepartment(departmentId)
after(() =>
logAudit({
action: "department.delete",
module: "school",
targetId: departmentId,
targetType: "department",
})
)
revalidatePath("/admin/school/departments")
return { success: true, message: "Department deleted" }
} catch (error) {
@@ -119,6 +147,15 @@ export async function createAcademicYearAction(
isActive: parsed.data.isActive,
})
after(() =>
logAudit({
action: "academicYear.create",
module: "school",
targetType: "academicYear",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year created" }
} catch (error) {
@@ -152,6 +189,16 @@ export async function updateAcademicYearAction(
isActive: parsed.data.isActive,
})
after(() =>
logAudit({
action: "academicYear.update",
module: "school",
targetId: academicYearId,
targetType: "academicYear",
detail: { name: parsed.data.name },
})
)
revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year updated" }
} catch (error) {
@@ -291,6 +338,15 @@ export async function createGradeAction(
teachingHeadId: parsed.data.teachingHeadId,
})
after(() =>
logAudit({
action: "grade.create",
module: "school",
targetType: "grade",
detail: { name: parsed.data.name, schoolId: parsed.data.schoolId },
})
)
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade created" }
} catch (error) {
@@ -326,6 +382,16 @@ export async function updateGradeAction(
teachingHeadId: parsed.data.teachingHeadId,
})
after(() =>
logAudit({
action: "grade.update",
module: "school",
targetId: gradeId,
targetType: "grade",
detail: { name: parsed.data.name, schoolId: parsed.data.schoolId },
})
)
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade updated" }
} catch (error) {
@@ -339,6 +405,14 @@ export async function deleteGradeAction(gradeId: string): Promise<ActionState<st
try {
await requirePermission(Permissions.GRADE_MANAGE)
await deleteGrade(gradeId)
after(() =>
logAudit({
action: "grade.delete",
module: "school",
targetId: gradeId,
targetType: "grade",
})
)
revalidatePath("/admin/school/grades")
return { success: true, message: "Grade deleted" }
} catch (error) {
@@ -347,3 +421,37 @@ export async function deleteGradeAction(gradeId: string): Promise<ActionState<st
return { success: false, message: "Failed to delete grade" }
}
}
export async function promoteGradesAction(
prevState: ActionState<string> | undefined,
formData: FormData
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_MANAGE)
const parsed = PromoteGradesSchema.safeParse({
schoolId: formData.get("schoolId"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const result = await promoteGrades(parsed.data.schoolId)
after(() =>
logAudit({
action: "grade.promote",
module: "school",
targetType: "grade",
targetId: parsed.data.schoolId,
detail: { promoted: result.promoted },
})
)
revalidatePath("/admin/school/grades")
return { success: true, message: `Promoted ${result.promoted} grades` }
} catch (error) {
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
if (error instanceof Error) return { success: false, message: error.message }
return { success: false, message: "Failed to promote grades" }
}
}

View File

@@ -0,0 +1,148 @@
"use client"
import { useMemo, useState, type JSX } from "react"
import { ChevronDown, ChevronRight, GraduationCap, School, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import type { OrgTreeNode } from "../types"
import { cn } from "@/shared/lib/utils"
import { Input } from "@/shared/components/ui/input"
type OrgTreeNavProps = {
nodes: OrgTreeNode[]
onSelect?: (node: OrgTreeNode) => void
selectedId?: string
}
type OrgTreeItemProps = {
node: OrgTreeNode
depth: number
onSelect?: (node: OrgTreeNode) => void
selectedId?: string
search: string
}
const NODE_ICON: Record<OrgTreeNode["type"], typeof School> = {
school: School,
grade: GraduationCap,
class: Users,
}
function matchesSearch(node: OrgTreeNode, query: string): boolean {
if (!query) return true
if (node.name.toLowerCase().includes(query)) return true
return (node.children ?? []).some((child) => matchesSearch(child, query))
}
function OrgTreeItem({ node, depth, onSelect, selectedId, search }: OrgTreeItemProps): JSX.Element {
const [expanded, setExpanded] = useState<boolean>(depth === 0)
const children = node.children ?? []
const hasChildren = children.length > 0
const isSelected = selectedId === node.id
const Icon = NODE_ICON[node.type]
const handleToggle = (): void => {
if (hasChildren) setExpanded((v) => !v)
}
const handleSelect = (): void => {
onSelect?.(node)
if (hasChildren && !expanded) setExpanded(true)
}
return (
<div>
<div
role="button"
tabIndex={0}
onClick={handleSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
handleSelect()
}
}}
className={cn(
"flex cursor-pointer items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected && "bg-accent font-medium text-accent-foreground"
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleToggle()
}}
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-transform"
aria-label={expanded ? "collapse" : "expand"}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
) : (
<span className="h-4 w-4 shrink-0" />
)}
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{node.name}</span>
</div>
{hasChildren && expanded && (
<div>
{children.map((child) =>
matchesSearch(child, search) ? (
<OrgTreeItem
key={child.id}
node={child}
depth={depth + 1}
onSelect={onSelect}
selectedId={selectedId}
search={search}
/>
) : null
)}
</div>
)}
</div>
)
}
export function OrgTreeNav({ nodes, onSelect, selectedId }: OrgTreeNavProps): JSX.Element {
const t = useTranslations("school")
const [search, setSearch] = useState<string>("")
const filtered = useMemo(
() => nodes.filter((node) => matchesSearch(node, search.toLowerCase())),
[nodes, search]
)
return (
<div className="space-y-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("orgTree.search")}
className="h-8 text-sm"
/>
{filtered.length === 0 ? (
<p className="px-2 py-4 text-center text-sm text-muted-foreground">{t("orgTree.empty")}</p>
) : (
<div className="space-y-0.5">
{filtered.map((node) => (
<OrgTreeItem
key={node.id}
node={node}
depth={0}
onSelect={onSelect}
selectedId={selectedId}
search={search.toLowerCase()}
/>
))}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
@@ -15,6 +15,7 @@ import type {
GradeInsertData,
GradeListItem,
GradeUpdateData,
OrgTreeNode,
SchoolInsertData,
SchoolListItem,
SchoolUpdateData,
@@ -595,6 +596,24 @@ export const getSubjectNameById = cache(
},
)
/**
* 批量获取科目名称映射subjectId -> name
* 供跨模块调用使用,避免直接查询 subjects 表。
*/
export const getSubjectNameMapByIds = cache(
async (subjectIds: string[]): Promise<Map<string, string | null>> => {
const ids = subjectIds.filter((id) => id.trim().length > 0)
if (ids.length === 0) return new Map()
const rows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, ids))
const map = new Map<string, string | null>()
for (const r of rows) map.set(r.id, r.name)
return map
},
)
// ---------------------------------------------------------------------------
// Cross-module query interfaces — grade head/teaching head verification
// ---------------------------------------------------------------------------
@@ -668,3 +687,114 @@ export const findGradeIdByHeadAndName = cache(async (
.limit(1)
return row?.id ?? null
})
/**
* 将年级名称升级一年级→二年级1年级→2年级Grade 1→Grade 2
* 无法识别的名称保持不变。
*/
function promoteGradeName(name: string): string {
// 中文数字映射
const chineseNums = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"]
for (let i = 0; i < chineseNums.length - 1; i++) {
if (name.includes(chineseNums[i]) && !name.includes(chineseNums[i + 1])) {
return name.replace(chineseNums[i], chineseNums[i + 1])
}
}
// 阿拉伯数字
const match = name.match(/(\d+)/)
if (match) {
const num = parseInt(match[1], 10)
if (num > 0 && num < 13) {
return name.replace(match[1], String(num + 1))
}
}
return name
}
/**
* 年级升级:将指定学校下的所有年级 order +1并更新年级名称如果符合数字模式
* 例如:一年级 → 二年级order 1 → 2
*
* 注意:此函数只更新年级本身的 order 和 name不迁移班级数据。
* 班级升级应由 classes 模块的独立 Action 处理。
*/
export async function promoteGrades(schoolId: string): Promise<{ promoted: number }> {
const rows = await db
.select({ id: grades.id, name: grades.name, order: grades.order })
.from(grades)
.where(eq(grades.schoolId, schoolId))
.orderBy(desc(grades.order)) // 从高到低升级,避免唯一约束冲突
let promoted = 0
for (const row of rows) {
const newOrder = (row.order ?? 0) + 1
const newName = promoteGradeName(row.name)
await db
.update(grades)
.set({ order: newOrder, name: newName })
.where(eq(grades.id, row.id))
promoted += 1
}
return { promoted }
}
/**
* 获取学校→年级→班级三级组织架构树。
* 班级数据通过 classes 模块的 data-access 动态导入获取,避免循环依赖。
* 任何一层查询失败均返回空数组,保证调用方拿到稳定结构。
*/
export const getOrgTree = cache(async (): Promise<OrgTreeNode[]> => {
try {
const [schoolList, gradeList] = await Promise.all([getSchools(), getGrades()])
const { getAdminClasses } = await import("@/modules/classes/data-access")
const allClasses = await getAdminClasses()
const classesByGradeId = new Map<string, OrgTreeNode[]>()
for (const cls of allClasses) {
const gradeId = cls.gradeId
if (typeof gradeId !== "string" || gradeId.length === 0) continue
const node: OrgTreeNode = {
id: cls.id,
name: cls.name,
type: "class",
}
const list = classesByGradeId.get(gradeId)
if (list) {
list.push(node)
} else {
classesByGradeId.set(gradeId, [node])
}
}
const gradesBySchoolId = new Map<string, OrgTreeNode[]>()
for (const grade of gradeList) {
const children = classesByGradeId.get(grade.id) ?? []
const node: OrgTreeNode = {
id: grade.id,
name: grade.name,
type: "grade",
children,
}
const list = gradesBySchoolId.get(grade.school.id)
if (list) {
list.push(node)
} else {
gradesBySchoolId.set(grade.school.id, [node])
}
}
return schoolList.map((school) => ({
id: school.id,
name: school.name,
type: "school",
children: gradesBySchoolId.get(school.id) ?? [],
}))
} catch (error) {
console.error("getOrgTree failed:", error)
return []
}
})

View File

@@ -50,3 +50,7 @@ export const UpsertGradeSchema = z
.refine((v) => Number.isFinite(v.order) && Number.isInteger(v.order) && v.order >= 0, {
message: "order must be a non-negative integer",
})
export const PromoteGradesSchema = z.object({
schoolId: z.string().trim().min(1),
})

View File

@@ -94,3 +94,10 @@ export type AcademicYearUpdateData = {
endDate: Date
isActive: boolean
}
export type OrgTreeNode = {
id: string
name: string
type: "school" | "grade" | "class"
children?: OrgTreeNode[]
}