Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
import "server-only"
|
|
|
|
import { cache } from "react"
|
|
import { and, asc, 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,
|
|
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 []
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
},
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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
|
|
})
|