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,145 @@
import "server-only"
import { and, asc, desc, eq, gte, lte } from "drizzle-orm"
import { db } from "@/shared/db"
import { attendanceRecords, classes, users } from "@/shared/db/schema"
import type {
AttendanceListItem,
AttendanceStats,
ClassAttendanceSummary,
StudentAttendanceSummary,
} from "./types"
const EMPTY_STATS: AttendanceStats = {
total: 0,
present: 0,
absent: 0,
late: 0,
earlyLeave: 0,
excused: 0,
presentRate: 0,
lateRate: 0,
}
const computeStats = (rows: { status: string }[]): AttendanceStats => {
if (rows.length === 0) return EMPTY_STATS
const stats: AttendanceStats = { ...EMPTY_STATS, total: rows.length }
for (const r of rows) {
if (r.status === "present") stats.present += 1
else if (r.status === "absent") stats.absent += 1
else if (r.status === "late") stats.late += 1
else if (r.status === "early_leave") stats.earlyLeave += 1
else if (r.status === "excused") stats.excused += 1
}
stats.presentRate = Math.round((stats.present / stats.total) * 10000) / 100
stats.lateRate = Math.round((stats.late / stats.total) * 10000) / 100
return stats
}
const serializeDate = (d: Date | string | null): string =>
d ? new Date(d).toISOString().slice(0, 10) : ""
export async function getStudentAttendanceSummary(
studentId: string,
startDate?: string,
endDate?: string
): Promise<StudentAttendanceSummary | null> {
const [student] = await db
.select({ name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
const conditions = [eq(attendanceRecords.studentId, studentId)]
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
const rows = await db
.select({
record: attendanceRecords,
className: classes.name,
})
.from(attendanceRecords)
.leftJoin(classes, eq(classes.id, attendanceRecords.classId))
.where(and(...conditions))
.orderBy(desc(attendanceRecords.date))
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
const recentRecords: AttendanceListItem[] = rows.slice(0, 20).map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: student.name ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
remark: r.record.remark ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
studentId,
studentName: student.name ?? "Unknown",
stats,
recentRecords,
}
}
export async function getClassAttendanceStats(
classId: string,
startDate?: string,
endDate?: string
): Promise<ClassAttendanceSummary | null> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return null
const conditions = [eq(attendanceRecords.classId, classId)]
if (startDate) conditions.push(gte(attendanceRecords.date, new Date(startDate)))
if (endDate) conditions.push(lte(attendanceRecords.date, new Date(endDate)))
const rows = await db
.select({
record: attendanceRecords,
studentName: users.name,
})
.from(attendanceRecords)
.leftJoin(users, eq(users.id, attendanceRecords.studentId))
.where(and(...conditions))
.orderBy(asc(users.name))
const stats = computeStats(rows.map((r) => ({ status: r.record.status })))
const studentRecords: AttendanceListItem[] = rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown",
classId: r.record.classId,
className: classRow.name,
scheduleId: r.record.scheduleId ?? null,
date: serializeDate(r.record.date),
status: r.record.status,
remark: r.record.remark ?? null,
recordedBy: r.record.recordedBy,
recorderName: "Unknown",
createdAt: r.record.createdAt.toISOString(),
}))
return {
classId,
className: classRow.name,
date: startDate ?? endDate ?? new Date().toISOString().slice(0, 10),
stats,
studentRecords,
}
}