Files
NextEdu/src/modules/school/data-access.ts
SpecialX c766951374 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
2026-06-23 08:55:21 +08:00

801 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { cache } from "react"
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"
import type {
AcademicYearInsertData,
AcademicYearListItem,
AcademicYearUpdateData,
DepartmentInsertData,
DepartmentListItem,
DepartmentUpdateData,
GradeInsertData,
GradeListItem,
GradeUpdateData,
OrgTreeNode,
SchoolInsertData,
SchoolListItem,
SchoolUpdateData,
StaffOption,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => {
try {
const rows = await db.select().from(departments).orderBy(asc(departments.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? null,
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch (error) {
console.error("getDepartments failed:", error)
return []
}
})
export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]> => {
try {
const rows = await db.select().from(academicYears).orderBy(asc(academicYears.startDate))
return rows.map((r) => ({
id: r.id,
name: r.name,
startDate: toIso(r.startDate),
endDate: toIso(r.endDate),
isActive: Boolean(r.isActive),
createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt),
}))
} catch (error) {
console.error("getAcademicYears failed:", error)
return []
}
})
export const getSchools = cache(async (): Promise<SchoolListItem[]> => {
try {
const rows = await db.select().from(schools).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("getSchools failed:", error)
return []
}
})
export const getGrades = cache(async (): Promise<GradeListItem[]> => {
try {
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))
.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),
}))
} catch (error) {
console.error("getGrades failed:", error)
return []
}
})
export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
try {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(roles.name, ["teacher", "admin"]))
.groupBy(users.id, users.name, users.email)
.orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unnamed",
email: r.email,
}))
} catch (error) {
console.error("getStaffOptions failed:", error)
return []
}
})
export const getGradesForStaff = cache(async (staffId: string): Promise<GradeListItem[]> => {
const id = staffId.trim()
if (!id) return []
try {
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(or(eq(grades.gradeHeadId, id), eq(grades.teachingHeadId, id)))
.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),
}))
} catch (error) {
console.error("getGradesForStaff failed:", error)
return []
}
})
/**
* 根据用户角色返回可见的学校列表(权限感知)。
* - 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)
// ---------------------------------------------------------------------------
export async function createDepartment(data: DepartmentInsertData): Promise<void> {
await db.insert(departments).values({
id: data.id,
name: data.name,
description: data.description,
})
}
export async function updateDepartment(
id: string,
data: DepartmentUpdateData
): Promise<void> {
await db
.update(departments)
.set({ name: data.name, description: data.description })
.where(eq(departments.id, id))
}
export async function deleteDepartment(id: string): Promise<void> {
await db.delete(departments).where(eq(departments.id, id))
}
export async function createSchool(data: SchoolInsertData): Promise<void> {
await db.insert(schools).values({
id: data.id,
name: data.name,
code: data.code,
})
}
export async function updateSchool(
id: string,
data: SchoolUpdateData
): Promise<void> {
await db
.update(schools)
.set({ name: data.name, code: data.code })
.where(eq(schools.id, id))
}
export async function deleteSchool(id: string): Promise<void> {
await db.delete(schools).where(eq(schools.id, id))
}
export async function createGrade(data: GradeInsertData): Promise<void> {
await db.insert(grades).values({
id: data.id,
schoolId: data.schoolId,
name: data.name,
order: data.order,
gradeHeadId: data.gradeHeadId,
teachingHeadId: data.teachingHeadId,
})
}
export async function updateGrade(
id: string,
data: GradeUpdateData
): Promise<void> {
await db
.update(grades)
.set({
schoolId: data.schoolId,
name: data.name,
order: data.order,
gradeHeadId: data.gradeHeadId,
teachingHeadId: data.teachingHeadId,
})
.where(eq(grades.id, id))
}
export async function deleteGrade(id: string): Promise<void> {
await db.delete(grades).where(eq(grades.id, id))
}
export async function createAcademicYear(
data: AcademicYearInsertData
): Promise<void> {
await db.transaction(async (tx) => {
if (data.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx.insert(academicYears).values({
id: data.id,
name: data.name,
startDate: data.startDate,
endDate: data.endDate,
isActive: data.isActive,
})
})
}
export async function updateAcademicYear(
id: string,
data: AcademicYearUpdateData
): Promise<void> {
await db.transaction(async (tx) => {
if (data.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx
.update(academicYears)
.set({
name: data.name,
startDate: data.startDate,
endDate: data.endDate,
isActive: data.isActive,
})
.where(eq(academicYears.id, id))
})
}
export async function deleteAcademicYear(id: string): Promise<void> {
await db.delete(academicYears).where(eq(academicYears.id, id))
}
// ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules
// ---------------------------------------------------------------------------
export type SubjectOption = {
id: string
name: string
code: string | null
order: number
}
export type GradeOption = {
id: string
name: string
schoolId: string
schoolName: string
order: number
}
export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => {
try {
const rows = await db
.select({
id: subjects.id,
name: subjects.name,
code: subjects.code,
order: subjects.order,
})
.from(subjects)
.orderBy(asc(subjects.order), asc(subjects.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
code: r.code ?? null,
order: Number(r.order ?? 0),
}))
} catch (error) {
console.error("getSubjectOptions failed:", error)
return []
}
})
export const getGradeOptions = cache(async (): Promise<GradeOption[]> => {
try {
const rows = await db
.select({
id: grades.id,
name: grades.name,
order: grades.order,
schoolId: schools.id,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
return rows.map((r) => ({
id: r.id,
name: r.name,
schoolId: r.schoolId,
schoolName: r.schoolName,
order: Number(r.order ?? 0),
}))
} catch (error) {
console.error("getGradeOptions failed:", error)
return []
}
})
/**
* 按 ID 获取单个年级名称。
* 供跨模块调用使用,避免为单个年级拉取全量年级选项。
*/
export const getGradeNameById = cache(
async (gradeId: string): Promise<string | null> => {
const id = gradeId.trim()
if (!id) return null
const [row] = await db
.select({ name: grades.name })
.from(grades)
.where(eq(grades.id, id))
.limit(1)
return row?.name ?? null
},
)
/**
* 按 ID 获取单个科目名称。
* 供跨模块调用使用,避免为单个科目拉取全量科目选项或直接查询 subjects 表。
*/
export const getSubjectNameById = cache(
async (subjectId: string): Promise<string | null> => {
const id = subjectId.trim()
if (!id) return null
const [row] = await db
.select({ name: subjects.name })
.from(subjects)
.where(eq(subjects.id, id))
.limit(1)
return row?.name ?? null
},
)
/**
* 批量获取科目名称映射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
// ---------------------------------------------------------------------------
/**
* 校验用户是否为指定年级的年级主任。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/
export const isGradeHead = cache(async (
gradeId: string,
userId: string
): Promise<boolean> => {
const trimmedGradeId = gradeId.trim()
const trimmedUserId = userId.trim()
if (!trimmedGradeId || !trimmedUserId) return false
const [row] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId)))
.limit(1)
return Boolean(row)
})
/**
* 校验用户是否为指定年级的年级主任或教学主任。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/
export const isGradeManager = cache(async (
gradeId: string,
userId: string
): Promise<boolean> => {
const trimmedGradeId = gradeId.trim()
const trimmedUserId = userId.trim()
if (!trimmedGradeId || !trimmedUserId) return false
const [row] = await db
.select({ id: grades.id })
.from(grades)
.where(
and(
eq(grades.id, trimmedGradeId),
or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId))
)
)
.limit(1)
return Boolean(row)
})
/**
* 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
*/
export const findGradeIdByHeadAndName = cache(async (
userId: string,
gradeName: string
): Promise<string | null> => {
const trimmedUserId = userId.trim()
const normalizedGradeName = gradeName.trim().toLowerCase()
if (!trimmedUserId || !normalizedGradeName) return null
const [row] = await db
.select({ id: grades.id })
.from(grades)
.where(
and(
eq(grades.gradeHeadId, trimmedUserId),
sql`LOWER(${grades.name}) = ${normalizedGradeName}`
)
)
.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 []
}
})