P2-2: 新增 OrgTreeNav 组件(学校→年级→班级三级树形导航,支持搜索过滤/选中高亮/展开折叠) P2-3: 新增 promoteGradesAction 年级升级功能(中文数字/阿拉伯数字识别,按 order 降序避免冲突) P2-4: 新增 bulkEnrollStudentsAction(CSV 批量导入学生)+ bulkAssignSubjectTeachersAction(CSV 批量分配教师) P2-5: 为 department/academicYear/grade 的 9 个 CRUD Action 补充 logAudit 审计日志 同步更新架构图文档 004/005
801 lines
23 KiB
TypeScript
801 lines
23 KiB
TypeScript
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 []
|
||
}
|
||
})
|