feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013
## P1 功能(20 项) - 站内消息系统、家长仪表盘、学生考勤管理 - Excel 导入导出、用户批量导入、成绩导出 - 排课规则+自动排课+课表调整 - 成绩趋势+对比分析、密码安全策略、速率限制 - 数据变更日志、文件预览+存储策略、全文检索 - 依赖审计集成 CI、数据库定时备份、E2E 测试完善 - 通知偏好管理 ## 基础设施修复 - src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求) - .env: MySQL 端口从 13002 切换至 14013 - scripts/create-db.ts: 新增数据库初始化脚本 ## 架构文档同步 - 004_architecture_impact_map.md 和 005_architecture_data.json 完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
272
src/modules/scheduling/data-access.ts
Normal file
272
src/modules/scheduling/data-access.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, desc, eq, isNull, or, type SQL } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classSchedule,
|
||||
classSubjectTeachers,
|
||||
classrooms,
|
||||
scheduleChanges,
|
||||
schedulingRules,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import type {
|
||||
ScheduleChangeListItem,
|
||||
ScheduleChangeQueryParams,
|
||||
ScheduleConflict,
|
||||
SchedulingRule,
|
||||
} from "./types"
|
||||
import type { SchedulingRuleInput, ScheduleChangeInput } from "./schema"
|
||||
|
||||
const serializeDate = (d: Date | string | null): string | null =>
|
||||
d ? new Date(d).toISOString().slice(0, 10) : null
|
||||
|
||||
const mapRule = (r: typeof schedulingRules.$inferSelect): SchedulingRule => ({
|
||||
id: r.id,
|
||||
classId: r.classId ?? null,
|
||||
maxDailyHours: r.maxDailyHours ?? 8,
|
||||
maxContinuousHours: r.maxContinuousHours ?? 2,
|
||||
lunchBreakStart: r.lunchBreakStart ?? "12:00",
|
||||
lunchBreakEnd: r.lunchBreakEnd ?? "13:00",
|
||||
morningStart: r.morningStart ?? "08:00",
|
||||
afternoonEnd: r.afternoonEnd ?? "17:00",
|
||||
avoidBackToBack: r.avoidBackToBack ?? false,
|
||||
balancedSubjects: r.balancedSubjects ?? true,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
export async function getSchedulingRules(classId?: string): Promise<SchedulingRule[]> {
|
||||
const conditions: SQL[] = []
|
||||
if (classId) {
|
||||
const cond = or(eq(schedulingRules.classId, classId), isNull(schedulingRules.classId))
|
||||
if (cond) conditions.push(cond)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schedulingRules)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(schedulingRules.classId), desc(schedulingRules.updatedAt))
|
||||
|
||||
return rows.map(mapRule)
|
||||
}
|
||||
|
||||
export async function upsertSchedulingRules(data: SchedulingRuleInput): Promise<string> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schedulingRules)
|
||||
.where(eq(schedulingRules.classId, data.classId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(schedulingRules)
|
||||
.set({
|
||||
maxDailyHours: data.maxDailyHours ?? 8,
|
||||
maxContinuousHours: data.maxContinuousHours ?? 2,
|
||||
lunchBreakStart: data.lunchBreakStart ?? "12:00",
|
||||
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
|
||||
morningStart: data.morningStart ?? "08:00",
|
||||
afternoonEnd: data.afternoonEnd ?? "17:00",
|
||||
avoidBackToBack: data.avoidBackToBack ?? false,
|
||||
balancedSubjects: data.balancedSubjects ?? true,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schedulingRules.id, existing.id))
|
||||
return existing.id
|
||||
}
|
||||
|
||||
const id = createId()
|
||||
await db.insert(schedulingRules).values({
|
||||
id,
|
||||
classId: data.classId,
|
||||
maxDailyHours: data.maxDailyHours ?? 8,
|
||||
maxContinuousHours: data.maxContinuousHours ?? 2,
|
||||
lunchBreakStart: data.lunchBreakStart ?? "12:00",
|
||||
lunchBreakEnd: data.lunchBreakEnd ?? "13:00",
|
||||
morningStart: data.morningStart ?? "08:00",
|
||||
afternoonEnd: data.afternoonEnd ?? "17:00",
|
||||
avoidBackToBack: data.avoidBackToBack ?? false,
|
||||
balancedSubjects: data.balancedSubjects ?? true,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function getScheduleChanges(
|
||||
params: ScheduleChangeQueryParams
|
||||
): Promise<ScheduleChangeListItem[]> {
|
||||
const conditions: SQL[] = []
|
||||
if (params.classId) conditions.push(eq(scheduleChanges.classId, params.classId))
|
||||
if (params.status) conditions.push(eq(scheduleChanges.status, params.status))
|
||||
if (params.requesterId) conditions.push(eq(scheduleChanges.requestedBy, params.requesterId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
change: scheduleChanges,
|
||||
className: classes.name,
|
||||
originalTeacherName: users.name,
|
||||
requesterName: users.name,
|
||||
})
|
||||
.from(scheduleChanges)
|
||||
.innerJoin(classes, eq(classes.id, scheduleChanges.classId))
|
||||
.leftJoin(users, eq(users.id, scheduleChanges.originalTeacherId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(scheduleChanges.createdAt))
|
||||
|
||||
// Resolve substitute teacher & approver names separately to avoid join ambiguity
|
||||
const userIds = Array.from(
|
||||
new Set(
|
||||
rows.flatMap((r) => [
|
||||
r.change.substituteTeacherId,
|
||||
r.change.approvedBy,
|
||||
r.change.requestedBy,
|
||||
].filter((v): v is string => typeof v === "string" && v.length > 0))
|
||||
)
|
||||
)
|
||||
|
||||
const userMap = new Map<string, string>()
|
||||
if (userIds.length > 0) {
|
||||
const userRows = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(
|
||||
userIds.length === 1
|
||||
? eq(users.id, userIds[0]!)
|
||||
: or(...userIds.map((id) => eq(users.id, id)))
|
||||
)
|
||||
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown")
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.change.id,
|
||||
originalScheduleId: r.change.originalScheduleId ?? null,
|
||||
classId: r.change.classId,
|
||||
originalTeacherId: r.change.originalTeacherId ?? null,
|
||||
substituteTeacherId: r.change.substituteTeacherId ?? null,
|
||||
originalDate: serializeDate(r.change.originalDate),
|
||||
newDate: serializeDate(r.change.newDate),
|
||||
newStartTime: r.change.newStartTime ?? null,
|
||||
newEndTime: r.change.newEndTime ?? null,
|
||||
reason: r.change.reason ?? null,
|
||||
status: r.change.status,
|
||||
requestedBy: r.change.requestedBy,
|
||||
approvedBy: r.change.approvedBy ?? null,
|
||||
createdAt: r.change.createdAt.toISOString(),
|
||||
updatedAt: r.change.updatedAt.toISOString(),
|
||||
className: r.className,
|
||||
originalTeacherName: r.originalTeacherName ?? null,
|
||||
substituteTeacherName: r.change.substituteTeacherId
|
||||
? userMap.get(r.change.substituteTeacherId) ?? null
|
||||
: null,
|
||||
requesterName: userMap.get(r.change.requestedBy) ?? "Unknown",
|
||||
approverName: r.change.approvedBy ? userMap.get(r.change.approvedBy) ?? null : null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createScheduleChange(
|
||||
data: ScheduleChangeInput,
|
||||
requestedBy: string
|
||||
): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(scheduleChanges).values({
|
||||
id,
|
||||
originalScheduleId: data.originalScheduleId ?? null,
|
||||
classId: data.classId,
|
||||
originalTeacherId: data.originalTeacherId ?? null,
|
||||
substituteTeacherId: data.substituteTeacherId ?? null,
|
||||
originalDate: data.originalDate ? new Date(data.originalDate) : null,
|
||||
newDate: data.newDate ? new Date(data.newDate) : null,
|
||||
newStartTime: data.newStartTime ?? null,
|
||||
newEndTime: data.newEndTime ?? null,
|
||||
reason: data.reason,
|
||||
status: "pending",
|
||||
requestedBy,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function updateScheduleChangeStatus(
|
||||
id: string,
|
||||
status: "approved" | "rejected" | "completed",
|
||||
approverId: string
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(scheduleChanges)
|
||||
.set({ status, approvedBy: approverId, updatedAt: new Date() })
|
||||
.where(eq(scheduleChanges.id, id))
|
||||
}
|
||||
|
||||
export async function getClassConflicts(classId: string): Promise<ScheduleConflict[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: classSchedule.id,
|
||||
weekday: classSchedule.weekday,
|
||||
startTime: classSchedule.startTime,
|
||||
endTime: classSchedule.endTime,
|
||||
course: classSchedule.course,
|
||||
})
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.classId, classId))
|
||||
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
|
||||
|
||||
const conflicts: ScheduleConflict[] = []
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
for (let j = i + 1; j < rows.length; j += 1) {
|
||||
const a = rows[i]!
|
||||
const b = rows[j]!
|
||||
if (a.weekday !== b.weekday) continue
|
||||
// Time overlap: a.start < b.end && b.start < a.end
|
||||
if (a.startTime < b.endTime && b.startTime < a.endTime) {
|
||||
conflicts.push({
|
||||
type: "class_overlap",
|
||||
description: `Time overlap on day ${a.weekday}: "${a.course}" (${a.startTime}-${a.endTime}) vs "${b.course}" (${b.startTime}-${b.endTime})`,
|
||||
scheduleIds: [a.id, b.id],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// --- Helpers for scheduling pages ---
|
||||
|
||||
export async function getAdminClassesForScheduling() {
|
||||
return await db
|
||||
.select({ id: classes.id, name: classes.name, grade: classes.grade })
|
||||
.from(classes)
|
||||
.orderBy(classes.grade, classes.name)
|
||||
}
|
||||
|
||||
export async function getTeachersForScheduling() {
|
||||
return await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.innerJoin(classSubjectTeachers, eq(classSubjectTeachers.teacherId, users.id))
|
||||
.groupBy(users.id, users.name, users.email)
|
||||
.orderBy(users.name)
|
||||
}
|
||||
|
||||
export async function getClassroomsForScheduling() {
|
||||
return await db
|
||||
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
|
||||
.from(classrooms)
|
||||
.orderBy(classrooms.name)
|
||||
}
|
||||
|
||||
export async function getClassSubjectsForScheduling(classId: string) {
|
||||
return await db
|
||||
.select({
|
||||
subjectId: subjects.id,
|
||||
subjectName: subjects.name,
|
||||
teacherId: classSubjectTeachers.teacherId,
|
||||
})
|
||||
.from(classSubjectTeachers)
|
||||
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
|
||||
.where(eq(classSubjectTeachers.classId, classId))
|
||||
}
|
||||
Reference in New Issue
Block a user