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,97 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input"
type Option = { id: string; name: string }
interface AttendanceFiltersProps {
classes: Option[]
}
const STATUS_OPTIONS = [
{ value: "present", label: "Present" },
{ value: "absent", label: "Absent" },
{ value: "late", label: "Late" },
{ value: "early_leave", label: "Early Leave" },
{ value: "excused", label: "Excused" },
]
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
const router = useRouter()
const searchParams = useSearchParams()
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
<div className="grid gap-2">
<Label className="text-xs">Class</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All classes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All classes</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Status</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label className="text-xs">Date</Label>
<Input
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
className="h-9"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Trash2 } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { formatDate } from "@/shared/lib/utils"
import { deleteAttendanceAction } from "../actions"
import {
ATTENDANCE_STATUS_COLORS,
ATTENDANCE_STATUS_LABELS,
type AttendanceListItem,
} from "../types"
export function AttendanceRecordList({ records }: { records: AttendanceListItem[] }) {
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
const result = await deleteAttendanceAction(deleteId)
setIsDeleting(false)
if (result.success) {
toast.success(result.message)
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
}
}
if (records.length === 0) {
return (
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
No attendance records found.
</div>
)
}
return (
<>
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Remark</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
{ATTENDANCE_STATUS_LABELS[r.status]}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">
{r.remark ?? "-"}
</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Attendance Record</DialogTitle>
<DialogDescription>
Are you sure you want to delete this attendance record? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { saveAttendanceRulesAction } from "../actions"
import type { AttendanceRule } from "../types"
type Option = { id: string; name: string }
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Rules"}
</Button>
)
}
export function AttendanceRulesForm({
classes,
existingRules,
}: {
classes: Option[]
existingRules: AttendanceRule[]
}) {
const router = useRouter()
const [classId, setClassId] = useState(classes[0]?.id ?? "")
const [lateThreshold, setLateThreshold] = useState("15")
const [earlyLeaveThreshold, setEarlyLeaveThreshold] = useState("15")
const [enableAutoMark, setEnableAutoMark] = useState(false)
const handleClassChange = (id: string) => {
setClassId(id)
const rule = existingRules.find((r) => r.classId === id)
if (rule) {
setLateThreshold(String(rule.lateThresholdMinutes ?? 15))
setEarlyLeaveThreshold(String(rule.earlyLeaveThresholdMinutes ?? 15))
setEnableAutoMark(rule.enableAutoMark ?? false)
} else {
setLateThreshold("15")
setEarlyLeaveThreshold("15")
setEnableAutoMark(false)
}
}
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error("Please select a class")
return
}
formData.set("classId", classId)
formData.set("lateThresholdMinutes", lateThreshold)
formData.set("earlyLeaveThresholdMinutes", earlyLeaveThreshold)
formData.set("enableAutoMark", enableAutoMark ? "true" : "false")
const result = await saveAttendanceRulesAction(null, formData)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to save rules")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Rules</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid gap-2">
<Label>Class</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="lateThresholdMinutes">Late Threshold (minutes)</Label>
<Input
id="lateThresholdMinutes"
type="number"
min="0"
value={lateThreshold}
onChange={(e) => setLateThreshold(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="earlyLeaveThresholdMinutes">Early Leave Threshold (minutes)</Label>
<Input
id="earlyLeaveThresholdMinutes"
type="number"
min="0"
value={earlyLeaveThreshold}
onChange={(e) => setEarlyLeaveThreshold(e.target.value)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="enableAutoMark"
checked={enableAutoMark}
onCheckedChange={(v) => setEnableAutoMark(v === true)}
/>
<Label htmlFor="enableAutoMark" className="cursor-pointer">
Enable auto-marking (mark present automatically when student checks in on time)
</Label>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,215 @@
"use client"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { CalendarDays } from "lucide-react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { batchRecordAttendanceAction } from "../actions"
import {
ATTENDANCE_STATUS_LABELS,
type AttendanceStatus,
} from "../types"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
const STATUS_OPTIONS: AttendanceStatus[] = [
"present",
"absent",
"late",
"early_leave",
"excused",
]
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Attendance"}
</Button>
)
}
export function AttendanceSheet({
classes,
students,
defaultClassId,
defaultDate,
}: {
classes: Option[]
students: Student[]
defaultClassId?: string
defaultDate?: string
}) {
const router = useRouter()
const today = new Date().toISOString().slice(0, 10)
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [date, setDate] = useState(defaultDate ?? today)
const [statuses, setStatuses] = useState<Record<string, AttendanceStatus>>({})
const handleStatusChange = (studentId: string, status: AttendanceStatus) => {
setStatuses((prev) => ({ ...prev, [studentId]: status }))
}
const markAllPresent = () => {
const all: Record<string, AttendanceStatus> = {}
for (const s of students) all[s.id] = "present"
setStatuses(all)
}
const handleSubmit = async (formData: FormData) => {
if (!classId || !date) {
toast.error("Please select class and date")
return
}
const records = students.map((s) => ({
studentId: s.id,
classId,
date,
status: statuses[s.id] ?? "present",
}))
if (records.length === 0) {
toast.error("No students to record attendance for")
return
}
formData.set("recordsJson", JSON.stringify(records))
const result = await batchRecordAttendanceAction(null, formData)
if (result.success) {
toast.success(result.message)
router.push("/teacher/attendance")
router.refresh()
} else {
toast.error(result.message || "Failed to save attendance")
}
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Sheet</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Class</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="date">Date</Label>
<div className="relative">
<CalendarDays className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="pl-9"
required
/>
</div>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">
No students in this class. Select a class to load students.
</p>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{students.length} students
</p>
<Button type="button" variant="outline" size="sm" onClick={markAllPresent}>
Mark All Present
</Button>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-48">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell className="text-muted-foreground">{s.email}</TableCell>
<TableCell>
<Select
value={statuses[s.id] ?? "present"}
onValueChange={(v) => handleStatusChange(s.id, v as AttendanceStatus)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((st) => (
<SelectItem key={st} value={st}>
{ATTENDANCE_STATUS_LABELS[st]}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
</Button>
<SubmitButton />
</CardFooter>
</form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,100 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
Users,
CheckCircle2,
XCircle,
Clock,
LogOut,
FileText,
TrendingUp,
} from "lucide-react"
import type { AttendanceStats } from "../types"
interface StatItemProps {
label: string
value: string | number
icon: React.ReactNode
hint?: string
}
function StatItem({ label, value, icon, hint }: StatItemProps) {
return (
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<span className="text-muted-foreground">{icon}</span>
</div>
<span className="text-2xl font-bold">{value}</span>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</div>
)
}
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
if (!stats || stats.total === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Attendance Statistics</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No attendance data available.</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>Attendance Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Total Records"
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label="Present"
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label="Absent"
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label="Late"
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label="Early Leave"
value={stats.earlyLeave}
icon={<LogOut className="h-4 w-4" />}
/>
<StatItem
label="Excused"
value={stats.excused}
icon={<FileText className="h-4 w-4" />}
/>
<StatItem
label="Present Rate"
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
hint="Present / Total"
/>
<StatItem
label="Late Rate"
value={`${stats.lateRate.toFixed(1)}%`}
icon={<Clock className="h-4 w-4" />}
hint="Late / Total"
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,104 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
import { AttendanceStatsCard } from "./attendance-stats-card"
import {
ATTENDANCE_STATUS_COLORS,
ATTENDANCE_STATUS_LABELS,
type StudentAttendanceSummary,
} from "../types"
export function StudentAttendanceView({
summary,
}: {
summary: StudentAttendanceSummary | null
}) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student attendance summary is not available."
icon={CalendarCheck}
className="border-none shadow-none"
/>
)
}
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.stats.total}</p>
</CardContent>
</Card>
</div>
<AttendanceStatsCard stats={summary.stats} />
{summary.recentRecords.length === 0 ? (
<EmptyState
title="No attendance records"
description="There are no attendance records for this student yet."
icon={CalendarCheck}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Recent Attendance</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Class</TableHead>
<TableHead>Status</TableHead>
<TableHead>Remark</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.recentRecords.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.date}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>
<Badge variant={ATTENDANCE_STATUS_COLORS[r.status]} className="capitalize">
{ATTENDANCE_STATUS_LABELS[r.status]}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{r.remark ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}