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,83 @@
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function TeacherAttendancePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const status = getParam(sp, "status")
const date = getParam(sp, "date")
const classes = await getTeacherClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const result = await getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status: status && status !== "all" ? (status as "present" | "absent" | "late" | "early_leave" | "excused") : undefined,
date: date && date.length > 0 ? date : undefined,
})
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance</h2>
<p className="text-muted-foreground">Manage student attendance records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/attendance/stats">
<BarChart3 className="mr-2 h-4 w-4" />
Statistics
</Link>
</Button>
<Button asChild>
<Link href="/teacher/attendance/sheet">
<PlusCircle className="mr-2 h-4 w-4" />
Take Attendance
</Link>
</Button>
</div>
</div>
<AttendanceFilters classes={classOptions} />
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title="No attendance records"
description="Start by taking attendance for your classes."
icon={ClipboardList}
action={{
label: "Take Attendance",
href: "/teacher/attendance/sheet",
}}
/>
) : (
<AttendanceRecordList records={result.items} />
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"
import { AttendanceSheet } from "@/modules/attendance/components/attendance-sheet"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AttendanceSheetPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultDate = getParam(sp, "date")
const classes = await getTeacherClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForAttendance(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Take Attendance</h2>
<p className="text-muted-foreground">
Select a class and date, then mark attendance for each student.
</p>
</div>
<AttendanceSheet
classes={classOptions}
students={students}
defaultClassId={defaultClassId}
defaultDate={defaultDate}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function AttendanceStatsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const classId = getParam(sp, "classId")
const startDate = getParam(sp, "startDate")
const endDate = getParam(sp, "endDate")
const classes = await getTeacherClasses()
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<p className="text-muted-foreground">View class attendance statistics.</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const summary = await getClassAttendanceStats(
targetClassId,
startDate,
endDate
)
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Attendance Statistics</h2>
<p className="text-muted-foreground">View class attendance statistics and trends.</p>
</div>
<StatsClassSelector
classes={classOptions}
currentClassId={targetClassId}
startDate={startDate ?? ""}
endDate={endDate ?? ""}
/>
{summary ? (
<>
<AttendanceStatsCard stats={summary.stats} />
<div>
<h3 className="mb-4 text-lg font-semibold">Student Records</h3>
<AttendanceRecordList records={summary.studentRecords} />
</div>
</>
) : (
<EmptyState
title="No data"
description="No attendance data available for this class."
icon={BarChart3}
className="border-none shadow-none"
/>
)}
</div>
)
}
function StatsClassSelector({
classes,
currentClassId,
startDate,
endDate,
}: {
classes: Array<{ id: string; name: string }>
currentClassId: string
startDate: string
endDate: string
}) {
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/attendance/stats?classId=${c.id}${dateParams}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { notFound } from "next/navigation"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
export const dynamic = "force-dynamic"
export default async function TeacherCoursePlanDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const plan = await getCoursePlanById(id)
if (!plan) notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<CoursePlanDetail
plan={plan}
backHref="/teacher/course-plans"
/>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { auth } from "@/auth"
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import type { CoursePlanStatus } from "@/modules/course-plans/types"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const isValidStatus = (v?: string): v is CoursePlanStatus =>
v === "planning" || v === "active" || v === "completed" || v === "paused"
export default async function TeacherCoursePlansPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const session = await auth()
const teacherId = String(session?.user?.id ?? "")
const sp = await searchParams
const statusParam = getParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const plans = teacherId
? await getCoursePlans({ teacherId, status })
: []
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">My Course Plans</h2>
<p className="text-muted-foreground">
View your course teaching plans and weekly schedules.
</p>
</div>
<CoursePlanList
plans={plans}
detailHrefBuilder={(id) => `/teacher/course-plans/${id}`}
initialStatus={status}
/>
</div>
)
}

View File

