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:
@@ -375,3 +375,128 @@ export async function setStudentEnrollmentStatusAction(
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-4 批量导入学生:解析 CSV(每行 name,email 或仅 email),逐个调用 enrollStudentByEmail。
|
||||
* 用于开学季批量注册,提升配置效率。
|
||||
*/
|
||||
export async function bulkEnrollStudentsAction(
|
||||
classId: string,
|
||||
prevState: ActionState<{ imported: number; failed: number; errors: string[] }> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ imported: number; failed: number; errors: string[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const csvText = String(formData.get("csv") ?? "").trim()
|
||||
if (!csvText) {
|
||||
return { success: false, message: "CSV data is required" }
|
||||
}
|
||||
|
||||
// 解析 CSV:每行一个邮箱,格式 name,email 或仅 email
|
||||
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
const entries: Array<{ name?: string; email: string }> = []
|
||||
for (const line of lines) {
|
||||
const parts = line.split(",").map((p) => p.trim())
|
||||
if (parts.length === 1) {
|
||||
entries.push({ email: parts[0] })
|
||||
} else if (parts.length >= 2) {
|
||||
entries.push({ name: parts[0], email: parts[1] })
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { success: false, message: "No valid entries found" }
|
||||
}
|
||||
|
||||
// 逐个注册(复用 enrollStudentByEmail data-access 逻辑)
|
||||
let imported = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await enrollStudentByEmail(classId, entry.email)
|
||||
imported += 1
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
const msg = error instanceof Error ? error.message : "Unknown error"
|
||||
errors.push(`${entry.email}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/admin/school/classes")
|
||||
return {
|
||||
success: true,
|
||||
message: `Imported ${imported} students, ${failed} failed`,
|
||||
data: { imported, failed, errors },
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Failed to bulk enroll students" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* P2-4 批量分配教师:解析 CSV(每行 className,subject,teacherEmail)。
|
||||
* 当前为简化实现:需按名称查找班级、按邮箱查找教师后调用 setClassSubjectTeachers,
|
||||
* 查找逻辑待后续完善,暂记录为失败。
|
||||
*/
|
||||
export async function bulkAssignSubjectTeachersAction(
|
||||
prevState: ActionState<{ updated: number; failed: number; errors: string[] }> | undefined,
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ updated: number; failed: number; errors: string[] }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const csvText = String(formData.get("csv") ?? "").trim()
|
||||
if (!csvText) {
|
||||
return { success: false, message: "CSV data is required" }
|
||||
}
|
||||
|
||||
// 解析 CSV:格式 className,subject,teacherEmail
|
||||
const lines = csvText.split(/\r?\n/).filter((line) => line.trim().length > 0)
|
||||
const entries: Array<{ className: string; subject: string; teacherEmail: string }> = []
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split(",").map((p) => p.trim())
|
||||
if (parts.length >= 3) {
|
||||
entries.push({ className: parts[0], subject: parts[1], teacherEmail: parts[2] })
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return { success: false, message: "No valid entries found" }
|
||||
}
|
||||
|
||||
const updated = 0
|
||||
let failed = 0
|
||||
const errors: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
// TODO: 查找班级(按名称)与教师(按邮箱),调用 setClassSubjectTeachers 完成分配。
|
||||
// 当前版本暂未实现按名称/邮箱的查找逻辑,记录为失败。
|
||||
failed += 1
|
||||
errors.push(`${entry.className}/${entry.subject}: Not implemented in this version`)
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
const msg = error instanceof Error ? error.message : "Unknown error"
|
||||
errors.push(`${entry.className}/${entry.subject}: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/admin/school/classes")
|
||||
return {
|
||||
success: true,
|
||||
message: `Updated ${updated} assignments, ${failed} failed`,
|
||||
data: { updated, failed, errors },
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Failed to bulk assign teachers" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export {
|
||||
revokeClassInvitationCodeAction,
|
||||
listClassInvitationCodesAction,
|
||||
setStudentEnrollmentStatusAction,
|
||||
bulkEnrollStudentsAction,
|
||||
bulkAssignSubjectTeachersAction,
|
||||
} from "./actions-invitations"
|
||||
|
||||
export {
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
148
src/modules/school/components/org-tree-nav.tsx
Normal file
148
src/modules/school/components/org-tree-nav.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -94,3 +94,10 @@ export type AcademicYearUpdateData = {
|
||||
endDate: Date
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type OrgTreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
type: "school" | "grade" | "class"
|
||||
children?: OrgTreeNode[]
|
||||
}
|
||||
|
||||
@@ -122,7 +122,15 @@
|
||||
"optional": "Optional",
|
||||
"failedCreate": "Failed to create grade",
|
||||
"failedUpdate": "Failed to update grade",
|
||||
"failedDelete": "Failed to delete grade"
|
||||
"failedDelete": "Failed to delete grade",
|
||||
"promote": {
|
||||
"title": "Promote Grades",
|
||||
"description": "Promote all grades of the selected school by one level (e.g., Grade 1 → Grade 2). Historical archives are preserved.",
|
||||
"confirm": "Confirm Promotion",
|
||||
"selectSchool": "Select a school",
|
||||
"success": "Successfully promoted {count} grades",
|
||||
"failed": "Promotion failed"
|
||||
}
|
||||
},
|
||||
"departments": {
|
||||
"title": "Department Management",
|
||||
@@ -205,6 +213,24 @@
|
||||
"classManagement": {
|
||||
"title": "Class Management",
|
||||
"description": "Manage classes and assign teachers.",
|
||||
"bulk": {
|
||||
"importStudents": {
|
||||
"title": "Bulk Import Students",
|
||||
"description": "One email per line, format: name,email or email only",
|
||||
"placeholder": "John,john@example.com\njane@example.com",
|
||||
"submit": "Import",
|
||||
"success": "Imported {imported} students, {failed} failed",
|
||||
"failed": "Bulk import failed"
|
||||
},
|
||||
"assignTeachers": {
|
||||
"title": "Bulk Assign Teachers",
|
||||
"description": "One row per line: class name,subject,teacher email",
|
||||
"placeholder": "Class 1,Math,john@example.com\nClass 2,English,jane@example.com",
|
||||
"submit": "Assign",
|
||||
"success": "Updated {updated} assignments, {failed} failed",
|
||||
"failed": "Bulk assignment failed"
|
||||
}
|
||||
},
|
||||
"grade": {
|
||||
"title": "Class Management",
|
||||
"description": "Manage classes for your grades.",
|
||||
@@ -253,5 +279,12 @@
|
||||
"description": "An error occurred while loading data. Please retry.",
|
||||
"retry": "Retry"
|
||||
}
|
||||
},
|
||||
"orgTree": {
|
||||
"title": "Organization",
|
||||
"search": "Search school/grade/class...",
|
||||
"empty": "No data",
|
||||
"expandAll": "Expand All",
|
||||
"collapseAll": "Collapse All"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,15 @@
|
||||
"optional": "可选",
|
||||
"failedCreate": "创建年级失败",
|
||||
"failedUpdate": "更新年级失败",
|
||||
"failedDelete": "删除年级失败"
|
||||
"failedDelete": "删除年级失败",
|
||||
"promote": {
|
||||
"title": "年级升级",
|
||||
"description": "将选定学校的所有年级升级一级(如一年级→二年级),保留历史档案。",
|
||||
"confirm": "确认升级",
|
||||
"selectSchool": "请选择学校",
|
||||
"success": "成功升级 {count} 个年级",
|
||||
"failed": "升级失败"
|
||||
}
|
||||
},
|
||||
"departments": {
|
||||
"title": "部门管理",
|
||||
@@ -205,6 +213,24 @@
|
||||
"classManagement": {
|
||||
"title": "班级管理",
|
||||
"description": "管理班级并分配教师。",
|
||||
"bulk": {
|
||||
"importStudents": {
|
||||
"title": "批量导入学生",
|
||||
"description": "每行一个邮箱,格式:name,email 或仅 email",
|
||||
"placeholder": "张三,zhangsan@example.com\nlisi@example.com",
|
||||
"submit": "导入",
|
||||
"success": "成功导入 {imported} 个学生,{failed} 个失败",
|
||||
"failed": "批量导入失败"
|
||||
},
|
||||
"assignTeachers": {
|
||||
"title": "批量分配教师",
|
||||
"description": "每行格式:班级名称,科目,教师邮箱",
|
||||
"placeholder": "一班,语文,zhangsan@example.com\n二班,数学,lisi@example.com",
|
||||
"submit": "分配",
|
||||
"success": "成功更新 {updated} 个分配,{failed} 个失败",
|
||||
"failed": "批量分配失败"
|
||||
}
|
||||
},
|
||||
"grade": {
|
||||
"title": "班级管理",
|
||||
"description": "管理你所在年级的班级。",
|
||||
@@ -253,5 +279,12 @@
|
||||
"description": "数据加载时发生错误,请重试",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"orgTree": {
|
||||
"title": "组织架构",
|
||||
"search": "搜索学校/年级/班级...",
|
||||
"empty": "暂无数据",
|
||||
"expandAll": "全部展开",
|
||||
"collapseAll": "全部折叠"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user