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

@@ -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 []
}
})