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:
@@ -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 []
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user