refactor(school,classes): 完成 school/grade/class 审计全量改进项

P0-1/P0-2: 删除 grade-management 死模块,年级 CRUD 统一由 school 模块负责

P0-3: classes/actions.ts 从 974 行拆分为 6 个职责文件 + barrel re-export

P0-5: 13 个页面 i18n 全量接入(grades/departments/academic-year/classes/insights)

P1-1: 角色硬编码改为 hasAdminScope/hasTeacherScope/hasStudentScope 基于 dataScope.type

P1-3: 新增 SchoolErrorBoundary + SchoolListSkeleton/SchoolCardSkeleton,4 个页面包裹 Error Boundary

P1-4: classes/types.ts 跨领域类型添加归属决策注释

P1-5: schools-view.tsx 拆分为组合模式(SchoolFormDialog + SchoolDeleteDialog + SchoolListToolbar)

P1-6: 新增 getSchoolsForUser/getGradesForUser 权限感知查询函数

P2-1: 抽取 useSchoolData hook,对话框状态管理与 UI 分离

同步更新架构图文档 004/005
This commit is contained in:
SpecialX
2026-06-22 18:54:01 +08:00
parent 97e59b95a1
commit 15aa84b72c
29 changed files with 2267 additions and 1380 deletions

View File

@@ -205,6 +205,172 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
}
})
/**
* 根据用户角色返回可见的学校列表(权限感知)。
* - 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)
// ---------------------------------------------------------------------------