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:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View 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))
}