@@ -0,0 +1,259 @@
import Link from "next/link"
import { BarChart3, ArrowLeft } from "lucide-react"
import { asc } from "drizzle-orm"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
import {
getClassComparison,
getGradeDistribution,
getGradeTrend,
getSubjectComparison,
} from "@/modules/grades/data-access-analytics"
import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function GradeAnalyticsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const gradeId = getParam(sp, "gradeId")
const [classes, allGrades, allSubjects] = await Promise.all([
getTeacherClasses(),
getGrades(),
db.query.subjects.findMany({
orderBy: [asc(subjects.order), asc(subjects.name)],
}),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const targetSubjectId =
subjectId && subjectId !== "all" ? subjectId : undefined
const targetGradeId = gradeId ?? allGrades[0]?.id
// Run analytics queries in parallel
const [trend, distribution, subjectComparison, classComparison] =
await Promise.all([
getGradeTrend({
classId: targetClassId,
subjectId: targetSubjectId,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getGradeDistribution({
classId: targetClassId,
subjectId: targetSubjectId,
scope: ctx.dataScope,
currentUserId: ctx.userId,
}),
getSubjectComparison({
classId: targetClassId,
scope: ctx.dataScope,
}),
targetGradeId
? getClassComparison({
gradeId: targetGradeId,
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
scope: ctx.dataScope,
})
: Promise.resolve([]),
])
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Analytics</h2>
<p className="text-muted-foreground">
Trend analysis, class comparisons, and score distributions.
</p>
</div>
<Button asChild variant="outline">
<Link href="/teacher/grades">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Grades
</Link>
</Button>
</div>
<AnalyticsFilters
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
currentClassId={targetClassId}
currentSubjectId={subjectId ?? "all"}
currentGradeId={targetGradeId ?? ""}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<GradeTrendChart data={trend} />
<GradeDistributionChart data={distribution} />
<SubjectComparisonChart data={subjectComparison} />
<ClassComparisonChart data={classComparison} />
</div>
</div>
)
}
interface AnalyticsFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
currentGradeId: string
}
function AnalyticsFilters({
classes,
grades,
subjects,
currentClassId,
currentSubjectId,
currentGradeId,
}: AnalyticsFiltersProps) {
const buildHref = (overrides: {
classId?: string
subjectId?: string
gradeId?: string
}) => {
const params = new URLSearchParams()
params.set(
"classId",
overrides.classId !== undefined ? overrides.classId : currentClassId
)
params.set(
"subjectId",
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
)
if (
overrides.gradeId !== undefined
? overrides.gradeId
: currentGradeId
) {
params.set(
"gradeId",
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
)
}
return `/teacher/grades/analytics?${params.toString()}`
}
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Class</div>
<div className="flex flex-wrap gap-1.5">
{classes.map((c) => (
<a
key={c.id}
href={buildHref({ classId: c.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{c.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Subject</div>
<div className="flex flex-wrap gap-1.5">
<a
href={buildHref({ subjectId: "all" })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
All
</a>
{subjects.map((s) => (
<a
key={s.id}
href={buildHref({ subjectId: s.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Grade (for class comparison)
</div>
<div className="flex flex-wrap gap-1.5">
{grades.map((g) => (
<a
key={g.id}
href={buildHref({ gradeId: g.id })}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
g.id === currentGradeId
? "border-primary bg-primary text-primary-foreground"
: "bg-background hover:bg-accent"
}`}
>
{g.name}
</a>
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function BatchEntryPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const defaultClassId = getParam(sp, "classId")
const defaultSubjectId = getParam(sp, "subjectId")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
let students: Array<{ id: string; name: string; email: string }> = []
if (defaultClassId) {
students = await getClassStudentsForEntry(defaultClassId)
}
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h2>
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
</div>
<BatchGradeEntry
classes={classOptions}
subjects={subjectOptions}
students={students}
defaultClassId={defaultClassId}
defaultSubjectId={defaultSubjectId}
/>
</div>
)
}

View File

@@ -0,0 +1,101 @@
import Link from "next/link"
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function TeacherGradesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const ctx = await getAuthContext()
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const type = getParam(sp, "type")
const semester = getParam(sp, "semester")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
const records = await getGradeRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
type: type && type !== "all" ? (type as "exam" | "quiz" | "homework" | "other") : undefined,
semester: semester && semester !== "all" ? (semester as "1" | "2") : undefined,
})
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grades</h2>
<p className="text-muted-foreground">Manage student grade records.</p>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/grades/stats">
<BarChart3 className="mr-2 h-4 w-4" />
Statistics
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/teacher/grades/entry">
<ClipboardList className="mr-2 h-4 w-4" />
Batch Entry
</Link>
</Button>
<ExportButton
classId={classId && classId !== "all" ? classId : ""}
subjectId={subjectId && subjectId !== "all" ? subjectId : undefined}
variant="outline"
/>
<Button asChild>
<Link href="/teacher/grades/entry">
<PlusCircle className="mr-2 h-4 w-4" />
Record Grades
</Link>
</Button>
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{records.length === 0 && !classId && !subjectId ? (
<EmptyState
title="No grade records"
description="Start by recording grades for your classes."
icon={ClipboardList}
action={{
label: "Record Grades",
href: "/teacher/grades/entry",
}}
/>
) : (
<GradeRecordList records={records} />
)}
</div>
)
}

View File

@@ -0,0 +1,139 @@
import { db } from "@/shared/db"
import { subjects } from "@/shared/db/schema"
import { asc } from "drizzle-orm"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassGradeStatsWithMeta, getClassRanking } from "@/modules/grades/data-access"
import { ClassGradeReport } from "@/modules/grades/components/class-grade-report"
import { ExportButton } from "@/modules/grades/components/export-button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { BarChart3 } from "lucide-react"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StatsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const sp = await searchParams
const classId = getParam(sp, "classId")
const subjectId = getParam(sp, "subjectId")
const [classes, allSubjects] = await Promise.all([
getTeacherClasses(),
db.query.subjects.findMany({ orderBy: [asc(subjects.order), asc(subjects.name)] }),
])
if (classes.length === 0) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<EmptyState
title="No classes"
description="You don't have any classes yet."
icon={BarChart3}
className="border-none shadow-none"
/>
</div>
)
}
const targetClassId = classId ?? classes[0].id
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
const [stats, ranking] = await Promise.all([
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
getClassRanking(targetClassId, targetSubjectId),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Grade Statistics</h2>
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
</div>
<ExportButton
classId={targetClassId}
subjectId={targetSubjectId}
variant="outline"
label="导出成绩"
/>
</div>
<StatsClassSelector
classes={classOptions}
subjects={subjectOptions}
currentClassId={targetClassId}
currentSubjectId={subjectId ?? "all"}
/>
<ClassGradeReport stats={stats} ranking={ranking} />
</div>
)
}
function StatsClassSelector({
classes,
subjects,
currentClassId,
currentSubjectId,
}: {
classes: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
currentClassId: string
currentSubjectId: string
}) {
return (
<div className="flex flex-wrap gap-2">
{classes.map((c) => (
<a
key={c.id}
href={`/teacher/grades/stats?classId=${c.id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
c.id === currentClassId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{c.name}
</a>
))}
<div className="ml-auto flex flex-wrap gap-2">
<a
href={`/teacher/grades/stats?classId=${currentClassId}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
currentSubjectId === "all"
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
All Subjects
</a>
{subjects.map((s) => (
<a
key={s.id}
href={`/teacher/grades/stats?classId=${currentClassId}&subjectId=${s.id}`}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
s.id === currentSubjectId
? "border-primary bg-primary text-primary-foreground"
: "bg-card hover:bg-accent"
}`}
>
{s.name}
</a>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAuthContext } from "@/shared/lib/auth-guard"
import {
getAdminClassesForScheduling,
getScheduleChanges,
getTeachersForScheduling,
} from "@/modules/scheduling/actions"
import { ScheduleChangeForm } from "@/modules/scheduling/components/schedule-change-form"
import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-change-list"
export const dynamic = "force-dynamic"
export default async function TeacherScheduleChangesPage() {
const ctx = await getAuthContext()
// Teachers see only their own requests; admins landing here see all.
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
const [classes, teachers, items] = await Promise.all([
getAdminClassesForScheduling(),
getTeachersForScheduling(),
getScheduleChanges({ requesterId }),
])
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
const teacherOptions = teachers.map((t) => ({
id: t.id,
name: t.name ?? "Unknown",
email: t.email ?? "",
}))
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">Schedule Change Requests</h2>
<p className="text-muted-foreground">
Submit a schedule change or substitute teacher request, and track its status.
</p>
</div>
{classOptions.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No classes available"
description="There are no classes available to request schedule changes for."
/>
) : (
<div className="space-y-8">
<ScheduleChangeForm classes={classOptions} teachers={teacherOptions} />
<div className="space-y-2">
<h3 className="text-lg font-semibold">My Requests</h3>
{items.length === 0 ? (
<EmptyState
icon={ClipboardList}
title="No requests yet"
description="Your submitted schedule change requests will appear here."
/>
) : (
<ScheduleChangeList items={items} canApprove={false} />
)}
</div>
</div>
)}
</div>
)
}